Live data from Hacker News

Sans-IO: The secret to effective Rust for network services

firezone.dev

71–80 of 89 posts

Re: Sans-IO: The secret to effective Rust for network services

#71
This article / idea really refactors two things out of some IO code

- the event loop

- the state machine of data states that occur

But async rust is already a state machine, so the stun binding could be expressed as a 3 line async function that is fairly close to sans-io (if you don't consider relying on abstractions like Stream and Sink to be IO).

    async fn stun(
        server: SocketAddr,
        mut socket: impl Sink
            + Stream>
            + Unpin
            + Send
            + 'static,
    ) -> Result {
        socket.send((BindingRequest, server)).await?;
        let (message, _server) = socket.next().await.ok_or_eyre("No response")??;
        Ok(message.address)
    }
If you look at how the underlying async primitives are implemented, they look pretty similar to what you;ve implemented. sink.send is just a future for Option, a future is just something that can be polled at some later point, which is exactly equivalent to your event loop constructing the StunBinding and then calling poll_transmit to get the next message. And the same goes with the stream.next call, it's the same as setting up a state machine that only proceeds when there is a next item that is being fed to it. The Tokio runtime is your event loop, but just generalized.

Restated simply: stun function above returns a future that that combines the same methods you have with a contract about how that interacts with a standard async event loop.

The above is testable without hitting the network. Just construct the test Stream / Sink yourself. It also easily composes to add timeouts etc. To make it work with the network instead pass in a UdpFramed (and implement codecs to convert the messages to / from bytes).

Adding timeout can be either composed from the outside caller if it's a timeout imposed by the application, or inside the function if it's a timeout you want to configure on the call. This can be tested using tokio test-utils and pausing / advancing the time in your tests.

---

The problem with the approach suggested in the article is that it splits the flow (event loop) and logic (statemachine) from places where the flow is the logic (send a stun binding request, get an answer).

Yes, there's arguments to be made about not wanting to use async await, but when you effectively create your own custom copy of async await, just without the syntactic sugar, and without the various benefits (threading, composability, ...), it's worth considering whether you could use async instead.

Re: Sans-IO: The secret to effective Rust for network services

#72
post #69

Earlier quoted context omitted.

If you want a web protocol, try oauth2. There's complexity in the number of things you can support, but in essence there's a state machine that can be modeled.

Ahh didn't even think of that level of the stack… It is true that the OAuth2 tango can be represented by a state machine… I’d probably do CAS instead, it’s simpler IMO.

The Stun protocol is surprisingly easy to implement, but see my comment at https://news.ycombinator.com/item?id=40879547 about why I'd just use async instead of making my own event loop system.

https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...

Re: Sans-IO: The secret to effective Rust for network services

#73
post #18

> Also, sequential workflows require more code to be written. In Rust, async functions compile down to state machines, with each .await point representing a transition to a different state. This makes it easy for developers to write sequential code together with non-blocking IO. Without async, we need to write our own state machines for expressing the various steps. Has anyone tried to combine async and sans-io? At l…

https://news.ycombinator.com/item?id=40879547

    async fn stun(
        server: SocketAddr,
        mut socket: impl Sink
            + Stream>
            + Unpin
            + Send
            + 'static,
    ) -> Result {
        socket.send((BindingRequest, server)).await?;
        let (message, _server) = socket.next().await.ok_or_eyre("No response")??;
        Ok(message.address)
    }
Fully working code at https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...

Re: Sans-IO: The secret to effective Rust for network services

#74
post #72

Earlier quoted context omitted.

Ahh didn't even think of that level of the stack… It is true that the OAuth2 tango can be represented by a state machine… I’d probably do CAS instead, it’s simpler IMO.

The Stun protocol is surprisingly easy to implement, but see my comment at https://news.ycombinator.com/item?id=40879547 about why I'd just use async instead of making my own event loop system. https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...

Thanks for the code! Going to pore over this.

I read the comment and I definitely agree (though it took me a while to get to where you landed), I think there are some benefits:

- More controllable/easy to reason about cancel safety (though this gets pushed up the stack somewhat). You just can't cancel a thread, but it turns out a ton of places in an async function are cancel points (everywhere you or some function calls .await, most obviuosly), and that can cause surprising problems.

- Ability to easily slap on both sync and async shells (I personally think it's not unforgivable to smuggle a tokio current thread runtime in as a dep and use block_on for async things internally, since callers are none the wiser)

Great comment though, very succinctly explained what I was getting at... I personally land on the "just make everything async" side of things. Not necessarily everything should be Send + Sync, but similar to Option/Result, I'd rather just start using async everywhere than try to make a sync world work.

There's also libraries like agnostic[0] that make it somewhat easier to support multiple runtimes (though I've done it in the past with feature flags).

> The problem with the approach suggested in the article is that it splits the flow (event loop) and logic (statemachine) from places where the flow is the logic (send a stun binding request, get an answer).

Very concisely put -- If I'm understanding OP's point of view, the answer to this might be "don't make the flow the logic"? basically rather encoding the flow as a state machine and passing that up to an upper event loop (essentially requiring the upper layer to do it).

Feels like there are at least 3 points in this design space:

- Sync only state machines (event loop must be at the outermost layer) - Sync state machines with possibly internal async (event loops could be anywhere) - Async everything (event loops are everywhere)

[0]: https://crates.io/crates/agnostic

Re: Sans-IO: The secret to effective Rust for network services

#75
post #72

Earlier quoted context omitted.

Ahh didn't even think of that level of the stack… It is true that the OAuth2 tango can be represented by a state machine… I’d probably do CAS instead, it’s simpler IMO.

The Stun protocol is surprisingly easy to implement, but see my comment at https://news.ycombinator.com/item?id=40879547 about why I'd just use async instead of making my own event loop system. https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...

also a bit late, but you've seen anyhow & miette right? noticed the color_eyre usage and was just wondering

Re: Sans-IO: The secret to effective Rust for network services

#76
post #72

Earlier quoted context omitted.

The Stun protocol is surprisingly easy to implement, but see my comment at https://news.ycombinator.com/item?id=40879547 about why I'd just use async instead of making my own event loop system. https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...

also a bit late, but you've seen anyhow & miette right? noticed the color_eyre usage and was just wondering

yep - color_eyre is a better anyhow (and there's plans afoot to merge them into just one at some point[1]). Miette occupies a space that I generally don't need (except when processing data), while color-eyre is in the goldilocks zone.

[1]: https://github.com/eyre-rs/eyre/issues/177

Re: Sans-IO: The secret to effective Rust for network services

#77
post #76

Earlier quoted context omitted.

also a bit late, but you've seen anyhow & miette right? noticed the color_eyre usage and was just wondering

yep - color_eyre is a better anyhow (and there's plans afoot to merge them into just one at some point[1]). Miette occupies a space that I generally don't need (except when processing data), while color-eyre is in the goldilocks zone. [1]: https://github.com/eyre-rs/eyre/issues/177

Thanks for the pointer! Rustaceans are spoiled for choice with good error handling and libraries, great to have so many great choices.

Re: Sans-IO: The secret to effective Rust for network services

#78

Earlier quoted context omitted.

> I don't think that's quite true. The lift here is that the state machine does not do any IO on its own. Here is a simple counter example. Suppose you have to process a packet that contains many sequences (strings/binary blobs) prefixed by 4 bytes of length. You are not always guaranteed to get the length bytes or the string all in one go. In a sequential system you'd accumulate the string as follows handle_input(..…

I'm not sure what part of that is supposed to be a pain. The sans-io equivalent would be: handle_input(buf) -> Result { if len(buf) where the semantics of `Error::IncompletePacket` are that the caller reads more into the buffer from its actual IO layer and then calls handle_input() again. So your "while not received required bytes: accumulate in buf" simply become "if len < required: return Error::IncompletePacket"

Fair enough. So let's complicate it a little. If you have hierarchical variable sized structures within structures (e.g. java class file), then you need a stack of work in progress (pointers plus length) at every level. In fact, the moment you need a stack to simulate what would otherwise have been a series of function calls, it becomes a pain.

Or let's say you have a loop ("retry three times before giving up"), then you have to store the index in a recoverable struct. Put this inside a nested loop, and you know what I mean.

I have run into these situations enough that a flat state machine becomes a pain to deal with.

These are nicely solved using coroutines. That way you can have function related temporary state, IO-related state and stacks all taken care of simply.

Re: Sans-IO: The secret to effective Rust for network services

#80
post #12

Earlier quoted context omitted.

Yep. The only things about async that bothers me is the need to write ".await" everywhere. I wish there'd be a way to inverse this, and actually just run ".await" by default, while having a special construct not to.

It’s important to be able to see where the async function might pause execution. For example, if you’re holding a mutex lock, you probably want to avoid holding it “across” an await point so that it’s not locked for longer than necessary if the function is paused.

I disagree. It should be a compiler warning, maybe a "clippy" one, in such cases.

Btw the problem of sync code blocking async code is very real and also needs to be resolved, adding explicit `.blocking` to every blocking call is just as bad as explicit .await at every line.

Also, I like Haskell approach of being able to introduce syntax extensions at a file-level, so that for code that'd benefit from explicit await – I'd rather let author have it explicit.

Post reply on HN