Skip to content

Release the flux-network changes as 0.2.0 - #146

Closed
Bronek wants to merge 40 commits into
bronek/api_server-cfrom
bronek/api_server-r
Closed

Release the flux-network changes as 0.2.0#146
Bronek wants to merge 40 commits into
bronek/api_server-cfrom
bronek/api_server-r

Conversation

@Bronek

@Bronek Bronek commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

DRAFT: FURTHER REFACTORING COMING, BELOW WILL BE SOON OBSOLETE

This PR builds on #145 and changes only the workspace version in Cargo.toml and the derived workspace-package versions in Cargo.lock.

The networking changes since v0.1.3 break public API compatibility, so Cargo's pre-1.0 compatibility rules require a minor version bump. Flux uses one version for every workspace crate; the complete workspace therefore moves from 0.1.3 to 0.2.0.

Closes #143.

Release highlights

  • StreamNetwork supports TCP and Unix-domain endpoints through one connection lifecycle, with network-owned or caller-owned polling.
  • Multiple independently configured HttpService instances can share one network and poll.
  • HTTP gains pull-based events, immediate or deferred responses, request deadlines with failure reasons, connection caps, lingering close for rejected uploads, and allocation-free warm paths.
  • listen reports the endpoint it bound, including a TCP port selected by the kernel.

HTTP server example

use flux_network::{
    http::{HttpConfig, HttpEvent, HttpService},
    stream::{ConnectionGroupConfig, Endpoint, Framing, StreamNetwork},
};

let mut net = StreamNetwork::default();
let group = net.add_group(ConnectionGroupConfig {
    framing: Framing::Raw,
    max_connections: Some(256),
    ..Default::default()
});
let mut http = HttpService::new(&mut net, group, HttpConfig::default());
let bound = http.listen(&mut net, Endpoint::Tcp("127.0.0.1:0".parse()?))?;
println!("listening on {bound}");

loop {
    net.drive(None, &mut [http.as_service()], |_| {});
    while let Some(event) = http.next_event(&mut net) {
        if let HttpEvent::Request { responder, .. } = event {
            responder.respond(200, &[("content-type", "text/plain")], b"ok");
        }
    }
}

StreamNetwork::default() owns its poll and drive(None, ...) blocks until I/O or the next deadline. Applications that already own a mio::Poll can instead construct the network with with_registry and call next_deadline, handle_event, and tick from their loop.

Migration from 0.1.3

The released v0.1.3 networking API was TCP-only. Its users need the following source changes:

0.1.3 0.2.0
flux_network::tcp flux_network::stream
TcpNetwork, TcpEvent, TcpGroup, TcpGroupConfig StreamNetwork, StreamEvent, ConnectionGroup, ConnectionGroupConfig
listen(group, SocketAddr), connect(group, SocketAddr) listen(group, Endpoint), connect(group, Endpoint) using Endpoint::Tcp(addr) or Endpoint::Unix(path)
listen(...) -> io::Result<()> listen(...) -> io::Result<Endpoint>; TCP port 0 returns the selected port
Event field peer_addr: SocketAddr peer: Peer, matched as Peer::Tcp(addr) or Peer::Unix
TCP options directly on TcpGroupConfig ConnectionGroupConfig::tcp: TcpOptions with the same defaults
Default group name "tcp" "stream"; callers relying on default log labels or telemetry suffixes will observe the change

TcpConnector, TcpStream, and TcpTelemetry keep their names but move from the tcp module to stream.

There is no HTTP migration from v0.1.3: that release did not contain the HTTP module. HttpService and its resilience features are new in 0.2.0.

Verification

The release checks pass with the updated lockfile:

cargo check --workspace --all-features --locked
cargo test --workspace --all-features --locked

Base: #145.

Assisted-by: Claude Code:claude-fable-5
Assisted-by: Codex:gpt-5

Bronek added 30 commits August 26, 2026 12:58
…stream network

Record the design for flux-network before the implementation stack lands:

- CONTEXT.md: the workspace glossary (Tile, Spine, Signal, Waker; Stream, Group,
  Tenant, Raw group, Owned/External mode, Endpoint, Peer, Deadline, Draining,
  Lingering, Refused).
- ADR 0001: poll ownership is a construction-time mode of the stream network, and
  protocol layers are tenants the network schedules — one driver owns deadline
  folding, routing by group, maintenance order and ticking; tenants expose their
  events by pull; hooks stay private behind an opaque TenantRef carrier.
- ADR 0002: transports are the closed set Endpoint { Tcp, Unix }.
- ADR 0003: Unix-domain socket files are probed (lstat, then connect) before a
  stale one is replaced, and removed on close.
- docs/extension-points.md: eventual (streamed responses) and speculative (TLS,
  Unix socket file mode, QUIC) directions the design keeps open without deciding.

All three ADRs are accepted.

Assisted-by: Claude Code:claude-fable-5
The networking glossary names a ConnectionGroup — the connections
sharing one configuration and one owner, together with the listeners
and outbound endpoints that produce them; a Service, the stateful
protocol layer that owns one group inside a shared network and is
scheduled by it; and an unclaimed ConnectionGroup, one no Service has
claimed, whose events the caller takes inline through the closure it
passes to drive. Owned poll and External poll name the two
poll-ownership choices.

ADR 0001 speaks of services with the same generality: a service is
any protocol layer the network schedules, and HttpService is the
first of them. Its decisions, options and consequences carry over
untouched; only the vocabulary moves. ADR 0003 names the
ConnectionGroup for the reconnect interval it configures.

docs/extension-points.md leaves the tree. Its purpose was
informational — directions the design keeps open, deciding nothing —
and reviewers read that as scope instead. It stays in the
maintainers' notes, where a direction can be written down without
reading as a commitment, and ADR 0003 states the Unix file-mode
setting as speculative in its own words. This answers the review
on #135.

Assisted-by: Claude Code:claude-opus-5
The probe connects without blocking, so a live owner whose accept queue
is full is reported rather than waited for: a connection that completes
or is left pending fails the bind with AddrInUse, and any other probe
error is returned as it is, naming the path, instead of being read as a
live owner.

Assisted-by: Claude Code:claude-fable-5
An iteration captures the time once for validation, maintenance and the
deadline fold, but the poll wait that follows may last the whole
timeout, so the service ticks are given the time the wait ended: a timer
a tick starts runs from the end of the wait, and one that expired during
it is due in the same iteration rather than the next.

Assisted-by: Claude Code:claude-fable-5
The network's unit is a Stream — an ordered byte channel, TCP or
Unix-domain (CONTEXT.md, ADR 0002) — so the type, its event and group
types, and the module take the stream name: TcpNetwork → StreamNetwork,
TcpEvent → StreamEvent, TcpGroup → ConnectionGroup, TcpGroupConfig →
ConnectionGroupConfig, module tcp → stream. The private module file
tcp/stream.rs becomes stream/tcp_stream.rs, since stream/stream.rs would
trip clippy's module_inception; its content is unchanged. Integration
tests that drive StreamNetwork are renamed tcp_network*.rs →
stream_network*.rs; the TcpConnector-only tests keep their names.

TcpConnector, TcpStream, TcpTelemetry, ConnState and HttpNetwork keep
their names, and runtime error and panic strings keep their wording. No
behaviour changes.

Assisted-by: Claude Code:claude-opus-5
Assisted-by: Claude Code:claude-fable-5
StreamNetwork listens and connects on `Endpoint { Tcp(SocketAddr),
Unix(PathBuf) }` and reports the remote end as `Peer { Tcp(SocketAddr),
Unix }`, so one poll holds both transports and one group mixes them.
ADR 0002 fixes the set: an internal enum over the two mio listener types
and the two mio stream types costs one match per operation, where a type
parameter would forbid mixing and a trait object would put a virtual
call on every read and write. Parsing an address out of text stays with
the caller.

TCP_NODELAY, keepalive and TCP_USER_TIMEOUT do not exist for a
Unix-domain socket and dispatch to a no-op there; SO_SNDBUF and
SO_RCVBUF reach both through the socket's raw fd. A Unix-domain outbound
endpoint is persistent like a TCP one: ENOENT for a socket file that has
not appeared yet, and ECONNREFUSED for one with no listener, each count
as a failed attempt and retry at the group's reconnect interval.

HttpNetwork follows the same signatures, and a request with no caller
Host header writes `Host: localhost` for a Unix-domain endpoint, which
has no address to name.

Runtime strings that called the network or a group TCP would now be
false, and drop the word:

  unknown TCP group                        -> unknown connection group
  couldn't set up a poll for tcp network   -> ... for the stream network
  couldn't poll tcp network                -> couldn't poll the stream
                                              network
  tcp token space exhausted                -> stream token space
                                              exhausted
  max_frame_size exceeds the TCP wire      -> ... exceeds the wire
    length field                              length field
  couldn't start tcp connection            -> couldn't start connection
  couldn't register connecting tcp stream  -> ... connecting stream
  tcp connection attempt failed            -> connection attempt failed
  couldn't register connected tcp stream   -> ... connected stream
  tcp connection established               -> connection established
  tcp accept failed                        -> accept failed
  couldn't register accepted tcp stream    -> ... accepted stream
  tcp connection accepted                  -> connection accepted
  ignoring stale tcp readiness event       -> ignoring stale readiness
                                              event
  tcp payload exceeds maximum frame size   -> payload exceeds maximum
                                              frame size
  tcp send backlog growing                 -> send backlog growing
  tcp send backlog would exceed            -> send backlog would exceed
    configured maximum                        configured maximum
  tcp raw read failed                      -> raw read failed
  tcp header read failed                   -> header read failed
  tcp payload read failed                  -> payload read failed
  tcp frame exceeds configured maximum     -> frame exceeds configured
                                              maximum
  tcp frame write failed                   -> frame write failed
  tcp backlog write failed                 -> backlog write failed

The default ConnectionGroupConfig name changes from "tcp" to "stream" for
the same reason; it labels a group that may now hold either transport.
The nodelay and keepalive warnings keep "tcp stream" because only a TCP
socket reaches them, and the tcp_latency_/tcp_alloc_ metric prefixes are
untouched.

The core accept, message and disconnect tests, and the HTTP accept,
request, respond, keep-alive and pipelining tests, run over both
transports from one body. Tests that turn on SO_SNDBUF backpressure or
talk to TcpConnector stay TCP-only.

Assisted-by: Claude Code:claude-opus-5
A listener binding an Endpoint::Unix whose path already exists resolves
it the way ADR 0003 settles: `lstat` the path without following it, and
leave anything that is not a socket — a regular file, a directory, a
symbolic link even when it points at a socket — exactly where it is,
failing the bind with AlreadyExists and an error naming the path. For a
socket, connect to it: a refused connection means no process is
listening, so the file is a remnant of one that did not clean up and is
unlinked before the bind; a connection that succeeds means a live server
owns the path, and the bind fails with AddrInUse.

The `lstat` is what makes the unlink safe. Connecting to a regular file
is refused too, so a probe on its own proves nothing about what the path
holds and would let a bind delete an unrelated file it happened to be
pointed at.

The probe is nonblocking, because the owner ADR 0003 is guarding against
is often one that has stopped accepting: a full accept queue answers
WouldBlock, which reports AddrInUse rather than sleeping until the owner
comes back.

Closing the listener unlinks its socket file, so a crash never leaves a
path that blocks the next start. There is no API for removing a single
listener today, so the unlink lives in the listener wrapper's Drop and
runs when the StreamNetwork is dropped.

Assisted-by: Claude Code:claude-opus-5
Endpoint is the closed set of stream transports and the only form the
network accepts, so callers name the variant. The conversion from
SocketAddr had no callers and privileged TCP over Unix-domain paths.

Assisted-by: Claude Code:claude-fable-5
`ConnectionGroupConfig` carries three options that only a TCP connection
has: `TCP_NODELAY`, keepalive, and `TCP_USER_TIMEOUT`. A `TcpOptions`
sub-struct states that scope in the type, and lets the note about
Unix-domain connections ignoring them sit on a single field.

`TcpOptions::default()` holds the defaults every group starts from:
nodelay on, keepalive off, and `DEFAULT_TCP_USER_TIMEOUT_MS`.

Assisted-by: Claude Code:claude-opus-5
A protocol layer such as an HTTP server owns one group inside a shared
network, and the network schedules it: `drive` captures the time, runs
maintenance, folds every deadline into one poll, routes each event to
the group's service or to the unclaimed handler, and ticks each service
in slice order with the time the wait ended, reporting whether anything
happened.

The scheduling hooks live on a flux-internal trait, so a service hands
the network an opaque `ServiceRef` and nothing outside flux can
implement or call a hook. Groups no service claimed stay raw: their
events reach the handler as they arrive, lending each payload for the
call, and `poll_with` is the nonblocking driver call for a network of
those alone.

A service's claim is validated before anything else happens on every
driver call, so a service that was dropped instead of closed, or one
supplied twice, is a configuration error reported at the next call
rather than a timing-dependent one.

Assisted-by: Claude Code:claude-opus-5
The HTTP layer owns one unclaimed group of a network the caller drives, not a
network of its own: `HttpService::new` claims a caller-created group,
every network-touching call takes the network, and `close` hands the
group back, empty and raw. Transport and queue policy stays on the
group; `HttpConfig` carries what parsing and connection state need.

Events are pulled rather than pushed. `next_event` parses the next
ready connection on demand and hands out a request borrowed from its
buffer beside a `Responder` that writes the answer, so a request is
answered exactly once and never copied to make a response possible.
Dropping the responder answers later by token. Readiness is connection
state, so a caller may stop pulling and resume in a later iteration,
and `tick` applies the bookkeeping the last pull left behind so that
work is reported while any pullable event remains.

A connection reclaims what it has answered by cursor: parsing runs from
`start`, a response moves `consumed`, and the buffer is compacted only
once the answered prefix is half of it, which a benchmark measures at a
quarter of the cost of dropping the prefix after every response.
Request metadata is kept as byte ranges the service owns, so an event
outlives the parse that produced it.

Assisted-by: Claude Code:claude-opus-5
The test pushes 8 MiB through a 1 KiB socket buffer to a receiver that
sleeps first, and how long the queue takes to drain is the receiver's
and the scheduler's business, not a number the test can pick. A fixed
five-second pump was on the edge of it and failed a run in several
under load.

Each collector now announces the frame the test waits for, and the
pump runs until both announcements arrive; the remaining deadline only
exists to fail loudly rather than hang. Every assertion and the payload
size stay as they were: real backpressure is the point of the test.

Assisted-by: Claude Code:claude-opus-5
An inline answer runs inside the event it was handed, which borrows the
connection until it is dropped, so the service records the token and the
next driver call reclaims the answered bytes and queues whatever is
behind them. An answer by token borrows nothing and reclaims before it
returns. Both advance `consumed` at once, so the two paths judge a
connection's limits alike and write the same bytes in the same order.

Assisted-by: Claude Code:claude-opus-5
Integration tests observe HTTP from outside the service: the bytes on the
wire, the order requests are served in, what a limit accepts, and what a
driver call reports as work. Unit tests beside the code read the
representation directly — `start`, `consumed`, `req_end`, the compaction
threshold and the ready queue — so the service exposes no accessor for
what it keeps to itself, and `cursors` and `ready_len` go with it.

Assisted-by: Claude Code:claude-opus-5
Add an External poll mode built from the caller's mio Registry and a
token base. The caller folds next_deadline into its poll timeout, passes
each readiness event to handle_event, and calls tick once per iteration.

Keep poll ownership fixed at construction. Owned networks use drive and
may hand out one reserved-token waker; External networks use the
three-phase interface and leave foreign events untouched. Each mode
rejects the other mode's driving operations.

Allocate network tokens upward without reuse so handle_event can
distinguish network events from the caller's sources. Stale network
tokens remain classified as ours. Readiness handling drains any
disconnect it produces before returning, while maintenance and service
ticks reuse the same internal phases as Owned driving.

Re-export mio so callers can use the exact Poll, Registry, Event and
Waker types exposed by the External interface without coordinating a
separate dependency version.

Exercise the External loop with HTTP over both transports, deadline and
reconnect handling, foreign and stale events, mode misuse, wakers,
token-space bounds and per-event disconnect delivery.

Assisted-by: Claude Code:claude-fable-5
Assisted-by: Claude Code:claude-opus-5
Assisted-by: Codex:gpt-5
shutdown_write_when_drained ends a connection's outbound stream without
giving up what the peer still has to say: the write side shuts as soon
as the queued bytes are written, and the connection stays registered and
readable. The peer reads the end of the stream, the bytes it sends
afterwards still arrive as Message, and its own close still arrives as
Disconnected. Both transports half-close, which is what ADR 0002 asks of
them.

Sends to such a token are rejected and queue nothing, so the queue only
shrinks from the request onward. A hard close still ends the connection
at any point, and a token that is draining as well closes when its queue
empties rather than half-closing.

Assisted-by: Claude Code:claude-opus-5
A group can bound how many connections it holds: max_connections is
enforced in the accept loop, which accepts the connection arriving at a
full group and drops it where it stands — no registration, no bytes, no
event — and drains the rest of the backlog, as an edge-triggered
listener requires. refused_connections reports how often that happened,
and a warning names the group at the ten-second cadence the backlog
warning already uses, never once per refusal.

The cap counts what the group accepted and still holds, a connection it
is closing included: draining and half-closed connections hold their
places until they are gone. Outbound endpoints and listeners are not
accepted connections and count for nothing.

Nothing above the transport takes part: an HttpService on a capped group
never hears of the connection its group refused.

Assisted-by: Claude Code:claude-opus-5
Three rules the half-close tests left free to be negated: that the hard
close wins over the half-close whichever was asked for first, that a
broadcast passes over a half-closed member rather than writing to it,
and that a reconnecting outbound endpoint gets its write side back. Each
new test fails if its rule is reversed — the drain gate's branches
swapped, the per-member broadcast filter dropped, the reset in
disconnect_index deleted — and each runs over both transports, none of
the three being a matter of which one carries the bytes.

The recorder the file already had now keeps every lifecycle event with
its token, which is what lets a test say which connection was closed and
which one a payload arrived on.

The write side of a shut connection is one the peer can read the end of
the stream from, whether or not it has; the rustdoc of WriteSide::Shut
said it already had.

Assisted-by: Claude Code:claude-opus-5
Admission compares a count the group keeps, incremented when an accepted
connection is inserted and decremented at each of the three places one
is removed, so refusing a connection at the cap costs the same however
many connections the network holds. A refusal flood is the case the cap
exists for, and a scan of every connection per arrival made that cost
grow with the network, unrelated groups and outbound endpoints included.

The count is a private invariant of the group that owns the policy. The
tests pin every path that moves it — peer disconnect, local disconnect,
removal and group close — and that two listeners of one group share one
cap.

Assisted-by: Claude Code:claude-opus-5
A client that meets a reset before it has read the answer to its upload
reports a node that is down rather than the status it was sent. A
response that closes a connection now shuts the write side alone
whenever the request stream behind it was cut short — every status the
service raises itself, and a connection driven over its buffer limit —
and reads its peer out under an idle and a total cap until the peer
stops, the caps run out, or it closes.

The bytes over the limit are no verdict on a request already delivered:
a connection holding one keeps it, and the answer the caller is
producing carries the close. Lingering connections answer to the
linger's caps rather than the idle sweep, and hold their place in the
group meanwhile.

Assisted-by: Claude Code:claude-opus-5
A request to an endpoint that answers nothing used to wait as long as
the connection stood, and a peer that broke the protocol dropped the
connection with nothing to say for it. An outbound request now carries a
deadline from the moment its bytes are queued — a request waiting behind
a backlog is already waiting — and every request that will not be
answered names its reason: the deadline, an answer the service cannot
frame, an answer over the caps the operator sets, or an endpoint that
closed with the request in flight.

The failure reaches the caller before the disconnect it causes, so a
puller learns why the connection went before learning that it did.

Assisted-by: Claude Code:claude-opus-5
An endpoint that echoes a status chosen elsewhere needs the whole range
and the phrase-free status line RFC 9112 §4.1 permits, so a status the
service has no phrase for is framed as `HTTP/1.1 250 ` rather than
carrying a phrase nobody chose. A caller-chosen 1xx is final: it
completes the request, framed with no length and no body.

`reason_phrase` is public, and the phrase it returns for a status it
does not name is now the empty string rather than `Unknown`.

Assisted-by: Claude Code:claude-opus-5
The caps were timing the wrong thing. They ran from the moment the
answer was queued, so a large answer over a small socket was cut off
mid-body — the idle cap ending a connection that had delivered a
fraction of the length it announced, which is the reset lingering exists
to avoid. They now time what they name: the reading and discarding that
begins where the answer ends. A connection still writing is bounded by
its transport, exactly as a draining one is.

This is the one network-side addition PR B needs: `write_side_shut`
reports that the peer has the whole of what was queued and the end of
the stream after it, which is where the clock starts. A linger whose
clock has yet to start folds no deadline of its own, since the write it
is waiting on is what wakes the poll.

A connection over its buffer limit with nothing pending is now answered
with the 431 every other head too large for the service gets, rather than
dropped where it stands: an over-limit that lands exactly on a request
boundary closes the way one that lands mid-request already did, however
the request before it was answered.

Assisted-by: Claude Code:claude-opus-5
The reason an outbound request carries is what tells an operator to
raise a cap apart from what tells them an endpoint is broken, so each
path that picks one is pinned: a head over its cap, a chunked body over
its cap and one the close delimits over its cap all report `TooLarge`,
while a chunk size the service cannot read and an answer framed twice
over report `Malformed`. Each asserts the failure reaches the caller
ahead of the disconnect that follows it.

A request deadline also has to be the deadline a blocking drive waits
for, which is what the last test holds it to.

Assisted-by: Claude Code:claude-opus-5
The caps time the reading and discarding, so a connection whose answer
has yet to reach its peer runs under none of them. A peer that stops
reading held such a connection open — and its place in the group —
against nothing but the transport, which bounds a TCP peer late and a
Unix-domain one never.

The sweep now lets go of a lingering connection only once its own clock
has taken over. Until then it is held to the same idle bound as any
other, a draining connection included, and folds that bound into the
deadline rather than none.

Assisted-by: Claude Code:claude-opus-5
Every parse borrowed a fresh header vector, every outbound request owned
a copy of its method and of the host it addressed, and every response
carried its body through the caller's slice. A warm connection needs
none of it: the scratch a parse borrows is bounded, all that is asked of
a request in flight is whether it was HEAD, and a body composed into a
buffer the service keeps reaches the wire in one copy.

Parsing borrows a stack array of MAX_HEADERS entries sliced to what the
configuration asks for, and with_max_headers refuses a larger cap. An
outbound connection records the request in flight rather than its
method. request() writes the Host it defaults to straight into the send
buffer. respond_with lends a closure the service's body scratch and
frames the Content-Length it wrote, HEAD included; respond is that call
with the body it was handed. Framing stays in one place, now the
Responder the request carries, and the wire bytes are unchanged.

Assisted-by: Claude Code:claude-opus-5
A warm keep-alive connection in each direction runs a thousand round
trips under a global allocator that counts allocation events on the
thread driving them, and each direction is asserted to have made none.
The peers are plain sockets driven from the same thread between
iterations of the network, so nothing else runs while a count is open
and nothing the peers do grows a buffer of their own. The bytes each
side reads are checked against the message it expects, so a direction
cannot pass by falling silent.

flux-profiler's CountingAllocator tallies bytes rather than events and
keeps its counters to the profiler, so the test brings its own.

Assisted-by: Claude Code:claude-opus-5
A lingering connection arms its caps once the transport has taken the
whole answer, and finding that out walks the network's connections. The
linger loop asked for every lingering connection on every tick; it now
asks only for one whose clock has yet to start, so a connection whose
caps are already running costs nothing further.

`write_side_shut` says what it means: the transport has taken everything
queued for the connection and the end of the stream after it, with up to
a socket buffer of that still on its way, not that the peer has read it.
The test pins that the caps run from the moment the answer has left,
which is the time the ticks are given, rather than from the start of the
poll wait that preceded it.

Assisted-by: Claude Code:claude-opus-5
A head that never ends is caught by the cap while the parse is still
incomplete, which is a path of its own: an answer whose open head runs
past the cap now pins `TooLarge` there, where before only the head that
parsed whole was held to it.

A request ending exactly at the buffer limit has two correct closes, and
which one the peer reads is settled by whether a tick falls between the
request and its answer. The pair that answer first are named for that,
and the third holds the other outcome: an answer that carries the close
itself, with no rejection behind it.

Assisted-by: Claude Code:claude-opus-5
A response the service refuses never reaches its body: the status range,
the header names and the pending request are all settled before the
closure that would compose one runs. Nothing said so, and nothing would
have noticed the check moving below the render.

The header count had the same gap on the other side. Its three tests
covered the builder alone, so what the count decides on the wire --- a
request at the count served, one over it answered 400, and a count
written past the scratch held to what the scratch holds --- went
unpinned, as did the answer an endpoint sending too many headers gets.

Both new server-side tests run over a socket file: what a header count
decides is the parse, and the loopback ports the rest of the suite
shares are contended enough already. The zero-alloc harness takes the
connection its service dialled straight from the listener rather than
opening a placeholder first, and records what one round trip already
settles.

Assisted-by: Claude Code:claude-opus-5
Bronek added 10 commits August 27, 2026 14:04
`HttpConfig`'s fields are public, so a configuration is as likely
written whole as built call by call. The builder refuses a header count
outside `1..=MAX_HEADERS`; a field written straight into the struct was
quietly held to `MAX_HEADERS` at every parse instead, so the same
configuration meant two different things.

`HttpService::new` validates the configuration it is handed, sharing the
check with the builder so the rule and its message cannot drift, and the
parse borrows the scratch the count names.

Assisted-by: Claude Code:claude-opus-5
`write_side_shut` answers a question the lingering close asks of its own
transport, and nothing outside the crate has cause to ask it. It is
`pub(crate)`, and the linger test that reached for it measures the same
instant from where a peer can see it: the client reads the whole answer
and then the end of the stream, which is the answer having left.

The test keeps its point by timing that instant against both ends. The
peer takes nothing for half a second, so the answer leaves only after
the pause -- and the caps end the connection an idle cap after that,
rather than an idle cap after it was queued.

Assisted-by: Claude Code:claude-opus-5
`buffered` handed the tests a connection's byte count, which is the
service's own representation and nothing a caller has business reading.
It goes, and what it stood in for is measured where it belongs.

The lingering close's discard is a state transition, so it is pinned by
a unit test that drives a connection through it: bytes read while
lingering leave the buffer empty, leave the total cap where the answer
put it, and move the idle cap to the read. The wire-level test keeps
what a peer can see -- the answer, then the end of the stream, and a
client that goes on uploading uncut.

The gates that make the over-limit-while-pending path deterministic ask
the network instead. An accepted connection is polled for reads alone
until the service writes, and a raw read empties the socket, so between
a request and its answer the one iteration reporting work is the one
that took the overrun whole.

Assisted-by: Claude Code:claude-opus-5
A caller who already holds its body has nowhere to render it, so the
buffer the service keeps for composed bodies costs it a copy it never
needed: once into the scratch, once out of it. A HEAD request paid for
both and then discarded the result.

Framing now takes the bytes wherever the caller holds them. respond
hands write_framed the caller's slice, respond_with renders into the
scratch and hands it that, and the admission rule both share sits in
answerable, which respond_with also asks before composing anything. On
the service, one path builds the responder and reclaims what its answer
consumed, and the statuses the service raises itself carry an empty body
rather than an empty closure.

Assisted-by: Claude Code:claude-opus-5
A caller that asks for TCP port 0 lets the kernel choose the port and
then has no way to learn it, so anything that must dial the listener has
to pick a port up front. Picking one means binding a probe, reading its
address and releasing it, and between the release and the real bind the
port belongs to whoever takes it first.

listen now returns the endpoint it bound. It is the endpoint asked for
in every case but a TCP port of 0, where it carries the port the kernel
chose, so a caller binds first and hands out an address that is already
its own.

Assisted-by: Claude Code:claude-opus-5
Every test binary picked its loopback ports by binding a probe, reading
its address and releasing it, and one of them held colliding probes
bound while it looked for a free port -- squatting on an address it had
already handed to another thread. The victim's own bind then failed with
AddrInUse, once in roughly thirty-five runs of the HTTP suite.

Listeners now bind port 0 and the tests use the endpoint that comes
back, so an address exists only once something holds it. The transport
macros hand a body a request rather than a reservation, and the helpers
that build a server -- Http, Server, Peer -- report where they landed.

Two places still choose an address before it is bound, both because
TcpConnector::listen_at takes one and reports none back: the wire
compatibility test in stream_network.rs, which says so where it probes,
and the tcp_* connector tests, which never shared the squatting helper.

Assisted-by: Claude Code:claude-opus-5
The in-module harness listens on port 0 and dials the address the
listener reports, rather than probing for a free port and hoping it
stays free until the service binds it. The unit tests no longer race
anything else on the machine for a port.

Assisted-by: Claude Code:claude-opus-5
Both public answer paths ask answerable before anything is composed --
a refused status or header runs no body closure -- and write_framed,
which they share, frames what they hand it as admitted. The check moves
out of write_framed into respond, beside the one respond_with already
made, so a composed answer scans its headers once rather than twice; a
debug assertion keeps the invariant visible where the framing happens.

One admission rule, one framing implementation, one body copy for a
slice and no closure for a refused answer, as before.

Assisted-by: Claude Code:claude-fable-5
flux-network's public API is incompatible with v0.1.3: the tcp
module and TcpNetwork family become stream and StreamNetwork;
listen and connect now take an Endpoint; events report a Peer;
listen returns the endpoint it bound; and the default group name
changes from "tcp" to "stream".

Under Cargo's pre-1.0 compatibility rules, this requires a minor
version bump. Flux uses one version for every workspace crate, so
the workspace moves to 0.2.0 together.

Assisted-by: Claude Code:claude-fable-5
Assisted-by: Codex:gpt-5
@Bronek Bronek changed the title Release the workspace as 0.2.0 Release the flux-network changes as 0.2.0 Aug 27, 2026
@Bronek
Bronek marked this pull request as draft August 28, 2026 11:15
@Bronek Bronek closed this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make flux-network production-ready across TCP and Unix sockets

1 participant