Designing a stream-multiplexer API
nfltr connects machines through a relay they dial out to, and every TCP tunnel, end-to-end encrypted share and forwarded request between an agent and the relay, or between two relay replicas, is a session of link, a stream multiplexer: many independent byte streams over one connection. The wire part of a multiplexer is well understood: frames, stream ids, per-stream windows, a ping. The Go API on top of it is where libraries differ, and where a mistake lasts as long as the module's major version. Before freezing link (Apache-2.0, standard library only) at v1, we read the exported APIs of hashicorp/yamux v0.1.2, libp2p/go-yamux v5.1.0, xtaci/smux v1.5.57, quic-go v0.63.0, libp2p/go-mplex v0.7.0, go-libp2p v0.50.0, golang.org/x/net v0.61.0's http2, and the standard library's net, and compared them decision by decision. This post goes through those decisions, what link chose in v0.9.0, and what it gave up for it.
What the review found: the wire was fine and the API had six things that should not be frozen at v1. Open returned a bare net.Conn, so half-close needed a type assertion; Mux.Open took a callback no caller used; drain had three different shapes; an internal queue package and an embedded type leaked into the exported surface; and an interface link calls could not grow. v0.9.0 changed all six with no byte changed on the wire, and fixed a write deadline that had been accepted and ignored. What link chose not to do (reset codes, priorities, auto-tuned windows, CloseRead) is listed with its cost at the end.
What the API review found
- Six things that should not be frozen at v1, all changed in v0.9.0 with no byte changed on the wire: what
Openreturns,Mux.Open's unused callback, three shapes of drain, an internal queue package and an embedded type in the exported surface, and an interface link calls that could not grow. - A write deadline that was accepted and ignored:
SetWriteDeadlinereturnedniland did nothing until v0.9.0. - Kept as they were: a zero-value
Configchecked byValidate, aServecallback instead of anAcceptloop, and a fixed window per session. - Left out, with their cost: reset error codes, stream priorities, auto-tuned windows and
CloseRead. - Three patch releases after v0.9.0, all about drain, found under load by the conformance suite and the relay's tests.
- Short of the v1 targets when written: 4.5 of 24 fuzzing hours and 2.35 of 24 soak hours.
Why nfltr needed a multiplexer at all, and how link compares with yamux on throughput, memory and failure detection, is in an earlier post: link: why relay replicas stopped talking gRPC. This one is about the API only. v0.9.0 changed no byte on the wire: its test vectors and golden recording are those of v0.8.1. When this post was written, nfltr.xyz ran link v0.9.3, the API described here plus three patch releases, since nfltr v2.1.4.
What Open returns
Every library answers the same question first: what does a caller get back when it opens a stream, and what can it do with it?
| Library | Open | Half-close | Reset |
|---|---|---|---|
| net | Dial(...) (net.Conn, error) | (*TCPConn).CloseWrite(), after a type assertion | SetLinger(0) |
| hashicorp/yamux | Open() (net.Conn, error), OpenStream() (*Stream, error) | Stream.Close sends FIN; no CloseWrite | none exported |
| go-yamux v5 | OpenStream(ctx) (*Stream, error) | CloseWrite, CloseRead | Reset, ResetWithError(uint32) |
| xtaci/smux | Open() (io.ReadWriteCloser, error), OpenStream() (*Stream, error) | CloseWrite() | none |
| quic-go | OpenStream() (*Stream, error), OpenStreamSync(ctx) | Close() closes the send side only | CancelRead, CancelWrite(StreamErrorCode) |
| go-libp2p | Host.NewStream(ctx, peer, pids...) (network.Stream, error), an interface | CloseWrite, CloseRead | Reset, ResetWithError |
| link v0.9.0 | Link.Open(ctx, peer, header) (peerlink.Session, error) | CloseWrite() | Reset() |
Evidence: the comparison is read from each library's exported API
- Setup
- link v0.8.1 and its
peerlink/quiclinkv0.2.1 andpeerlink/grpclinkv0.2.0, read throughgo doc -all, the README, recipes, changelog and security notes; the eight other APIs at the versions named above, fromgo doc; link's callers checked in the vendored relay SDK and relay-server; one review, October 15, 2026. - Numbers
- Six changes judged v1 blockers; eight call sites of
Mux.Open, none using the session id its callback received; three session wrappers in the relay re-forwardingCloseWriteby assertion. - Reproduce
- (a) In a scratch Go module,
go geteach library at its version and readgo doc -all <package>. link's public module path starts at v0.10.17 (what is open), so v0.8.1 and v0.9.0 can no longer be downloaded; thev0.9.0entry of CHANGELOG.md in github.com/nfltr/link has a before-and-after line for each change, andmake check-apithere fails on an exported API change that itsapi/directory does not list. (b) The call-site counts are our record, from the closed relay-server repository and the vendored relay SDK. - Limits
- A reading of documentation and signatures, not a benchmark: it says nothing about performance (part 1 measures that against yamux), and the call-site counts cover our own code only.
The concrete libraries offer two forms: a net.Conn for drop-in use, and the concrete type with every method. go-libp2p is the only one that abstracts over transports, as link's peerlink.Link does (it has implementations over TCP or TLS, QUIC and gRPC), and it returns a rich interface rather than a bare net.Conn. link now does the same.
Why half-close matters
Request and reply on one stream needs a way to say "the request is complete" without closing the stream. Without it, the protocol inside the stream needs a length prefix or a delimiter, or the opener closes the whole stream and cannot read the answer. With half-close the opener sends its request, closes its write side, and keeps reading.
CloseWrite, not with a length prefix, and the reply still arrives.link's quick start does exactly this. Up to v0.8.1, peerlink.Link.Open returned a net.Conn, so the quick start had to write s.(peerlink.Session).CloseWrite(). A type assertion is the least discoverable API in Go, and an unchecked one panics on a third-party Link that breaks the contract. The cost was real in our own code: the relay had three wrappers around a session (one counting bytes, one tying it to a context, one copying and then closing the write side), and each re-forwarded CloseWrite through an interface{ CloseWrite() error } assertion. A wrapper that forgets one loses half-close silently. A wrapper that embeds peerlink.Session gets the method from the compiler. The same rule holds one layer down: since v0.9.1, link.Client and link.Server document that the net.Conn they are given should have CloseWrite, as a wrapper of a *net.TCPConn or *tls.Conn passes it on. Without it a graceful close closes the whole connection, and a TCP connection closed while bytes still arrive resets, losing what was in flight to the peer.
v0.9.0 returns peerlink.Session from Open and passes it to Serve's callback and to Listener.Handle. It is net.Conn plus CloseWrite and Reset, so it still goes to http.Transport, grpc.WithContextDialer and io.Copy unchanged:
// v0.8.1
node.Serve(func(peer string, h []byte, s net.Conn) { s.(peerlink.Session).CloseWrite() })
// v0.9.0
node.Serve(func(peer string, h []byte, s peerlink.Session) { s.CloseWrite() })
Two costs come with it. Go function types are not covariant, so an adapter that wants a func(ctx, addr) (net.Conn, error) needs a one-line closure. And an interface that callers call cannot gain methods after v1 without breaking every implementation, so peerlink.Session will never grow ResetWithError, ID or anything else; new capabilities arrive as optional interfaces on the concrete types. A concrete struct was not an option: Link has three implementations.
The lower-level Mux.Open changed for a smaller reason. It took a callback, func(sid string) ([]byte, error), so the header could carry the new session's id. All eight call sites (the pool, the node, one in the relay, five tests and examples) ignored the id and returned a fixed header. It is now Mux.Open(header []byte) (*Session, error). If a caller needs the id later, (*Session).ID() can be added without breaking anyone; yamux, smux, quic-go and libp2p all have one.
Configuration
Four designs are in use:
// hashicorp/yamux, xtaci/smux: a pointer to the defaults, checked by the constructor
cfg := yamux.DefaultConfig()
cfg.KeepAliveInterval = 10 * time.Second
sess, err := yamux.Client(conn, cfg)
// quic-go: nil, or zero fields, mean the default
c, err := quic.DialAddr(ctx, addr, tlsConf, &quic.Config{MaxIdleTimeout: 30 * time.Second})
// functional options, as in grpc-go
cc, err := grpc.NewClient(target, grpc.WithKeepaliveParams(keepalive.ClientParameters{Time: 10 * time.Second}))
// link: a value whose zero is the default, checked by the constructor
c, err := link.Client(ctx, conn, link.Config{Liveness: link.LivenessFast})
link kept the last one, and the review found no reason to change it. With yamux and smux a &Config{} is invalid (smux's has version 0), so every caller starts from DefaultConfig(), and a pointer can be changed after the session started. Functional options add one exported function per setting, and the settings cannot be printed, compared or read as one struct in godoc. A value type whose zero value is the default has neither problem. Config.Validate, PoolConfig.Validate and the constructors return an error that names the field to change; yamux's equivalent says "keep-alive interval must be positive".
Two fields treat 0 as "the default" (MaxSessions is 1024, a pool's Max is 1). That loses nothing, because 0 is never a valid value for either.
A Serve callback or an Accept loop
net.Listener, yamux, smux, quic-go's AcceptStream(ctx) and go-mplex hand the caller an Accept loop. http.Server, http2.Server.ServeConn and go-libp2p's SetStreamHandler(pid, func(network.Stream)) take a handler instead. link takes a handler: Serve(func(peer string, header []byte, s peerlink.Session)).
For the callback: the library runs one goroutine per session, so a slow handler cannot hold the receive loop or the next accept. The peer's name, the header and the session arrive together, with no tuple type, and the library decides when shutdown has finished. Against it: a caller cannot select over accepts or put its own admission queue in front of them; the only accept-side limit is Config.MaxSessions. Code that needs an Accept (http.Server.Serve, grpc.Server.Serve) uses peerlink.Listener, which adapts the callback to a net.Listener, so one primitive covers both styles.
One sharp edge remains at the low level. On a bare link.Mux, Serve is also the receive loop, so a side that only dials must still call Serve(nil), or credit never returns and its writes stall. yamux and smux start their receive goroutine inside Client and Server. link leaves goroutine ownership to the caller and, since v0.9.0, says so in the first line of NewMux's documentation. Pool and peerlink.Node run it for you.
Errors
| Library | Connection dead | Stream reset | Timeout |
|---|---|---|---|
| hashicorp/yamux | ErrSessionShutdown, ErrKeepAliveTimeout | ErrConnectionReset | ErrTimeout, a net.Error |
| go-yamux v5 | *Error sentinels (net.Error) | *StreamError{ErrorCode, Remote} | Timeout() |
| quic-go | *IdleTimeoutError (Timeout() true), *ApplicationError | *StreamError{StreamID, ErrorCode, Remote} | os.ErrDeadlineExceeded |
| x/net/http2 | ConnectionError(ErrCode), GoAwayError | StreamError{StreamID, Code, Cause} | none |
| link | ErrConnClosed, wrapping why (ErrDeadPeer, the network's error) | ErrReset | os.ErrDeadlineExceeded |
link's errors are sentinels tested with errors.Is, plus one typed error, *VersionError, for a peer that speaks a later wire version. Each wraps its cause, and each call documents which ones it returns. The rule we care most about: io.EOF means the peer ended its writes on purpose, never that the connection was cut. A cut connection reads as ErrConnClosed, and a write after it returns io.ErrClosedPipe wrapping the reason, as smux does. A reader copying a file can tell a complete file from a truncated one.
v0.9.0 made two changes. ErrDeadPeer is now a net.Error whose Timeout() is true, following quic-go's IdleTimeoutError, because net/http, gRPC and retry libraries classify errors by Timeout(); errors.Is(err, link.ErrDeadPeer) still matches. And ErrV1, a name kept for a wire version removed in v0.5.0, is gone: a version-1 peer reads as ErrVersion.
What link did not add: error codes on reset. go-libp2p's ResetWithError, quic-go's CancelWrite(code) and HTTP/2's RST_STREAM codes carry an application code; link's reset carries none, and adding one is a wire change. An application can write its own error into the stream before closing it. If codes come later, they arrive as an optional interface on the concrete types, because peerlink.Session cannot grow.
Graceful drain
| Library | Hard close | Graceful |
|---|---|---|
| net/http | Server.Close() | Server.Shutdown(ctx) |
| hashicorp/yamux | Session.Close() | GoAway(), returns at once |
| xtaci/smux | Close() | none |
| quic-go | CloseWithError(code, desc) | none at the transport (HTTP/3 has GOAWAY) |
| link v0.9.0 | Mux.Close, Pool.Close | Mux.Drain(ctx), Pool.Drain(ctx), Node.Drain(ctx) |
How link's drain works on the wire (a GOAWAY, then a ping that fences opens which crossed it) is in the earlier post. The API problem was shape: up to v0.8.1 there were three, a duration plus a reason on Conn, a time.Time on Mux.GoAway, and a context on Pool and Node. v0.9.0 has one, Drain(ctx) error, the shape of http.Server.Shutdown: it blocks until the connection closed, and when the context ends it closes at once and returns the context's error. The context's deadline goes into the GOAWAY, so the peer knows how long it has. A caller that wants the old fire-and-forget behaviour writes go m.Drain(ctx). Conn.GoAway stays at the frame level, for callers who run their own loop.
peerlink.Link has no Close: a Drain whose context is already done closes at once. A Close() error on the concrete Node types, as on http.Server, can be added at any time without breaking anyone; adding it to the interface later could not.
The patch releases after v0.9.0 were all about drain, with no API or wire change. The conformance suite's drain check, peerlinktest's DrainAccepter, and the relay's own tests found them under load: Pool.Close cut the last bytes of sessions that had already ended, and now lets an idle connection finish sending before it closes (v0.9.1); the QUIC implementation's Open toward a peer that is draining every connection now fails at once with ErrGoAway instead of waiting; and peerlink.Node.Open (v0.9.2) and Pool.Open (v0.9.3) pass over a connection whose writes already failed instead of returning that error.
Write deadlines and credit
Each link session has its own window: the reader grants the writer credit for as many bytes as it will buffer, and grants more as its application reads. A session whose reader stops reading stops its writer and nobody else. That property is what link exists for, and the earlier post measured it.
The review found a hole next to it. Up to v0.8.1, (*Session).SetWriteDeadline returned nil and did nothing, and SetDeadline set only the read side. net.Conn documents both deadlines, every library in the comparison implements them, and http.Server.WriteTimeout relies on them, including in link's own recipe for HTTP over a link. A writer facing a reader that had stopped would wait for credit forever while its deadline said otherwise.
Since v0.9.0 a Write returns os.ErrDeadlineExceeded, with the count of bytes it did send, once the deadline passes while it waits for credit or for room in the connection's write buffer. While a deadline is set, the network write runs from a goroutine, so no Write waits on the network itself either. It was not a breaking change; it made an existing method do what its documentation already said.
The knobs stay few. The libraries compared offer a lot here:
| Library | Per stream | Per connection | Auto-tuning |
|---|---|---|---|
| hashicorp/yamux | MaxStreamWindowSize | none | grows to the maximum |
| go-yamux v5 | initial and maximum window | MemoryManager | yes, from the round-trip time |
| xtaci/smux | MaxStreamBuffer | MaxReceiveBuffer | no |
| quic-go | initial and maximum window | initial and maximum, plus a callback to grow it | yes |
| x/net/http2 | MaxUploadBufferPerStream | MaxUploadBufferPerConnection | no |
| link | Config.Window, fixed | bounded by MaxSessions × Window | no |
link has a fixed window (1 MiB by default) and a session cap. That is predictable, and its worst case, 1 GiB of buffer per connection with the defaults, is written down in the security notes. A per-connection budget could come later as a Config field enforced locally by withholding grants, with no wire change; auto-tuning could come later with no API change. Neither blocks v1. The price today is a window that must be sized for the path by the caller.
Keeping the v1 surface small
Counted at v0.8.1, before the cleanup:
| Library | Packages | Exported functions and methods |
|---|---|---|
| hashicorp/yamux | 1 | 33 |
| go-yamux v5 | 1 | 42 |
| xtaci/smux | 1 | 31 |
| go-mplex | 1 | 17 |
| quic-go | many | hundreds |
| link | root, peerlink, peerlinktest (and bytequeue, then) | about 126 (76 in the root, 7 of them on NopObserver) |
Evidence: link exported about 126 functions and methods at v0.8.1
- Setup
- Exported functions and methods counted in
go doc -alloutput for each module at the versions above; link at v0.8.1, its root package plusbytequeue,peerlink,peerlinktestand the two transport modules; October 15, 2026. - Numbers
- link: 76 in the root (7 on
NopObserver), 12 inbytequeue, 10 inpeerlink, 12 inpeerlinktest, about 126 across the main module, plus 11 in quiclink and 5 in grpclink. hashicorp/yamux 33, go-yamux v5 42, smux 31, go-mplex 17. - Reproduce
- (a) In a scratch module,
go geteach other library at its version, thengo doc -all <package>and count thefunclines. link v0.8.1 is no longer downloadable (published versions start at v0.10.17), so its count is our record;go doc -all github.com/nfltr/linkcounts today's surface the same way. - Limits
- A hand count of functions and methods, not types or fields, so "about"; quic-go was not counted. Size is a proxy for what v1 freezes, not for quality.
link is larger than the single-package multiplexers because its root package holds three tiers: the handshake, frames and credit for callers that run their own receive loop, and sessions. Most callers never see the lower two; the README sends newcomers to peerlink. The work before v1 was to take out of the surface what should never have been frozen.
Connembedded*Credit. Embedding put everyCreditmethod (Acquire,Grant,Received,Forgetand more) onConn's method set, andCredit.Closewas hidden behindConn.Closeonly because the latter is shallower. It is now an accessor,Conn.Credit().bytequeuewas a public package. Its API (PopUnless(*atomic.Bool), aTryPopBatchwith three results,Wake) is the receive path's internals. Freezing it would have frozen the receive path. It isinternal/bytequeuenow. The README's own v1 checklist has the rule: no exported name that exists only for one caller.Observeris an interface link calls. Adding a method to an exported interface breaks every implementation, and new events (bytes, resets, handshake failures) are likely. We considered a struct of optional functions, ashttptrace.ClientTracedoes, and chose grpc-go's pattern instead: the interface has an unexported method thatNopObserverprovides, so implementations must embedNopObserver, and the stability policy saysObservermay gain methods in minor releases.
The stability policy now separates the two kinds of interface. Interfaces link calls (Observer) may grow. Interfaces callers call (peerlink.Link, peerlink.Session, Authenticator) never change, and new capabilities arrive as optional interfaces. The conformance suite, peerlinktest, may add checks in minor releases: a test suite that cannot get stricter is not much use. The QUIC and gRPC implementations are separate modules and stay v0 while their transport's module does, because quiclink's API exposes quic-go's types.
What link left out
CloseRead. libp2p, mplex and go-yamux have it, and its meaning differs between them (libp2p calls it implementation-defined). link'sClosecovers both directions.- Stream priorities. quic-go has
SetPriority; HTTP/2's priority tree is deprecated by RFC 9113. link schedules sessions fairly, and priorities would need wire support with no caller asking for them. - Names on open. mplex has
NewNamedStream(ctx, name)and libp2p has protocol ids. link has an opaqueheader []byteon every open, which is more general; the contract guarantees 4 KiB of it. - Any
io.ReadWriteCloserunderneath. yamux and smux accept one. link takes anet.Conn, because it needs deadlines during the handshake and the TLS connection state for certificate authentication;net.Pipecovers in-process use.
The trade-offs, in one place
| Decision | What it gives | What it costs |
|---|---|---|
Open returns peerlink.Session | Half-close and reset without an assertion; wrappers checked by the compiler | The interface is frozen at v1; dialer adapters need a closure |
Zero-value Config plus Validate | No invalid zero value, no shared mutable pointer, errors that name the field | Settings that need a non-zero default must treat 0 as "default" |
Serve callback | A goroutine per session; no accept loop to get wrong | No select over accepts; admission only through MaxSessions |
Drain(ctx) everywhere | One shape, the same as http.Server.Shutdown | It blocks; fire-and-forget needs go |
| Fixed window, session cap | Predictable memory, one number to tune | No auto-tuning; the window must be sized for the path |
| No reset codes | No wire change | Applications put their own error in the stream |
Observer embeds NopObserver | New events without a new major version | Implementations cannot be a bare type |
The review put the migration of our own code at about twelve files across the relay's two repositories, every one of them a compile error, with no behaviour change except that write deadlines became real. That migration shipped with nfltr v2.1.4. The changelog for v0.9.0 has a before-and-after line for each change.
What is next
v1.0.0 freezes the API once the README's maturity table holds. The peerlink contract has to stay unchanged for two minor releases. Fuzzing stands at 4.5 cumulative hours without a crasher in the library against a target of 24, and the soak under faults at 2.35 hours against 24. The security review of the handshake was done on 14 October. An apidiff gate in CI already fails any API change that is not listed in the repository, so a break between now and then is a decision, not an accident. Since this post link has moved on: v0.10 added chaining sessions across hops, the repository became public on 2026-10-29 at v0.10.17 (what is open), and v1 has not been tagged.
Evidence: link's maturity against its v1 targets
- Setup
- link's README maturity table at v0.9.x, October 15, 2026: cumulative hours of its fuzz targets and of its own soak (two in-process pairs per transport, link over TCP and quiclink over UDP, with cuts, throttles and black holes).
- Numbers
- Fuzzing 4.5 hours without a crasher in the library, against 24. Soak under faults 2.35 hours, against 24. Handshake security review: done, October 14.
- Reproduce
- (a) In github.com/nfltr/link (Apache-2.0):
make fuzz-long(every target forFUZZTIME, 30 minutes by default; the run is recorded in FUZZING.md) andmake soak SOAKTIME=24h(1 hour by default; the JSON summary goes toSOAKOUT).make checkis the per-push gate. - Limits
- Hours are cumulative across runs, on developer machines; the soak is in process, so it tests link's invariants in isolation, not on a real network. These are the figures of the day, not today's.
Is it for you?
Fits if you are designing or choosing a Go multiplexer and want the trade-offs laid out against yamux, smux, quic-go and libp2p, or you want a library that returns a session with CloseWrite and Reset, takes a zero-value Config, drains with one Drain(ctx), honours write deadlines and reports a cut stream as an error rather than io.EOF, with no dependency outside the standard library.
Not ready for code that needs a frozen API today: link is still v0, the QUIC and gRPC transports stay v0 as long as their own dependencies do, and fuzzing and soak hours were well short of their targets when this was written. There are no reset error codes, no stream priorities, no CloseRead and no auto-tuned windows, and it needs a net.Conn underneath, not any io.ReadWriteCloser. Inside nfltr it is not a separate choice: the relay and agents use it for you. The relay's session core and client SDK are public, but the relay server and the nfltr CLI are closed source.
Try next the quick start in link's README at github.com/nfltr/link (go get github.com/nfltr/link), then make check in that repository. Why nfltr needed a multiplexer, with link against yamux measured, is part 1; the connect sidecar, whose sessions ride on link, is in Every service reachable from every remote client, with no inbound ports; what else became public with link is in Four nfltr repositories are now open source.