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 Open returns, 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: SetWriteDeadline returned nil and did nothing until v0.9.0.
  • Kept as they were: a zero-value Config checked by Validate, a Serve callback instead of an Accept loop, 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?

LibraryOpenHalf-closeReset
netDial(...) (net.Conn, error)(*TCPConn).CloseWrite(), after a type assertionSetLinger(0)
hashicorp/yamuxOpen() (net.Conn, error), OpenStream() (*Stream, error)Stream.Close sends FIN; no CloseWritenone exported
go-yamux v5OpenStream(ctx) (*Stream, error)CloseWrite, CloseReadReset, ResetWithError(uint32)
xtaci/smuxOpen() (io.ReadWriteCloser, error), OpenStream() (*Stream, error)CloseWrite()none
quic-goOpenStream() (*Stream, error), OpenStreamSync(ctx)Close() closes the send side onlyCancelRead, CancelWrite(StreamErrorCode)
go-libp2pHost.NewStream(ctx, peer, pids...) (network.Stream, error), an interfaceCloseWrite, CloseReadReset, ResetWithError
link v0.9.0Link.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/quiclink v0.2.1 and peerlink/grpclink v0.2.0, read through go doc -all, the README, recipes, changelog and security notes; the eight other APIs at the versions named above, from go 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-forwarding CloseWrite by assertion.
Reproduce
(a) In a scratch Go module, go get each library at its version and read go 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; the v0.9.0 entry of CHANGELOG.md in github.com/nfltr/link has a before-and-after line for each change, and make check-api there fails on an exported API change that its api/ 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.

Request and reply with CloseWrite The opener sends OPEN with a header, then the request, then CloseWrite, which the peer reads as io.EOF. The peer then writes the reply and closes; the opener, whose write side is closed, still reads the reply and then io.EOF. opener (a) peer (b) OPEN, with a header DATA: the request CloseWrite: b reads io.EOF DATA: the reply Close: a reads io.EOF shaded: a has stopped writing and still reads
One session, one request, one reply. The request ends with 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)).

Accept loop and Serve callback With an Accept loop, sessions queue for the caller's loop, and a handler run inside the loop holds every later Accept. With a Serve callback, the library starts a goroutine per session, so a slow handler holds only its own session. Accept loop: net, yamux, smux, quic-go, mplex receive s3 s2 s1 your loop Accept, then handle a handler run in the loop holds every later Accept Serve callback: link, http.Server, go-libp2p Serve a goroutine each handler(s1) handler(s2), slow handler(s3) a slow handler holds only its own session
The most common bug in a hand-written accept loop is handling a stream inside it. A callback makes the library own the goroutine.

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

LibraryConnection deadStream resetTimeout
hashicorp/yamuxErrSessionShutdown, ErrKeepAliveTimeoutErrConnectionResetErrTimeout, 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/http2ConnectionError(ErrCode), GoAwayErrorStreamError{StreamID, Code, Cause}none
linkErrConnClosed, wrapping why (ErrDeadPeer, the network's error)ErrResetos.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

LibraryHard closeGraceful
net/httpServer.Close()Server.Shutdown(ctx)
hashicorp/yamuxSession.Close()GoAway(), returns at once
xtaci/smuxClose()none
quic-goCloseWithError(code, desc)none at the transport (HTTP/3 has GOAWAY)
link v0.9.0Mux.Close, Pool.CloseMux.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.

Credit and a write deadline The writer sends the session's whole window and its Write waits for credit. The peer reads some bytes and grants a window update; the writer sends as much as was granted. The peer stops reading, no more credit arrives, and when the write deadline passes Write returns the bytes it sent and os.ErrDeadlineExceeded. Other sessions have their own credit. writer Session.Write peer reads the session DATA: the whole window Write waits for credit WINDOW: b read some bytes DATA: as much as was granted b stops reading no credit, no WINDOW write deadline Write returns (n, os.ErrDeadlineExceeded) other sessions keep their own credit and go on
A Write blocks in exactly one place: waiting for credit, or for room in the connection's write buffer. Since v0.9.0 the write deadline bounds that wait.

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:

LibraryPer streamPer connectionAuto-tuning
hashicorp/yamuxMaxStreamWindowSizenonegrows to the maximum
go-yamux v5initial and maximum windowMemoryManageryes, from the round-trip time
xtaci/smuxMaxStreamBufferMaxReceiveBufferno
quic-goinitial and maximum windowinitial and maximum, plus a callback to grow ityes
x/net/http2MaxUploadBufferPerStreamMaxUploadBufferPerConnectionno
linkConfig.Window, fixedbounded by MaxSessions × Windowno

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:

LibraryPackagesExported functions and methods
hashicorp/yamux133
go-yamux v5142
xtaci/smux131
go-mplex117
quic-gomanyhundreds
linkroot, 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 -all output for each module at the versions above; link at v0.8.1, its root package plus bytequeue, peerlink, peerlinktest and the two transport modules; October 15, 2026.
Numbers
link: 76 in the root (7 on NopObserver), 12 in bytequeue, 10 in peerlink, 12 in peerlinktest, 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 get each other library at its version, then go doc -all <package> and count the func lines. 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/link counts 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.

link's API tiers at v0.9.0 Four tiers from top to bottom: peerlink (Link, Session, Node, Listener), where callers start; sessions (Mux, Session, Pool, Liveness, Observer); frames and credit (Conn, Frame, Credit); the handshake (Client, Server, Authenticator). Taken out of the v1 surface in v0.9.0: Credit embedded in Conn, now an accessor, and the bytequeue package, now internal. peerlink: start here Link, Session, Node, Listener sessions Mux, Session, Pool, Liveness frames and credit Conn, Frame, Credit handshake Client, Server, Authenticator Conn.Credit() not embedded bytequeue now internal Observer embeds NopObserver dashed: what v0.9.0 changed so v1 need not freeze it
Each tier is usable alone. The dashed changes keep internals and growth points out of the v1 method sets.
  • Conn embedded *Credit. Embedding put every Credit method (Acquire, Grant, Received, Forget and more) on Conn's method set, and Credit.Close was hidden behind Conn.Close only because the latter is shallower. It is now an accessor, Conn.Credit().
  • bytequeue was a public package. Its API (PopUnless(*atomic.Bool), a TryPopBatch with three results, Wake) is the receive path's internals. Freezing it would have frozen the receive path. It is internal/bytequeue now. The README's own v1 checklist has the rule: no exported name that exists only for one caller.
  • Observer is 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, as httptrace.ClientTrace does, and chose grpc-go's pattern instead: the interface has an unexported method that NopObserver provides, so implementations must embed NopObserver, and the stability policy says Observer may 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's Close covers 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 opaque header []byte on every open, which is more general; the contract guarantees 4 KiB of it.
  • Any io.ReadWriteCloser underneath. yamux and smux accept one. link takes a net.Conn, because it needs deadlines during the handshake and the TLS connection state for certificate authentication; net.Pipe covers in-process use.

The trade-offs, in one place

DecisionWhat it givesWhat it costs
Open returns peerlink.SessionHalf-close and reset without an assertion; wrappers checked by the compilerThe interface is frozen at v1; dialer adapters need a closure
Zero-value Config plus ValidateNo invalid zero value, no shared mutable pointer, errors that name the fieldSettings that need a non-zero default must treat 0 as "default"
Serve callbackA goroutine per session; no accept loop to get wrongNo select over accepts; admission only through MaxSessions
Drain(ctx) everywhereOne shape, the same as http.Server.ShutdownIt blocks; fire-and-forget needs go
Fixed window, session capPredictable memory, one number to tuneNo auto-tuning; the window must be sized for the path
No reset codesNo wire changeApplications put their own error in the stream
Observer embeds NopObserverNew events without a new major versionImplementations 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 for FUZZTIME, 30 minutes by default; the run is recorded in FUZZING.md) and make soak SOAKTIME=24h (1 hour by default; the JSON summary goes to SOAKOUT). make check is 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.

← All posts