Skip to content

toyos-net-node: streams, a connection of the own stack's bridged to its client's two pipes (stage D) - #775

Merged
Japabu merged 10 commits into
mainfrom
wt/toyos-ownstack-d
Oct 9, 2026
Merged

Japabu merged 10 commits into
mainfrom
wt/toyos-ownstack-d

Conversation

@Japabu

@Japabu Japabu commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Stage D of the cut of issues/toyos-has-its-own-network-stack.md's stage 5: netstack's stream decisions on ToyOS's own TCP, in toyos-net-node, host-tested and shipped in nothing. Stages A (#771), B (#772) and C (#774) have landed, and origin/main at a4416fe6c is merged in by hand (below): git diff --shortstat origin/main...49e38ca4a is the stage alone, 12 files, +1,924, -5: production +542 -3 (streams.rs 411, lease/tcp.rs 64, lib.rs +12 -3, lease.rs 2, [tcp] 40, the shard 13), tests 1,373 (tests/streams.rs 1,243, tcp/tests/take.rs 130), the track and the manifest the rest. No image, no shipped module, no guest test, no manifest dependency and no lockfile entry changes; smoltcp is named nowhere in what is added.

What differs from what ships today

Nothing ships this yet. These are the behaviours that will differ from netstack on smoltcp once netstack moves onto the node:

  1. A connection whose client is gone with nothing left in its pipe is the stack's, not netstack's. The node holds a stream only while it holds one of its two pipes. With neither left it closes the connection and [tcp] finishes it alone by its rules for a user who let go: a reset at once if text is unread or arrives (RFC 9293 §3.6.1, SHLD-3), a reset after 60 s idle (limits::ORPHAN_IDLE), and otherwise the FIN exchange and TIME-WAIT. Today netstack keeps such a connection itself for 100 s from the client's leaving, unread text sitting in the socket and the window shut on the peer, then resets it.
  2. A stream its client can see no more is cut on no progress, not on a clock from the leaving. That is a stream whose to-client end the node has let go (its reader is gone: the kernel says so, a write is refused for it, or it closed; or the peer's FIN or a failure was read through it) and whose client writes no more. Nothing of it reaches the client again, and nothing tells the node whether that client lives. The bytes its pipe still holds are sent as [tcp] makes room, and the connection is reset once the pipe has given up no byte for 100 s. A peer address restarts that clock for at most 16 such streams at once (OWNERLESS_PER_PEER); one past them has 100 s from the pass that found it so, until one of the 16 is done and it takes its place. The 16 is an estimate; no measurement set it. It bounds an address: there is no floor on the rate and no bound across addresses, and the track says so with its exits. Today the cut is 100 s from the leaving for every such connection, whatever it still owes.
  3. A client that still holds its reading end, or may still write, is never timed by the node. One that shut its writing down and still reads, or whose reading ended and who still writes, is [tcp]'s to give up on (R2). A client that is done writing after the peer's FIN is timed as in 2, alive or not: the node cannot tell, and such a client cannot see the connection. That is what netstack does today at an absolute 100 s (issues/netstack-cuts-a-departed-clients-unsent-tail-at-the-ceiling.md, which stays open until netstack runs on the node); here it is on progress, for 16 an address.
  4. The 3 s RESET_LIFE is gone. After an abort the reset is [tcp]'s to deliver or drop with its next hop.
  5. A close or a shutdown sends what the client's pipe still holds, then the FIN. Today TcpClose removes the socket and drops both pipes with whatever the send pipe held (issues/netstack-removes-a-closed-stream-before-its-fin-leaves.md), and TcpShutdown closes the socket and never reads the pipe again.
  6. A connect before the lease is the stack's own refusal, ConnectError::Route, with nothing sent. The mapping onto ERR_NOT_CONNECTED is the move's.
  7. A failed connect carries its reason (Failure::Refused, TimedOut, Unreachable, …) where today every one is ERR_CONNECTION_REFUSED, and one whose next hop answers no ARP fails when [tcp] knows, not at a timeout. The mapping onto the pipe ABI's codes is the move's.
  8. Not ported: the bound on streams (max_piped_connections). The node exposes streams(); the track holds what must be true before netstack runs on the node.

Unchanged: connect with its optional deadline and its answer; nothing leaves the stack that the pipe did not take; nothing leaves the pipe that the stack will not take; the peer's FIN is the end of the client's reading after its last byte; a reset or any failure is the end of both pipes; a pipe handle that refuses netstack for any reason but full or gone resets that client's connection; Nagle on by default.

Blocked outside this stage's fence

  • The pass over every stream. Every frame and every deadline ends in a pass over every stream, and Node::next_deadline reads every stream: linear in streams per frame, as netstack's bridge_piped is today. A pass's recv_with also re-files its connection's deadline and offers it to the transmit round. [tcp] cannot say which connections a segment, a timer, an ICMP error or a failed next hop moved: it reports drain_eligible and drain_gone, which are the round's. The fix is drain_moved in toyos-net-tcp: [tcp] marks a held connection in for_conn, tick, icmp and end and hands the marks out once; the node maps a connection to its stream, keeps its deadlines ordered, and passes only the streams [tcp] moved, the one a pipe's wake names, and those due. That is a new reporting contract of a crate built from specifications, and a redesign of the node's deadlines, not a fix-round change; it is in the track with the measurement owed at the move (netstack's CPU time per frame on the T14 with 1, 100 and 1,000 idle streams beside one bulk stream) and the number it is held to: the change lands if 1,000 idle streams cost more than 12.3 µs a frame over what one costs. Every transmit opportunity walks every stream too, twice. Nothing here pretends to be it.
  • A reset stream's option answers (issues/a-stream-its-peer-reset-refuses-the-option-requests-a-host-answers.md). A stream its peer reset is gone from the node at once, so for a client that still holds it Node::set_nodelay answers false and Node::nodelay None. std's nodelay() asks netstack nothing: it already answers from the value its TcpStream holds (sdk/std/sys/net/connection.rs). What the node carries is set_nodelay on a reset stream and libc's getsockopt of TCP_NODELAY, which asks through toyos::net::tcp_get_option (userland/libc/src/socket.rs). The node cannot hold the stream for the client: its two pipe ends are all it has of the client, letting both go is how the client learns of the reset, and with both gone nothing tells it when the client leaves, so a kept stream would be kept for ever. The fix is outside the node: libc answering from a value its socket holds, as std does, or the pipe ABI giving netstack a handle whose other end's leaving the kernel reports. In the track.

The merge of main at a4416fe6c (the resolver, #774), by hand

49e38ca4a. Three hunks in two files were resolved by hand, and one line edited where git saw no conflict:

  • userland/netstack/node/src/lib.rs, the modules: main's mod resolve; and this stage's mod streams; with its pub use streams::{…}, both kept.
  • userland/netstack/node/src/lib.rs, Node::next_deadline: one chain of five, the stack's, the DHCP client's, the name's, the resolver's and the streams' deadlines.
  • issues/toyos-has-its-own-network-stack.md, the stage 5 paragraph: in the tree are the lease, the datagram sockets, the mDNS name, the resolver and streams; still to build are listeners.
  • userland/netstack/node/Cargo.toml, description (no conflict; main left it without the resolver): it names the resolver beside the rest.

Merged by git and read: settle's loop ends in break and its tail is serve_name, then the resolver's pass, and nothing in the merge adds a third call of that pass; receive and fire run settle and then bridge; transmit ends in self.pass(now, true), main's having no tail of its own; Node and Node::new hold name, resolver and streams once each; lease.rs has mod tcp;, and the resolver's Stack::connect stands among the unprefixed datagram forwards beside tcp_connect; the track's list has this stage's six lines and all of main's.

Mutations after this merge, all three controls of the one next_deadline line regenerated: this stage's d21, stage B's n2 and the resolver's r16. The other 48 stream patches, stage B's other nine on name.rs and the resolver's r17 (resolve.rs's own next_deadline) apply unchanged. Of the resolver's 21 (#774's comment), 20 apply unchanged; only r16 and r17 patch the node's lib.rs or the deadline chain, and only those two were run here.

The merge of main at 35d35859e (the datagram stage, #772), by hand

da4a51968. Five hunks in three files were resolved by hand, each to carry both stages:

  • userland/netstack/node/src/lib.rs, the modules: main's mod name; and this stage's mod streams; with its pub use streams::{…}, both kept.
  • userland/netstack/node/src/lib.rs, Node::next_deadline: one chain of the stack's, the DHCP client's, the name's and then the streams' deadlines.
  • userland/netstack/node/Cargo.toml, description: one sentence naming the lease, the datagram sockets and streams, and the mDNS name.
  • issues/toyos-has-its-own-network-stack.md, the stage 5 paragraph: in the tree are the lease, the datagram sockets, the mDNS name and streams; still to build are the resolver and listeners.
  • issues/toyos-has-its-own-network-stack.md, "What the node does not yet meet": this stage's six lines, then main's six, none reworded.

Merged by git and read: settle ends in stage B's serve_name; receive and fire run settle and then the stream pass; transmit ends in the pass over the connects (main's side had nothing in its tail); lease.rs has the datagram forwards and mod tcp;. Stack's TCP forwards stay tcp_*; stage B's datagram forwards (bind, send_to, recv_from, close, set_ttl, join) have no prefix and are left as landed, and no name collides.

Mutations after the merge: 48 of the 49 stream patches apply unchanged; d21 is regenerated, since it patches the merged next_deadline line. Of stage B's fifteen (#772's comment), fourteen apply unchanged and n2-deadline-without-the-name is regenerated for the same line. Request 7 ran the 49 and stage B's ten that patch the node's lib.rs or name.rs (eleven runs; n1 has two tests): 60 reds. Stage B's d1 to d4 and its s1 patch neither file and were not run here.

The review of e0b917b04 (round 3)

One NOTE: the tests' fake dropped a segment to an address no peer is at, unseen. Net::hears now fails the test on one. git diff e0b917b04 47231f0d7 over the stage's twelve files is that assertion, tests/streams.rs +7 -1, and nothing else; the merge of origin/main (dc8e6d9b7) had no conflict.

The review of ddf479738 (round 2), finding by finding

BLOCKER:

  • A client that dies after the peer's FIN is never timed, and no bound counts it. The cut armed on reader_left && done_writing, and after the pass lets the to-client end go at the peer's FIN nothing can set reader_left. The condition is now what the node can know: the to-client end is let go, whichever way, and the client writes no more (to_client.is_none() && done_writing). Nothing of such a stream reaches its client again, alive or not, so it is timed on progress under the same count. reader_left said nothing the pipe end's absence does not, and is deleted: no path can now let the end go and forget to say so. The module header says what the code decides. a_client_done_writing_after_the_peers_fin_has_100_seconds_without_progress: the peer's FIN arrives offering no window, the client writes 100,000 bytes, its writer leaves, the peer answers every probe and takes nothing; 100 s on, Cut, streams() == 0, one reset, the pipe still holding all [tcp] had no room for. Red first: that test against ddf479738's streams.rs (step red-first-blocker). Mutation d44.

NOTEs:

  • extended is decided once. It is decided in every pass while it is false: a stream past the 16 becomes one of them in the first pass over the streams that begins with fewer, the oldest first. a_departed_clients_connection_past_the_sixteen_is_one_of_them_once_one_is_done: seventeen leave, the sixteen peers read all there is at once, the seventeenth's peer takes a segment a second and sees the whole tail and the FIN past 100 s. Red first against ddf479738's source (step red-first-note). Mutation d43.
  • The track's departed-client line. Rewritten to what holds in this stage: per address 16, an estimate; no floor on the rate (2,097,152 x 100 s for a full 2 MiB pipe, then the tail in [tcp]); no bound across addresses, which an accepted stream's peer picks. The bound across addresses is the stream bound of the line above it, the node's places of toyos-net-node: listeners on the own stack's accept queue, and one bound on the streams, listeners and datagram sockets clients make the node hold (stage E) #777; this stage builds no second one. Its exit is a test there, a measurement or ruling on the rate, and the move's bench row.
  • The reset-stream line and this body's second "blocked" item. Corrected to the tree: std answers nodelay() itself; the node carries set_nodelay on a reset stream and libc's getsockopt.
  • The every-stream line. It names the two walks a transmit opportunity makes, what a pass costs an idle stream (one recv_with, two status, one read of the client's pipe, a system call in netstack), and the number: the change lands if 1,000 idle streams cost more than 12.3 µs a frame over what one costs. 12.3 µs is arithmetic, the time of a full frame on a 1 Gbit/s wire (1,538 bytes of 8 ns with preamble and gap); that 1,000 system calls exceed it is an estimate, not measured.
  • The sixteen-peers test. Its sixteen are now slow readers of 250,000 bytes each, alive together: the seventeenth is cut at 100 s with sixteen streams left, and each of the sixteen then sees its whole tail and FIN with no reset (d40, d41, d42, d46). a_peer_at_another_address_keeps_its_own_sixteen holds sixteen at 192.0.2.2 and keeps one alive at 192.0.2.3 (d45).
  • Behaviour 3. Rewritten above.

The review of 8a2302e90, finding by finding

BLOCKERs:

  • Node::transmit ends in no pass, and a connect can fail inside it. transmit ends in a pass over the connects (Node::pass(now, true)), which is all an opportunity can move. a_connect_nobody_answers_arp_for_fails_when_tcp_knows: the peer's address answers no ARP, timeout: None, driven by next_deadline alone; at every step the stream is gone exactly when tcp.next-hop-failed has counted, the answer is Failed { Unreachable }, no SYN left, and the clock never nears the renewal. Mutation d31.
  • self.deadline = None untested. an_established_streams_connect_deadline_is_gone (a stream connected with a 30 s timeout contributes no deadline 31 s on) and a_departed_clients_unsent_bytes_have_100_seconds, now on a stream connected with a timeout. Mutations d32, d33.
  • Neither stack.abort of an unanswered connect tested. Both tests run 60 s on past the answer and find no SYN sent again. Mutations d34, d35.
  • The cut's condition widened stays green. The condition is now reader_left && done_writing. the_peers_fin_ends_the_clients_reading_and_not_its_writing waits 150 s in CLOSE-WAIT before the client's last write (d36); a_live_client_that_is_done_writing_is_not_timed (d37) and a_reader_that_left_is_not_written_to_again, 150 s (d38), hold each half.
  • The read's PipeRefusal::Gone arm. A read cannot answer Gone: a pipe with no writer gives what it holds and then the end. PipeRefusal is two types, WriteRefusal { Full, Gone, Broken } and ReadRefusal { Empty, Broken }, and the arm is gone.
  • recv_with on a connection both FINs ended. text_unread_when_both_fins_ended_the_connection_is_still_taken in take.rs (t4) and text_the_pipe_had_no_room_for_outlives_both_fins in streams.rs, with room = 0 until after the FIN (d27, the same patch).
  • Evidence. The gates ran at bd6273b5c (45 tests); the table below has every exit per head. cargo run -- --ci host at this head is not mine to run and is not claimed here.

NOTEs:

  • The pass over every stream: decided as above, not faked; in the track.
  • set_nodelay writes the whole option set: a stream holds the Options it last wrote and writes that with one field changed.
  • The 100 s cut rebuilds the recorded defect: fixed in the node, behaviours 2 and 3. The issue's own bench row is a host test: a_departed_clients_tail_arrives_whole_while_its_peer_takes_it (a peer that reads one segment a second sees 250,000 bytes and the FIN, past 100 s; d39). The bound: a_peer_keeps_sixteen_departed_clients_connections_alive_and_no_more (d40, d41, d42).
  • A reset stream's option answers: blocked, above; in the track.
  • The stream bound's exit: rewritten in the track to what must hold before netstack runs on the node.
  • take.rs's scenario ids: Tcp::recv_with is listed among stage 4's departures in the track with its exit; the file's header says so.
  • Stack's unprefixed TCP names: every forward is tcp_*.
  • The body's behaviour 2: rewritten (2 and 3 above).

The third request found two mutations green, d22 and d23 (the pass after receive and after fire), because both were aimed at connect tests, which the new pass over the connects answers at the next opportunity. Both passes are needed, from the code: an opportunity's pass skips every established stream. a_frame_moves_an_established_stream_with_no_opportunity_after_it and a_deadline_moves_an_established_stream_with_no_opportunity_after_it call receive and fire and offer no opportunity; both mutations are red on them at ddf479738.

Signatures that moved since ddf479738

Public: none. Node's calls and Pipes, PipeEnd, StreamId, StreamEvent, Watch, ToClient, FromClient, WriteRefusal, ReadRefusal are as they were. What a caller sees differently: a stream whose reading ended at the peer's FIN and whose client writes no more can now be answered StreamEvent::Cut.

Crate-private:

  • Stream::reader_left is gone. A stream built any way is timed once its to_client is None and done_writing is set; an accepted stream needs nothing of its own for that.
  • Stream::extended can turn true in any pass, not only the one that finds the client gone.
  • Stream::pass and Node::pass keep their signatures.

In the tests' fake:

  • Far gained addr, mac and parked, and Far::at(addr, mac); Far::new() is Far::at(B, MAC_B). Far::update() is the bare window update two sites built by hand.
  • Far::hears no longer asserts that a segment is to 192.0.2.2: it hears one only if it is to its own address and, once parked, from its own connection's port. The MAC assertion is on what it hears.
  • Net gained parked: Vec<Far>, Net::turn_to(next) and Net::hears(frame), which Net::opportunity calls in place of self.far.hears; an answer two peers give alike is delivered once.
  • Net::connect connects to self.far.addr.
  • Net::run_slowly_until opens the window of every slow peer, parked or not, that has had neither the node's FIN nor its reset.
  • New constants C and MAC_C.

Signatures that moved since 8a2302e90

Public:

  • PipeRefusal is gone. ToClient::write returns Result<usize, WriteRefusal> (Full, Gone, Broken); FromClient::read returns Result<usize, ReadRefusal> (Empty, Broken).
  • Nothing else: Node::connect, bridge, close, shutdown_write, set_nodelay, nodelay, pipe_gone, pipe_broken, watches, streams, drain_stream_events, and Pipes, PipeEnd, StreamId, StreamEvent, Watch are as they were. Node::transmit keeps its signature and gains the pass.

Crate-private:

  • Stack::connect, status, local_port, stream_send, stream_recv, shutdown_write, close, abort, set_options are tcp_connect, tcp_status, tcp_local_port, tcp_send, tcp_recv, tcp_shutdown_write, tcp_close, tcp_abort, tcp_set_options.
  • Stream gained remote, reader_left, extended and options (in place of nodelay); Stream::pass takes the per-peer count map; Node::pass(now, connects) is new.

What changed, per decision

  • streams.rs: pipe ends are two traits held boxed, so Node stays non-generic as stage A made it and the kernel's calls stay in netstack. No node counter and no new Event variant: connect answers and the one log line (Cut) are StreamEvents of their own queue.
  • lease.rs gained mod tcp;, one line, accepted by the orchestrator. src/lease/tcp.rs is a child of lease and holds only [tcp]'s forwards, so shard stays unreachable outside lease.
  • toyos-net-tcp: Tcp::recv_with (stack.rs, conn.rs, rx.rs, 40 lines). recv copies out and cannot put back what a full pipe refused; a chunk carried in the node would be a second buffer per connection whose bytes have already opened the window to the peer. recv_with shows the reader the oldest bytes held, in one piece, and lets go of as many as it answers.
  • toyos-net-shard: recv_with and set_options, forwards that settle. shutdown_read and info, which the cut also listed, are not added: nothing here calls them.
  • The watches (Node::watches) are netstack's poll-set rules for a stream's pipes, with no consumer until the move.

The checks (a trust boundary)

Wire input. No received byte is read in the node: every frame goes through toyos-net-wire inside the shard. no_cut_of_a_segment_is_a_segment delivers every prefix of a peer's segment, and no_flipped_bit_of_a_segment_delivers_other_bytes every single-bit flip of it, each to a fresh established stream: the client reads the peer's text or nothing, the stream stands, nothing panics. The unreachable!s are on [tcp]'s answers to the holder of a connection and on the pipe traits' contract; the review constructed no wire input that reaches one, nor could I.

Time is an argument of every call; the tests' clock moves only to next_deadline or, for the slow reader, to its next second. No wait anywhere.

Independent oracle. etherparse reads every segment the node emits (Ethernet addresses, IPv4 header checksum and total length, TCP checksum, ports) and builds every segment the peer sends. The numbers each test asserts are RFC 9293's, cited at the test. Not independent: the peer's behaviour is the tests' own script (it acknowledges in order, loses nothing; the slow reader opens a window of 1,460 bytes once a second), every Watch assertion, and the 100 s and the 16, which are the node's own constants. No second TCP has been on the far end: the cut's first oracle for this stage was a smoltcp peer, which the owner ruled out. In the track, with its exit (the host kernel's TCP through slirp, at the move).

Negative control. 49 named mutations, one per rule, each applied as a checked patch, shown to build, run against one test by exact name and reversed. Round 2 regenerated the 13 whose lines moved, dropped d28, d29 and d30 with the field they forgot to set, and added d43 to d46. Beside them the two new rules' tests run against ddf479738's streams.rs, the whole source change reverted. The patches are in the newest mutation comment below.

Gates

Requests 1 to 7 were run by the orchestrator; the eighth I ran myself, under the owner's ruling that agents build again.

request head step exit
1 (ownstack-d-r1.log) 96c0fd25a lock, the three crates' tests, the four gates, [tcp]'s and the shard's clippy 0 each
the node's clippy 101: clippy::drain_collect in a test
31 mutations 101 each
2 (ownstack-d-r2.log) 8a2302e90 lock, compile, the three crates' tests, the three clippies 0 each
3 (ownstack-d-r3.log) bd6273b5c cargo metadata --locked 0
cargo test --locked -p toyos-net-tcp, -p toyos-net-shard, --manifest-path userland/netstack/node/Cargo.toml 0, 0, 0
cargo test --locked --lib -- hostws:: userlandhost:: sourcegate:: licence:: 0, 45 tests run
the three clippies, --all-targets, adopted lints, -D warnings 0 each
48 mutations: builds 0 each; 46 named tests 101; d22 and d23 0, expected red request exit 1
4 (ownstack-d-r4.log) ddf479738 lock, the three crates' tests, the three clippies 0 each
d22, d23 on their re-aimed tests 101, 101
all 48 patches apply; tree clean yes
5 (ownstack-d-r5.log) e0b917b04 cargo metadata --locked 0
cargo test --locked -p toyos-net-tcp, -p toyos-net-shard, --manifest-path userland/netstack/node/Cargo.toml 0, 0, 0
cargo test --locked --lib -- hostws:: userlandhost:: sourcegate:: licence:: 0, 45 tests run
the three clippies, --all-targets, adopted lints, -D warnings 0 each
red first, ddf479738's streams.rs under this head's tests: builds; the BLOCKER's test; the first NOTE's test; the two-address test as control 0; 101; 101; 0
the same two tests at the head 0, 0
49 mutations: builds 0 each; each named test 101 each
the node's and [tcp]'s take tests again, unmutated; tree clean 0, 0; yes
the request 0
6 (ownstack-d-r6.log) 47231f0d7 cargo test --locked --manifest-path userland/netstack/node/Cargo.toml (streams 34, lease 18, slirp 3) 0
the node's clippy, --all-targets, adopted lints, -D warnings 0
7 (ownstack-d-r7.log) da4a51968 cargo metadata --locked 0
cargo test --locked -p toyos-net-tcp, -p toyos-net-shard, --manifest-path userland/netstack/node/Cargo.toml 0, 0, 0
cargo test --locked --lib -- hostws:: userlandhost:: sourcegate:: licence:: 0, 45 tests run
the three clippies, --all-targets, adopted lints, -D warnings 0 each
49 stream mutations: builds 0 each; each named test 101 each
stage B's n1 to n10 on the name target, eleven runs: builds 0 each; each named test 101 each
the node's and [tcp]'s take tests again, unmutated; tree clean 0, 0; yes
the request 0
8, run by me (logs: the orchestrator's scratchpad, ownstack/d/logs8/, one file per step, the command first and the exit code last; _summary.log lists every exit) 49e38ca4a cargo metadata --locked 0
cargo test --locked -p toyos-net-tcp, -p toyos-net-shard, -p toyos-dns, --manifest-path userland/netstack/node/Cargo.toml 0, 0, 0, 0
cargo test --locked --lib -- hostws:: userlandhost:: sourcegate:: licence:: 0, 45 tests run
clippy over [tcp], the shard, toyos-dns and the node, --all-targets, adopted lints, -D warnings 0 each
49 stream mutations: builds 0 each; each named test 101 each
stage B's n1 to n10 on the name target, eleven runs: builds 0 each; each named test 101 each
the resolver's r16 and r17 on the resolve target: builds 0; each named test 101, 101
the node's and [tcp]'s take tests again, unmutated; tree clean 0, 0; yes

At 49e38ca4a the node's streams target runs 34 tests, datagram 5, lease 18, name 13, resolve 23 and slirp 3; toyos-dns runs 44; [tcp]'s take runs 5, and its other targets and the shard's each run more than 0 (the log lists every count).

Not run, and not claimed: cargo run -- --ci host at this head; the pull request is a draft and its checks have not run. The red-first runs against ddf479738's streams.rs are request 5's, at e0b917b04, and were not repeated after the merges. No guest test reaches the change: no shipped package depends on toyos-net-node, toyos-net-shard or toyos-net-tcp.

What I am unsure of

  • The 16. No measurement set it, and a per-address count is the only share the node can keep: it knows peers, not clients. It is no bound on the node and none on the rate; the track says which line bounds the node.
  • A live client past the 16, or whose peer takes nothing for 100 s, loses its tail after the peer's FIN as a dead one does. The node cannot tell them apart, and such a client cannot learn of the loss from the connection either way; the cut is logged.
  • Whether [tcp]'s 60 s idle bound is the right one for a connection its client left with nothing in its pipe. A peer that acknowledges a byte a minute holds one for as long as it has bytes to acknowledge. In the track, with its exit.
  • Refused versus Unreachable on the pipe ABI and every other mapping onto toyos::net's eight error codes is the move's.
  • a_close_with_text_unread_resets tests a rule that is [tcp]'s; it is kept as the one place that shows the node hands a close to the stack and not an abort or a hold.

Stage E (listeners) follows on this branch line, in its own pull request.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A

Japabu and others added 3 commits October 8, 2026 17:46
…n it is shown

A pipe that is nearly full takes a prefix of what it is offered. `recv`
copies out and cannot put back what was refused, and a byte that left the
receive buffer has opened the window to the peer. `recv_with` shows the
reader the oldest bytes held, in one piece, and lets go of as many as it
answers; the window update a read owes is owed the same way.

The shard forwards it, and `set_options`, for the node's streams.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
…lient's two pipes

Stage D of the own stack's cut: netstack's stream decisions, ported onto
ToyOS's TCP, host-tested and shipped in nothing. Connect with its
deadline, the bridge's two rules (nothing leaves the stack that the pipe
did not take, nothing leaves the pipe that the stack will not take),
half-close in both orders, a reset surfaced as the end of both pipes, a
departed client, Nagle, and what netstack asks the kernel about each pipe.
The pipe ends are traits; the kernel's calls stay in netstack.

Where the own stack changes the shape of what is ported:

- The node holds a stream only while it holds one of its pipes. With
  neither left it closes the connection and [tcp] finishes it alone by its
  rules for a user who let go, a reset on unread text included. netstack
  on smoltcp held such a connection itself for 100 s and its reset for 3 s
  more; the 100 s remains for the one case [tcp] cannot see, a departed
  client's bytes still in its pipe, and the reset is [tcp]'s to deliver.
- A close or a shutdown sends what the client's pipe still holds before
  the FIN, where netstack dropped the pipe and its bytes with it.
- [tcp]'s calls reach the shard through `lease/tcp.rs`, a child of
  `lease`, so the shard stays unreachable outside that module.

The peer in `tests/streams.rs` is the tests' own script; `etherparse`
reads every segment the node emits and builds every one it receives. The
track records what that leaves unshown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
…to a copy

clippy::drain_collect, which the first request's node clippy step refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

The 31 mutations of stage D

Run by the orchestrator at 96c0fd25a (orch/builds/ownstack-d-r1.log), each by build-request.sh's mutation: git apply --check, git apply, cargo test --no-run (exit 0 every time), then the one test by --exact name, then git apply -R and a clean-tree check. t patches run cargo test --locked -p toyos-net-tcp --test take, the rest cargo test --locked --manifest-path userland/netstack/node/Cargo.toml --test streams. At 8a2302e90 every patch still applies.

mutation test that turned red exit
t1-a-take-lets-go-of-all-it-was-shown what_the_reader_did_not_take_stays_held_in_order 101
t2-a-take-owes-no-window-update a_take_owes_the_window_update_a_read_does 101
t3-a-takers-count-is-not-clamped text_across_the_rings_wrap_is_shown_in_two_pieces_and_none_is_lost 101
s1-the-shards-take-settles-nothing room_in_the_pipe_opens_the_window_at_the_next_opportunity 101
s2-the-shards-options-settle-nothing nodelay_reaches_the_stack 101
d01-the-stack-lets-go-of-what-the-pipe-refused a_full_pipe_keeps_the_rest_in_the_stack_and_is_watched_for_room 101
d02-the-pipe-is-read-past-the-stacks-room nothing_leaves_the_pipe_that_the_stack_will_not_take 101
d17-a-pipe-is-watched-for-bytes-the-stack-has-no-room-for nothing_leaves_the_pipe_that_the_stack_will_not_take 101
d18-a-pipe-is-watched-for-room-nobody-waits-on a_connect_is_a_handshake_and_its_answer_names_the_port 101
d22-a-frame-ends-in-no-pass a_connect_is_a_handshake_and_its_answer_names_the_port 101
d07-the-connects-deadline-is-not-kept a_connect_past_its_deadline_is_timed_out_at_the_deadline 101
d21-the-nodes-deadline-leaves-streams-out a_connect_past_its_deadline_is_timed_out_at_the_deadline 101
d23-a-deadline-ends-in-no-pass a_connect_past_its_deadline_is_timed_out_at_the_deadline 101
d08-a-refusal-is-not-answered a_refused_connect_is_answered_with_the_refusal 101
d26-a-closed-connect-is-not-answered a_connect_closed_before_its_answer_is_answered_closed 101
d03-the-end-of-writing-sends-no-fin the_end_of_the_clients_writing_is_a_fin_and_the_peer_still_sends 101
d04-the-peers-fin-is-not-read-through the_peers_fin_ends_the_clients_reading_and_not_its_writing 101
d05-a-failure-is-not-read-through a_reset_ends_both_pipes_and_the_stream 101
d06-a-connection-that-is-over-keeps-the-clients-pipe a_reset_ends_both_pipes_and_the_stream 101
d11-an-empty-pipe-is-not-the-end-of-a-writer-who-is-done a_close_sends_what_the_pipe_held_and_then_the_fin 101
d25-a-close-keeps-the-readers-end a_close_sends_what_the_pipe_held_and_then_the_fin 101
d12-a-shutdown-marks-nothing a_shutdown_sends_what_the_pipe_held_and_then_the_fin 101
d10-a-stream-with-no-pipe-is-kept a_client_that_left_is_finished_by_the_stack 101
d20-a-gone-readers-end-is-kept a_client_that_left_is_finished_by_the_stack 101
d13-a-gone-reader-is-waited-on a_reader_that_left_is_not_written_to_again 101
d09-a-departed-clients-bytes-have-101-seconds a_departed_clients_unsent_bytes_have_100_seconds 101
d19-a-gone-writer-is-not-marked a_departed_clients_unsent_bytes_have_100_seconds 101
d14-a-broken-write-end-is-waited-on a_pipe_that_is_no_pipe_resets_its_connection 101
d15-a-broken-read-end-is-waited-on a_pipe_that_is_no_pipe_resets_its_connection 101
d24-a-refused-watch-resets-whatever-is-held a_refused_watch_resets_only_a_stream_that_holds_the_end 101
d16-nodelay-stays-in-the-node nodelay_reaches_the_stack 101
The patches

d01-the-stack-lets-go-of-what-the-pipe-refused.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..db036d590 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -153,7 +153,7 @@ impl Stream {
         while let Some(pipe) = self.to_client.as_mut() {
             let mut refused = None;
             let received = stack.stream_recv(now, conn, |bytes| match pipe.write(bytes) {
-                Ok(taken) => taken,
+                Ok(_) => bytes.len(),
                 Err(refusal) => {
                     refused = Some(refusal);
                     0

d02-the-pipe-is-read-past-the-stacks-room.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..d9606d555 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -181,7 +181,7 @@ impl Stream {
         while let Some(pipe) = self.from_client.as_mut() {
             // Never more than [tcp] has room for, and so never a read of no bytes, whose answer
             // would be the end's.
-            let room = stack.status(conn).writable.min(CHUNK);
+            let room = CHUNK;
             if room == 0 {
                 break;
             }

d03-the-end-of-writing-sends-no-fin.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..543c0f900 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -197,7 +197,6 @@ impl Stream {
                 }
             };
             if read == 0 {
-                stack.shutdown_write(now, conn);
                 self.from_client = None;
                 break;
             }

d04-the-peers-fin-is-not-read-through.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..7124c4f83 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -172,7 +172,8 @@ impl Stream {
                 }
                 // The peer's FIN after its last byte, or the connection's failure: the client
                 // reads the end.
-                (Ok(Received::End) | Err(Error::Failed(_)), _) => self.to_client = None,
+                (Ok(Received::End), _) => break,
+                (Err(Error::Failed(_)), _) => self.to_client = None,
                 (Err(Error::WouldBlock), _) => break,
                 (Err(refusal), _) => unreachable!("[tcp] refused the holder of a connection a read: {refusal:?}"),
             }

d05-a-failure-is-not-read-through.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..d947a9041 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -172,7 +172,8 @@ impl Stream {
                 }
                 // The peer's FIN after its last byte, or the connection's failure: the client
                 // reads the end.
-                (Ok(Received::End) | Err(Error::Failed(_)), _) => self.to_client = None,
+                (Ok(Received::End), _) => self.to_client = None,
+                (Err(Error::Failed(_)), _) => break,
                 (Err(Error::WouldBlock), _) => break,
                 (Err(refusal), _) => unreachable!("[tcp] refused the holder of a connection a read: {refusal:?}"),
             }

d06-a-connection-that-is-over-keeps-the-clients-pipe.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..e93c79b7b 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -208,9 +208,6 @@ impl Stream {
         let status = stack.status(conn);
         // A connection that is over takes no more of the client's bytes: its writes fail rather
         // than fill a pipe nobody drains.
-        if matches!(status.state, State::Closed | State::TimeWait) {
-            self.from_client = None;
-        }
         self.room = status.writable > 0;
         if self.to_client.is_none() && self.from_client.is_none() {
             stack.close(now, conn);

d07-the-connects-deadline-is-not-kept.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..7d4373654 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -136,7 +136,6 @@ impl Stream {
             let status = stack.status(conn);
             let event = match (status.state, status.failure) {
                 (_, Some(failure)) => StreamEvent::Failed { id, failure },
-                (State::SynSent | State::SynReceived, None) if self.deadline.is_some_and(|at| at <= now) => StreamEvent::TimedOut { id },
                 (State::SynSent | State::SynReceived, None) => return true,
                 (_, None) => StreamEvent::Connected { id, local: stack.local_port(conn) },
             };

d08-a-refusal-is-not-answered.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..ab4ec07e9 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -135,7 +135,7 @@ impl Stream {
         if self.connecting {
             let status = stack.status(conn);
             let event = match (status.state, status.failure) {
-                (_, Some(failure)) => StreamEvent::Failed { id, failure },
+                (_, Some(_)) => return true,
                 (State::SynSent | State::SynReceived, None) if self.deadline.is_some_and(|at| at <= now) => StreamEvent::TimedOut { id },
                 (State::SynSent | State::SynReceived, None) => return true,
                 (_, None) => StreamEvent::Connected { id, local: stack.local_port(conn) },

d09-a-departed-clients-bytes-have-101-seconds.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..b1033971a 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -37,7 +37,7 @@ use crate::Node;
 /// How long a departed client's unsent bytes have: R2, the time RFC 9293 §3.8.3 gives a
 /// segment's retransmission before the connection is closed, at the 100 seconds it asks for at
 /// least.
-const OWNERLESS_LIFE: Duration = Duration::from_secs(100);
+const OWNERLESS_LIFE: Duration = Duration::from_secs(101);
 
 /// The most one read of a client's pipe takes.
 const CHUNK: usize = 4096;

d10-a-stream-with-no-pipe-is-kept.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..dded2f609 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -212,10 +212,6 @@ impl Stream {
             self.from_client = None;
         }
         self.room = status.writable > 0;
-        if self.to_client.is_none() && self.from_client.is_none() {
-            stack.close(now, conn);
-            return false;
-        }
         if self.to_client.is_none() && self.done_writing {
             let at = *self.deadline.get_or_insert(now.after(OWNERLESS_LIFE));
             if at <= now {

d11-an-empty-pipe-is-not-the-end-of-a-writer-who-is-done.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..c81586cab 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -189,7 +189,6 @@ impl Stream {
             let Some(space) = chunk.get_mut(..room) else { unreachable!("room is at most a chunk") };
             let read = match pipe.read(space) {
                 Ok(read) => read,
-                Err(PipeRefusal::WouldBlock) if self.done_writing => 0,
                 Err(PipeRefusal::WouldBlock) => break,
                 Err(PipeRefusal::Gone | PipeRefusal::Broken) => {
                     stack.abort(now, conn);

d12-a-shutdown-marks-nothing.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..8d72ae29c 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -290,7 +290,6 @@ impl Node {
     /// id that names no established stream.
     pub fn shutdown_write(&mut self, now: Instant, id: StreamId) -> bool {
         let Some(stream) = self.streams.live.get_mut(&id).filter(|stream| !stream.connecting) else { return false };
-        stream.done_writing = true;
         self.bridge(now);
         true
     }

d13-a-gone-reader-is-waited-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..e83e7533f 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -165,7 +165,7 @@ impl Stream {
                     self.held = true;
                     break;
                 }
-                (Ok(Received::Data(_)), Some(PipeRefusal::Gone)) => self.to_client = None,
+                (Ok(Received::Data(_)), Some(PipeRefusal::Gone)) => break,
                 (Ok(Received::Data(_)), Some(PipeRefusal::Broken)) => {
                     stack.abort(now, conn);
                     return false;

d14-a-broken-write-end-is-waited-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..b5b794baa 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -166,10 +166,7 @@ impl Stream {
                     break;
                 }
                 (Ok(Received::Data(_)), Some(PipeRefusal::Gone)) => self.to_client = None,
-                (Ok(Received::Data(_)), Some(PipeRefusal::Broken)) => {
-                    stack.abort(now, conn);
-                    return false;
-                }
+                (Ok(Received::Data(_)), Some(PipeRefusal::Broken)) => break,
                 // The peer's FIN after its last byte, or the connection's failure: the client
                 // reads the end.
                 (Ok(Received::End) | Err(Error::Failed(_)), _) => self.to_client = None,

d15-a-broken-read-end-is-waited-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..eef6e0442 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -191,10 +191,7 @@ impl Stream {
                 Ok(read) => read,
                 Err(PipeRefusal::WouldBlock) if self.done_writing => 0,
                 Err(PipeRefusal::WouldBlock) => break,
-                Err(PipeRefusal::Gone | PipeRefusal::Broken) => {
-                    stack.abort(now, conn);
-                    return false;
-                }
+                Err(PipeRefusal::Gone | PipeRefusal::Broken) => break,
             };
             if read == 0 {
                 stack.shutdown_write(now, conn);

d16-nodelay-stays-in-the-node.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..74abaac68 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -299,7 +299,6 @@ impl Node {
     pub fn set_nodelay(&mut self, now: Instant, id: StreamId, nodelay: bool) -> bool {
         let Some(stream) = self.streams.live.get_mut(&id) else { return false };
         stream.nodelay = nodelay;
-        self.stack.set_options(now, stream.conn, Options { nodelay, ..Options::default() });
         true
     }
 

d17-a-pipe-is-watched-for-bytes-the-stack-has-no-room-for.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..e4cff147c 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -336,7 +336,7 @@ impl Node {
     pub fn watches(&self) -> impl Iterator<Item = (StreamId, Watch)> + '_ {
         self.streams.live.iter().filter(|(_, stream)| !stream.connecting).map(|(id, stream)| {
             let (from, to) = (stream.from_client.is_some(), stream.to_client.is_some());
-            (*id, Watch { readable: from && stream.room, writer: from && !stream.done_writing, writable: to && stream.held, reader: to })
+            (*id, Watch { readable: from, writer: from && !stream.done_writing, writable: to && stream.held, reader: to })
         })
     }
 

d18-a-pipe-is-watched-for-room-nobody-waits-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..1bb9a0c2e 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -336,7 +336,7 @@ impl Node {
     pub fn watches(&self) -> impl Iterator<Item = (StreamId, Watch)> + '_ {
         self.streams.live.iter().filter(|(_, stream)| !stream.connecting).map(|(id, stream)| {
             let (from, to) = (stream.from_client.is_some(), stream.to_client.is_some());
-            (*id, Watch { readable: from && stream.room, writer: from && !stream.done_writing, writable: to && stream.held, reader: to })
+            (*id, Watch { readable: from && stream.room, writer: from && !stream.done_writing, writable: to, reader: to })
         })
     }
 

d19-a-gone-writer-is-not-marked.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..71e4371fe 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -313,7 +313,7 @@ impl Node {
         let Some(stream) = self.streams.live.get_mut(&id).filter(|stream| !stream.connecting) else { return };
         match end {
             PipeEnd::ToClient => stream.to_client = None,
-            PipeEnd::FromClient => stream.done_writing = true,
+            PipeEnd::FromClient => {}
         }
         self.bridge(now);
     }

d20-a-gone-readers-end-is-kept.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..52fb7828f 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -312,7 +312,7 @@ impl Node {
     pub fn pipe_gone(&mut self, now: Instant, id: StreamId, end: PipeEnd) {
         let Some(stream) = self.streams.live.get_mut(&id).filter(|stream| !stream.connecting) else { return };
         match end {
-            PipeEnd::ToClient => stream.to_client = None,
+            PipeEnd::ToClient => {}
             PipeEnd::FromClient => stream.done_writing = true,
         }
         self.bridge(now);

d21-the-nodes-deadline-leaves-streams-out.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index 4fc03ff11..0e076cfb1 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -136,7 +136,7 @@ impl Node {
     // ---- the clock ----
 
     pub fn next_deadline(&self) -> Option<Instant> {
-        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(self.streams.next_deadline()).min()
+        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).min()
     }
 
     /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`].

d22-a-frame-ends-in-no-pass.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index 4fc03ff11..b93d19cdc 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -113,7 +113,6 @@ impl Node {
     pub fn receive(&mut self, now: Instant, frame: &[u8], mut draw: impl FnMut() -> u32) {
         self.stack.receive(now, frame);
         self.settle(now, &mut draw);
-        self.bridge(now);
     }
 
     /// A transmit opportunity with room for `credit` frames, each handed to `sink` as it is built.

d23-a-deadline-ends-in-no-pass.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index 4fc03ff11..80e7570ab 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -146,7 +146,6 @@ impl Node {
         let out = self.client.timer(now, &mut draw);
         self.carry_out(now, out, None, &mut draw);
         self.settle(now, &mut draw);
-        self.bridge(now);
     }
 
     /// Hands the client what the shard reported and what reached its socket, and carries out

d24-a-refused-watch-resets-whatever-is-held.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..919947164 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -326,7 +326,7 @@ impl Node {
             PipeEnd::ToClient => stream.to_client.is_some(),
             PipeEnd::FromClient => stream.from_client.is_some(),
         };
-        if held {
+        if held || true {
             self.stack.abort(now, stream.conn);
             self.streams.live.remove(&id);
         }

d25-a-close-keeps-the-readers-end.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..c6636a393 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -281,7 +281,6 @@ impl Node {
             self.streams.events.push_back(StreamEvent::Closed { id });
             return;
         }
-        stream.to_client = None;
         stream.done_writing = true;
         self.bridge(now);
     }

d26-a-closed-connect-is-not-answered.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 4217b67f5..fab205701 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -278,7 +278,6 @@ impl Node {
         if stream.connecting {
             self.stack.abort(now, stream.conn);
             self.streams.live.remove(&id);
-            self.streams.events.push_back(StreamEvent::Closed { id });
             return;
         }
         stream.to_client = None;

s1-the-shards-take-settles-nothing.patch

diff --git a/toyos-net-shard/src/lib.rs b/toyos-net-shard/src/lib.rs
index 5d2f804c1..7a56ace07 100644
--- a/toyos-net-shard/src/lib.rs
+++ b/toyos-net-shard/src/lib.rs
@@ -481,7 +481,6 @@ impl Shard {
     /// [`Tcp::recv_with`]: `take` is shown the oldest bytes held and answers how many it took.
     pub fn recv_with(&mut self, now: Instant, id: ConnId, take: impl FnOnce(&[u8]) -> usize) -> Result<Received, toyos_net_tcp::Error> {
         let done = self.tcp.recv_with(now, id, take);
-        self.settle(now);
         done
     }
 

s2-the-shards-options-settle-nothing.patch

diff --git a/toyos-net-shard/src/lib.rs b/toyos-net-shard/src/lib.rs
index 5d2f804c1..e0cfe0c97 100644
--- a/toyos-net-shard/src/lib.rs
+++ b/toyos-net-shard/src/lib.rs
@@ -487,7 +487,6 @@ impl Shard {
 
     pub fn set_options(&mut self, now: Instant, id: ConnId, options: toyos_net_tcp::Options) -> Result<(), toyos_net_tcp::Error> {
         let done = self.tcp.set_options(now, id, options);
-        self.settle(now);
         done
     }
 

t1-a-take-lets-go-of-all-it-was-shown.patch

diff --git a/toyos-net-shard/tcp/src/rx.rs b/toyos-net-shard/tcp/src/rx.rs
index 8a5900d80..ec891d6e8 100644
--- a/toyos-net-shard/tcp/src/rx.rs
+++ b/toyos-net-shard/tcp/src/rx.rs
@@ -343,7 +343,8 @@ impl Rx {
     pub fn read_with(&mut self, take: impl FnOnce(&[u8]) -> usize) -> usize {
         let (held, _) = self.buf.slices(0, self.buf.len());
         let n = take(held).min(held.len());
-        self.buf.consume(n);
+        let shown = held.len();
+        self.buf.consume(shown);
         n
     }
 

t2-a-take-owes-no-window-update.patch

diff --git a/toyos-net-shard/tcp/src/conn.rs b/toyos-net-shard/tcp/src/conn.rs
index b0086dfff..65b0225c1 100644
--- a/toyos-net-shard/tcp/src/conn.rs
+++ b/toyos-net-shard/tcp/src/conn.rs
@@ -871,9 +871,6 @@ impl Sync {
             return if self.rx.closed { Ok(Received::End) } else { Err(Error::WouldBlock) };
         }
         let n = self.rx.read_with(take);
-        if n > 0 {
-            self.rx.after_read(self.smss());
-        }
         Ok(Received::Data(n))
     }
 

t3-a-takers-count-is-not-clamped.patch

diff --git a/toyos-net-shard/tcp/src/rx.rs b/toyos-net-shard/tcp/src/rx.rs
index 8a5900d80..047ddabec 100644
--- a/toyos-net-shard/tcp/src/rx.rs
+++ b/toyos-net-shard/tcp/src/rx.rs
@@ -342,7 +342,7 @@ impl Rx {
     /// many of them as it answers it took.
     pub fn read_with(&mut self, take: impl FnOnce(&[u8]) -> usize) -> usize {
         let (held, _) = self.buf.slices(0, self.buf.len());
-        let n = take(held).min(held.len());
+        let n = take(held);
         self.buf.consume(n);
         n
     }

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review of wt/toyos-ownstack-d at 8a2302e90, round 1, what it adds over wt/toyos-ownstack-a at 9a99ee0ba. Read, not run.

Net lines over the base: 12 files, +1,424, -4. Production +482 (streams.rs 352, lease/tcp.rs 64, lib.rs and lease.rs 13, toyos-net-tcp 40, the shard 13), tests +939 (tests/streams.rs 828, tcp/tests/take.rs 111), the track and the manifest 7. Nothing is deleted because nothing ships this yet; the growth is accepted as the stage's, less what the findings below take out or add.

BLOCKER

  • userland/netstack/node/src/lib.rs:121 — Node::transmit ends in no pass, and a connect can fail inside it — Tcp::serve ends a SYN-SENT connection whose next hop failed (toyos-net-shard/tcp/src/stack.rs:1322-1325, asked through hop, toyos-net-shard/src/lib.rs:569-580: a neighbour that answered no ARP, or a route withdrawn under the connect), and end takes the connection's deadline with it. With timeout: None the stream then has no deadline of its own either, so Failed { Unreachable } reaches the client at the next frame that happens to arrive or the next unrelated deadline (the lease's renewal, on a quiet link), and with a timeout it arrives at the timeout instead of when [tcp] knew. streams.rs:24 ("every call that can move a stream ends in a pass") is not true of the tree. No test has a peer that answers no ARP: Far::hears answers for B whatever its Manner. Wanted: the pass, and a test whose peer's address resolves to nobody, timeout: None, driven by next_deadline alone, that finds Failed { failure: Unreachable(..) } and streams() == 0 without the clock reaching the lease's renewal.
  • userland/netstack/node/src/streams.rs:149 — no test fails without self.deadline = None — every test that establishes a stream connects with None, and the one with a timeout never connects. Patch: delete line 149. I expect all 23 to stay green. With it gone, a client that connected with a timeout (std's connect_timeout) leaves next_deadline in the past for the stream's whole life, so the caller's loop fires with nothing due, and get_or_insert at line 220 finds the connect's deadline, so its unsent bytes are cut at once and not after 100 s. Must turn red: a_departed_clients_unsent_bytes_have_100_seconds on a stream connected with a timeout, and a new assertion that an established stream that connected with one contributes no deadline.
  • userland/netstack/node/src/streams.rs:145 and :279 — no test fails without either stack.abort of a connect that is not answered Connected — Patch 1: delete line 145. Patch 2: delete line 279. a_connect_past_its_deadline_is_timed_out_at_the_deadline stops reading at streams() == 0, a_refused_connect_is_answered_with_the_refusal and a_connect_closed_before_its_answer_is_answered_closed read only the event, the count and the pipe ends. Without the abort the connection stays User::Held in [tcp] with nobody holding its id: its SYN is retransmitted until SYN_GIVE_UP, and its slot and port are never freed (end keeps a held connection). Must turn red: the first under patch 1 (no SYN leaves after the deadline, run on past it), the third under patch 2 (the same, after the close).
  • userland/netstack/node/src/streams.rs:219 — no test fails with the cut's condition widened to every stream nobody reads — Patch: if self.to_client.is_none() && self.done_writing to if self.to_client.is_none(). the_peers_fin_ends_the_clients_reading_and_not_its_writing and a_reader_that_left_is_not_written_to_again never move the clock, and in a_departed_clients_unsent_bytes_have_100_seconds the writer is marked first, so the deadline is the same instant. With the patch a client that still writes after the peer's FIN (RFC 9293 §3.6 case 2, CLOSE-WAIT) is reset 100 s later. Must turn red: the_peers_fin_ends_the_clients_reading_and_not_its_writing, with more than 100 s between the peer's FIN and the client's last write. (The other half, dropping self.to_client.is_none() &&, is caught: the test staggers the two leavings by 5 s.)
  • userland/netstack/node/src/streams.rs:194 — the PipeRefusal::Gone arm of a read resets the connection and no test reaches it — the fake ReadEnd never answers Gone, and d15 replaces both arms at once while the test sets only read_broken. Patch: Err(PipeRefusal::Gone) => break, beside Err(PipeRefusal::Broken) => { abort }. Stays green. The contract disagrees with itself here: FromClient::read says a pipe with no writer answers 0 once empty, PipeRefusal::Gone says "nobody holds the other end", and the arm makes the second a reset of a client that only left. Either a read cannot answer Gone (a refusal type per trait, and the arm goes: nothing a type refuses is tested), or the rule is stated and a_pipe_that_is_no_pipe_resets_its_connection turns red under the patch.
  • toyos-net-shard/tcp/src/stack.rs:1011-1014 — recv_with on a connection both FINs ended has no test — take.rs reads only a synchronized connection, and in the_end_of_the_clients_writing_is_a_fin_and_the_peer_still_sends the pipe has taken everything before the peer's FIN. Patch: replace the arm's match with Ok(Received::End). Stays green, and the client then loses, with nothing saying so, every byte its full pipe had left in [tcp] when the peer's FIN completed the close (end(index, None, Some(rx)), stack.rs:873, 878). Must turn red: a new take.rs test (our FIN acknowledged, text and the peer's FIN arriving unread, then takes that return the text and only then End), and a streams.rs test of the same with room = 0 until after the FIN.
  • Evidence — cargo run -- --ci host has not run at 8a2302e90 (the body says so), nor the four named gates at this head — the rule holds a branch without it. Its log at the head that lands must show, not as a grepped result line: the node's streams target running 23 tests (more, after the above), lease 18 and slirp 3; toyos-net-tcp's take running 4 tests; toyos-net-shard's targets with a count above 0; hostws::, userlandhost::, sourcegate::, licence:: run; and clippy on the three crates with CI's toolchain at exit 0.

NOTE

  • userland/netstack/node/src/streams.rs:268-272 with toyos-net-shard/tcp/src/stack.rs:1016 — every received frame and every deadline runs a pass over every stream, and each pass's recv_with ends in Tcp::settle, which re-files the connection's deadline and offers it to the transmit round — so one ARP frame makes every idle stream eligible, and the next opportunity serves each to learn it has nothing. Correct, and linear in streams per frame in both directions. The listener stage copies whatever this is: decide before it does whether a pass is driven by what changed, and measure it at the move.
  • userland/netstack/node/src/streams.rs:302 — Options { nodelay, ..Options::default() } writes the whole option set — the first keep-alive or user timeout a later stage sets is erased by the next set_nodelay, and a listener's children inherit options. The stream holds the Options it last wrote, or the forward takes the one field.
  • userland/netstack/node/src/streams.rs:219-226 — the 100 s cut is issues/netstack-cuts-a-departed-clients-unsent-tail-at-the-ceiling.md rebuilt, both halves: absolute from the leaving whatever progress the peer makes while bytes are still in the pipe, and applied to a live client that shut its writing after the peer's FIN ended its reading. That issue names only userland/netstack/src/main.rs; its exit (kept while it makes progress, under a bound) is not met by the node, and neither the track nor this body says the node carries it. The track's bullet names it, or the clock runs from the last byte the pipe gave up once the bound exists.
  • userland/netstack/node/src/streams.rs:298-308 — a stream its peer reset is gone from the node, so set_nodelay answers false and nodelay None for a client that still holds it: issues/a-stream-its-peer-reset-refuses-the-option-requests-a-host-answers.md, whose exit the node as built cannot meet. Same owner; the track should say the node carries it to the move.
  • issues/toyos-has-its-own-network-stack.md:27 — the stream bound's exit, "with them or with the move", is one a reader can check, and a stage shipped in nothing may land without the bound. What must hold before netstack runs on the node: a refusal of a connect past a bound that also counts connecting streams and the connections [tcp] finishes alone (60 s idle, restarted by every acknowledgment, so a peer holds one at will), since [tcp] has no table limit of its own and each connection is two 64 KiB buffers of netstack's.
  • toyos-net-shard/tcp/tests/take.rs:1-3 — the track says every test names its scenario id; Tcp::recv_with is an addition to [tcp] the specifications do not hold, and the track's departures do not list it with an exit.
  • userland/netstack/node/src/lease/tcp.rs — widens nothing: every method is a forward on a ConnId, none reaches add_address, remove_address, set_gateways or the state, and shard stays private to lease. connect reads the address only through [ip]'s route. The names connect, close, status on Stack are TCP's with nothing saying so; toyos-net-node: clients' datagram sockets and the machine's mDNS name on ToyOS's own UDP, host-tested and shipped in nothing #772 adds its forwards to the same type.
  • The body, behaviour 2 — "a departed client (no reader, writer gone)" is narrower than the code, which also times a live client (above).

Asked in the brief

  • Wire input. Nothing a peer sends reaches a panic or an unreachable! that I can construct: held is called only on a connection the stream still holds (every abort and close is paired with the stream's removal), stream_send only for at most Status::writable re-read each turn, shutdown_write only straight after writable > 0, and close, abort and set_options answer Ok in every state of a held connection. Bytes the node holds between pipe and stack: none going in (recv_with hands [tcp]'s own slice to the pipe), one 4,096-byte stack chunk going out; the bounds are [tcp]'s two buffers. Events: at most two per stream. Out-of-window data, a reset in each state, a FIN on the SYN-ACK, zero windows and simultaneous close are [tcp]'s (acceptability.rs, close.rs, handshake.rs, timers.rs), not retested here, rightly. The node's own, tested here: both half-close orders, the reset in ESTABLISHED, the refusal, the window shut by a full pipe and reopened. The node's own and not tested: the blockers above, a reset after the reader's end was let go, and a peer that never opens its window against a client still alive (held, correctly, for as long as the client holds it).
  • The oracle. Checked against RFC 9293 myself: the handshake's numbers (§3.5 figure 6: SYN without ACK, then ACK of ISS+1 from our ISS+1) are right; case 1 (§3.6: FIN at first+7, text taken in FIN-WAIT-2, the peer's FIN acknowledged at its sequence plus one) and case 2 (text after the peer's FIN, then our FIN, no reset) are right; the reset is built at exactly RCV.NXT (§3.10.7.4, first check) and the refusal acknowledges the SYN (§3.10.7.3); the abort's reset is at SND.NXT and SYN-SENT sends none (§3.10.5). Independent of the code under test: etherparse's reading of every emitted segment and its checksums, and those RFC numbers. Not independent: the peer's script, every Watch assertion (the node's rule read back), the dropped-end flags, and the 100 s, which is the node's own constant under R2's name.
  • The seven behaviours. 1, 3, 4, 5, 6 are the right ones: an orphan is [tcp]'s by §3.6.1, a reset's delivery is the stack's, and 4 is the fix of issues/netstack-removes-a-closed-stream-before-its-fin-leaves.md. 2 is the recorded defect (above). 7 is above. Dependents on the old behaviour, by search of tests/, userland/, toyos/ and std's sys for the constants, the two error codes and the error kinds: only userland/netstack/src/listen/tests.rs (the OWNERLESS_LIFE and RESET_LIFE bench rows, which go with the move); logkeeper and sshserver match NotConnected on bind, which is the listener stage's; no guest test asserts a connect's error kind, a reset's timing or a close's tail.
  • CI's clippy. I expect no line of the diff to red on redundant_clone, unchecked_time_subtraction, manual_is_multiple_of, manual_isolate_lowest_one, drain_collect or wrong_self_convention: the three clones in tests/streams.rs are each used again or taken from a Ref, no Duration or std instant is subtracted, no remainder is compared with 0, drain_stream_events returns the drain uncollected, and no method is named to_, from_, into_, as_ or is_.
  • What the next stages build on. Listeners: the hook list (a pass after transmit), the pipe traits' refusal types if the read's changes, set_nodelay's option write, and the every-stream pass. Datagrams (toyos-net-node: clients' datagram sockets and the machine's mDNS name on ToyOS's own UDP, host-tested and shipped in nothing #772): lib.rs's hook lines move again if transmit gains one, and Stack's unprefixed TCP names.

SEND BACK

Japabu and others added 2 commits October 8, 2026 20:34
…lock runs on progress, and six rules get the test that fails without them

- `Node::transmit` ends in a pass over the connects: a connect whose next
  hop answers no ARP fails inside a transmit opportunity, and its answer
  waited for an unrelated frame or deadline.
- A pipe end's refusals are a type per trait: a read cannot answer `Gone`
  (a pipe with no writer gives what it holds and then the end), so the arm
  that reset a client for leaving is gone.
- The cut of a departed client's unsent bytes runs from the last byte its
  pipe gave up, for at most 16 such connections a peer address; one past
  them has 100 s from the leaving. Only a client whose reader is found
  gone is timed: one that shut its writing down after the peer's FIN and
  still holds its reading end is [tcp]'s to give up on. netstack on
  smoltcp cuts both at 100 s from the leaving.
- A stream holds the options it last wrote, so `set_nodelay` writes one
  field of them.
- `Stack`'s TCP forwards are named `tcp_*`.
- New tests: the connect nobody answers ARP for; a connect's deadline
  gone with its answer; no SYN after a connect timed out or was closed;
  CLOSE-WAIT and a reader that left, each for 150 s; a live client done
  writing, 150 s; text unread when both FINs ended the connection, in
  [tcp] and through a full pipe; the tail to a slow reader past 100 s;
  the seventeenth departed client's connection.
- The track: `Tcp::recv_with` among stage 4's departures; the stream
  bound's exit; what the node carries of the two recorded stream defects;
  the pass over every stream, the [tcp] change that would drive it by
  what moved, and the measurement owed at the move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
… with no opportunity after

The pass after `Node::receive` and the one after `Node::fire` each had one
mutation, aimed at a connect's handshake and a connect's deadline. Since
`Node::transmit` ends in a pass over the connects, the opportunity those
tests offer next answers a connect either way, and both mutations stayed
green. The two passes are not redundant: an opportunity's pass skips every
established stream, so text a frame brings and the cut a deadline brings
reach a stream only through their own. These two tests call `receive` and
`fire` and offer no opportunity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

The 48 mutations of stage D, at ddf479738

This replaces the table of the first comment: the patches there were cut against 96c0fd25a and no longer apply. Run by the orchestrator, each by the request's mutation: git apply --check, git apply, cargo test --no-run (exit 0 every time), then the one test by --exact name, then git apply -R and a clean-tree check. The t rows run cargo test --locked -p toyos-net-tcp --test take, the rest cargo test --locked --manifest-path userland/netstack/node/Cargo.toml --test streams. 46 ran at bd6273b5c (ownstack-d-r3.log); d22 and d23, green there on the tests they were then aimed at, ran at ddf479738 (ownstack-d-r4.log) on the tests named here. At ddf479738 every patch applies. d27 is t4's patch and d33 is d32's, each run against a second test.

mutation test that turned red exit head
t1-a-take-lets-go-of-all-it-was-shown what_the_reader_did_not_take_stays_held_in_order 101 bd6273b5c
t2-a-take-owes-no-window-update a_take_owes_the_window_update_a_read_does 101 bd6273b5c
t3-a-takers-count-is-not-clamped text_across_the_rings_wrap_is_shown_in_two_pieces_and_none_is_lost 101 bd6273b5c
t4-both-fins-end-the-reading text_unread_when_both_fins_ended_the_connection_is_still_taken 101 bd6273b5c
d27-both-fins-end-the-clients-reading text_the_pipe_had_no_room_for_outlives_both_fins 101 bd6273b5c
s1-the-shards-take-settles-nothing room_in_the_pipe_opens_the_window_at_the_next_opportunity 101 bd6273b5c
s2-the-shards-options-settle-nothing nodelay_reaches_the_stack 101 bd6273b5c
d01-the-stack-lets-go-of-what-the-pipe-refused a_full_pipe_keeps_the_rest_in_the_stack_and_is_watched_for_room 101 bd6273b5c
d02-the-pipe-is-read-past-the-stacks-room nothing_leaves_the_pipe_that_the_stack_will_not_take 101 bd6273b5c
d17-a-pipe-is-watched-for-bytes-the-stack-has-no-room-for nothing_leaves_the_pipe_that_the_stack_will_not_take 101 bd6273b5c
d18-a-pipe-is-watched-for-room-nobody-waits-on a_connect_is_a_handshake_and_its_answer_names_the_port 101 bd6273b5c
d22-a-frame-ends-in-no-pass a_frame_moves_an_established_stream_with_no_opportunity_after_it 101 ddf479738
d07-the-connects-deadline-is-not-kept a_connect_past_its_deadline_is_timed_out_at_the_deadline 101 bd6273b5c
d21-the-nodes-deadline-leaves-streams-out a_connect_past_its_deadline_is_timed_out_at_the_deadline 101 bd6273b5c
d23-a-deadline-ends-in-no-pass a_deadline_moves_an_established_stream_with_no_opportunity_after_it 101 ddf479738
d34-a-connect-not-answered-connected-is-not-aborted a_connect_past_its_deadline_is_timed_out_at_the_deadline 101 bd6273b5c
d08-a-refusal-is-not-answered a_refused_connect_is_answered_with_the_refusal 101 bd6273b5c
d26-a-closed-connect-is-not-answered a_connect_closed_before_its_answer_is_answered_closed 101 bd6273b5c
d35-a-closed-connect-is-not-aborted a_connect_closed_before_its_answer_is_answered_closed 101 bd6273b5c
d31-a-transmit-ends-in-no-pass a_connect_nobody_answers_arp_for_fails_when_tcp_knows 101 bd6273b5c
d32-a-connects-deadline-outlives-its-answer an_established_streams_connect_deadline_is_gone 101 bd6273b5c
d33-a-connects-deadline-stays-the-streams a_departed_clients_unsent_bytes_have_100_seconds 101 bd6273b5c
d03-the-end-of-writing-sends-no-fin the_end_of_the_clients_writing_is_a_fin_and_the_peer_still_sends 101 bd6273b5c
d04-the-peers-fin-is-not-read-through the_peers_fin_ends_the_clients_reading_and_not_its_writing 101 bd6273b5c
d05-a-failure-is-not-read-through a_reset_ends_both_pipes_and_the_stream 101 bd6273b5c
d06-a-connection-that-is-over-keeps-the-clients-pipe a_reset_ends_both_pipes_and_the_stream 101 bd6273b5c
d11-an-empty-pipe-is-not-the-end-of-a-writer-who-is-done a_close_sends_what_the_pipe_held_and_then_the_fin 101 bd6273b5c
d25-a-close-keeps-the-readers-end a_close_sends_what_the_pipe_held_and_then_the_fin 101 bd6273b5c
d12-a-shutdown-marks-nothing a_shutdown_sends_what_the_pipe_held_and_then_the_fin 101 bd6273b5c
d10-a-stream-with-no-pipe-is-kept a_client_that_left_is_finished_by_the_stack 101 bd6273b5c
d20-a-gone-readers-end-is-kept a_client_that_left_is_finished_by_the_stack 101 bd6273b5c
d13-a-gone-reader-is-waited-on a_reader_that_left_is_not_written_to_again 101 bd6273b5c
d09-a-departed-clients-bytes-have-101-seconds a_departed_clients_unsent_bytes_have_100_seconds 101 bd6273b5c
d19-a-gone-writer-is-not-marked a_departed_clients_unsent_bytes_have_100_seconds 101 bd6273b5c
d28-a-refused-write-marks-no-reader-gone a_departed_clients_unsent_bytes_have_100_seconds 101 bd6273b5c
d29-the-kernels-word-marks-no-reader-gone a_departed_clients_unsent_bytes_have_100_seconds 101 bd6273b5c
d30-a-close-marks-no-reader-gone a_departed_clients_unsent_bytes_have_100_seconds 101 bd6273b5c
d36-every-stream-nobody-reads-is-timed the_peers_fin_ends_the_clients_reading_and_not_its_writing 101 bd6273b5c
d37-a-writer-who-is-done-is-timed-with-its-reader-there a_live_client_that_is_done_writing_is_not_timed 101 bd6273b5c
d38-a-reader-that-left-is-timed-with-its-writer-there a_reader_that_left_is_not_written_to_again 101 bd6273b5c
d39-the-cuts-clock-is-not-restarted a_departed_clients_tail_arrives_whole_while_its_peer_takes_it 101 bd6273b5c
d40-a-peer-keeps-any-number-alive a_peer_keeps_sixteen_departed_clients_connections_alive_and_no_more 101 bd6273b5c
d41-the-clock-restarts-for-a-connection-past-the-bound a_peer_keeps_sixteen_departed_clients_connections_alive_and_no_more 101 bd6273b5c
d42-a-pass-forgets-what-a-peer-already-keeps a_peer_keeps_sixteen_departed_clients_connections_alive_and_no_more 101 bd6273b5c
d14-a-broken-write-end-is-waited-on a_pipe_that_is_no_pipe_resets_its_connection 101 bd6273b5c
d15-a-broken-read-end-is-waited-on a_pipe_that_is_no_pipe_resets_its_connection 101 bd6273b5c
d24-a-refused-watch-resets-whatever-is-held a_refused_watch_resets_only_a_stream_that_holds_the_end 101 bd6273b5c
d16-nodelay-stays-in-the-node nodelay_reaches_the_stack 101 bd6273b5c
The patches

d01-the-stack-lets-go-of-what-the-pipe-refused.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..6d7394b0c 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -183,7 +183,7 @@ impl Stream {
         while let Some(pipe) = self.to_client.as_mut() {
             let mut refused = None;
             let received = stack.tcp_recv(now, conn, |bytes| match pipe.write(bytes) {
-                Ok(taken) => taken,
+                Ok(_) => bytes.len(),
                 Err(refusal) => {
                     refused = Some(refusal);
                     0

d02-the-pipe-is-read-past-the-stacks-room.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..7f10fe5e1 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -215,7 +215,7 @@ impl Stream {
         while let Some(pipe) = self.from_client.as_mut() {
             // Never more than [tcp] has room for, and so never a read of no bytes, whose answer
             // would be the end's.
-            let room = stack.tcp_status(conn).writable.min(CHUNK);
+            let room = CHUNK;
             if room == 0 {
                 break;
             }

d03-the-end-of-writing-sends-no-fin.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..c43e93ec2 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -231,7 +231,6 @@ impl Stream {
                 }
             };
             if read == 0 {
-                stack.tcp_shutdown_write(now, conn);
                 self.from_client = None;
                 break;
             }

d04-the-peers-fin-is-not-read-through.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..9bcadfc43 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -205,7 +205,8 @@ impl Stream {
                 }
                 // The peer's FIN after its last byte, or the connection's failure: the client
                 // reads the end.
-                (Ok(Received::End) | Err(Error::Failed(_)), _) => self.to_client = None,
+                (Ok(Received::End), _) => break,
+                (Err(Error::Failed(_)), _) => self.to_client = None,
                 (Err(Error::WouldBlock), _) => break,
                 (Err(refusal), _) => unreachable!("[tcp] refused the holder of a connection a read: {refusal:?}"),
             }

d05-a-failure-is-not-read-through.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..a61a4ab25 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -205,7 +205,8 @@ impl Stream {
                 }
                 // The peer's FIN after its last byte, or the connection's failure: the client
                 // reads the end.
-                (Ok(Received::End) | Err(Error::Failed(_)), _) => self.to_client = None,
+                (Ok(Received::End), _) => self.to_client = None,
+                (Err(Error::Failed(_)), _) => break,
                 (Err(Error::WouldBlock), _) => break,
                 (Err(refusal), _) => unreachable!("[tcp] refused the holder of a connection a read: {refusal:?}"),
             }

d06-a-connection-that-is-over-keeps-the-clients-pipe.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..7e0606021 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -243,9 +243,6 @@ impl Stream {
         let status = stack.tcp_status(conn);
         // A connection that is over takes no more of the client's bytes: its writes fail rather
         // than fill a pipe nobody drains.
-        if matches!(status.state, State::Closed | State::TimeWait) {
-            self.from_client = None;
-        }
         self.room = status.writable > 0;
         if self.to_client.is_none() && self.from_client.is_none() {
             stack.tcp_close(now, conn);

d07-the-connects-deadline-is-not-kept.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..e701b82b9 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -166,7 +166,6 @@ impl Stream {
             let status = stack.tcp_status(conn);
             let event = match (status.state, status.failure) {
                 (_, Some(failure)) => StreamEvent::Failed { id, failure },
-                (State::SynSent | State::SynReceived, None) if self.deadline.is_some_and(|at| at <= now) => StreamEvent::TimedOut { id },
                 (State::SynSent | State::SynReceived, None) => return true,
                 (_, None) => StreamEvent::Connected { id, local: stack.tcp_local_port(conn) },
             };

d08-a-refusal-is-not-answered.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..c262ff9f4 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -165,7 +165,7 @@ impl Stream {
         if self.connecting {
             let status = stack.tcp_status(conn);
             let event = match (status.state, status.failure) {
-                (_, Some(failure)) => StreamEvent::Failed { id, failure },
+                (_, Some(_)) => return true,
                 (State::SynSent | State::SynReceived, None) if self.deadline.is_some_and(|at| at <= now) => StreamEvent::TimedOut { id },
                 (State::SynSent | State::SynReceived, None) => return true,
                 (_, None) => StreamEvent::Connected { id, local: stack.tcp_local_port(conn) },

d09-a-departed-clients-bytes-have-101-seconds.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..f5c623918 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -44,7 +44,7 @@ use crate::Node;
 /// How long a departed client's pipe may give up no byte: R2, the time RFC 9293 §3.8.3 gives a
 /// segment's retransmission before the connection is closed, at the 100 seconds it asks for at
 /// least.
-const OWNERLESS_LIFE: Duration = Duration::from_secs(100);
+const OWNERLESS_LIFE: Duration = Duration::from_secs(101);
 
 /// How many departed clients' connections one peer address keeps alive by taking their bytes.
 /// An estimate: no measurement sets it.

d10-a-stream-with-no-pipe-is-kept.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..4e6a548c5 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -247,10 +247,6 @@ impl Stream {
             self.from_client = None;
         }
         self.room = status.writable > 0;
-        if self.to_client.is_none() && self.from_client.is_none() {
-            stack.tcp_close(now, conn);
-            return false;
-        }
         if self.reader_left && self.done_writing {
             let left_now = self.deadline.is_none();
             if left_now {

d11-an-empty-pipe-is-not-the-end-of-a-writer-who-is-done.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..0f711cd67 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -223,7 +223,6 @@ impl Stream {
             let Some(space) = chunk.get_mut(..room) else { unreachable!("room is at most a chunk") };
             let read = match pipe.read(space) {
                 Ok(read) => read,
-                Err(ReadRefusal::Empty) if self.done_writing => 0,
                 Err(ReadRefusal::Empty) => break,
                 Err(ReadRefusal::Broken) => {
                     stack.tcp_abort(now, conn);

d12-a-shutdown-marks-nothing.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..bca947b65 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -348,7 +348,6 @@ impl Node {
     /// id that names no established stream.
     pub fn shutdown_write(&mut self, now: Instant, id: StreamId) -> bool {
         let Some(stream) = self.streams.live.get_mut(&id).filter(|stream| !stream.connecting) else { return false };
-        stream.done_writing = true;
         self.bridge(now);
         true
     }

d13-a-gone-reader-is-waited-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..1beef45a3 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -195,10 +195,7 @@ impl Stream {
                     self.held = true;
                     break;
                 }
-                (Ok(Received::Data(_)), Some(WriteRefusal::Gone)) => {
-                    self.reader_left = true;
-                    self.to_client = None;
-                }
+                (Ok(Received::Data(_)), Some(WriteRefusal::Gone)) => break,
                 (Ok(Received::Data(_)), Some(WriteRefusal::Broken)) => {
                     stack.tcp_abort(now, conn);
                     return false;

d14-a-broken-write-end-is-waited-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..0281d1577 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -199,10 +199,7 @@ impl Stream {
                     self.reader_left = true;
                     self.to_client = None;
                 }
-                (Ok(Received::Data(_)), Some(WriteRefusal::Broken)) => {
-                    stack.tcp_abort(now, conn);
-                    return false;
-                }
+                (Ok(Received::Data(_)), Some(WriteRefusal::Broken)) => break,
                 // The peer's FIN after its last byte, or the connection's failure: the client
                 // reads the end.
                 (Ok(Received::End) | Err(Error::Failed(_)), _) => self.to_client = None,

d15-a-broken-read-end-is-waited-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..38f9fc85a 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -225,10 +225,7 @@ impl Stream {
                 Ok(read) => read,
                 Err(ReadRefusal::Empty) if self.done_writing => 0,
                 Err(ReadRefusal::Empty) => break,
-                Err(ReadRefusal::Broken) => {
-                    stack.tcp_abort(now, conn);
-                    return false;
-                }
+                Err(ReadRefusal::Broken) => break,
             };
             if read == 0 {
                 stack.tcp_shutdown_write(now, conn);

d16-nodelay-stays-in-the-node.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..d5eb31ac0 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -357,7 +357,6 @@ impl Node {
     pub fn set_nodelay(&mut self, now: Instant, id: StreamId, nodelay: bool) -> bool {
         let Some(stream) = self.streams.live.get_mut(&id) else { return false };
         stream.options.nodelay = nodelay;
-        self.stack.tcp_set_options(now, stream.conn, stream.options);
         true
     }
 

d17-a-pipe-is-watched-for-bytes-the-stack-has-no-room-for.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..3f18eddde 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -397,7 +397,7 @@ impl Node {
     pub fn watches(&self) -> impl Iterator<Item = (StreamId, Watch)> + '_ {
         self.streams.live.iter().filter(|(_, stream)| !stream.connecting).map(|(id, stream)| {
             let (from, to) = (stream.from_client.is_some(), stream.to_client.is_some());
-            (*id, Watch { readable: from && stream.room, writer: from && !stream.done_writing, writable: to && stream.held, reader: to })
+            (*id, Watch { readable: from, writer: from && !stream.done_writing, writable: to && stream.held, reader: to })
         })
     }
 

d18-a-pipe-is-watched-for-room-nobody-waits-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..051fd5987 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -397,7 +397,7 @@ impl Node {
     pub fn watches(&self) -> impl Iterator<Item = (StreamId, Watch)> + '_ {
         self.streams.live.iter().filter(|(_, stream)| !stream.connecting).map(|(id, stream)| {
             let (from, to) = (stream.from_client.is_some(), stream.to_client.is_some());
-            (*id, Watch { readable: from && stream.room, writer: from && !stream.done_writing, writable: to && stream.held, reader: to })
+            (*id, Watch { readable: from && stream.room, writer: from && !stream.done_writing, writable: to, reader: to })
         })
     }
 

d19-a-gone-writer-is-not-marked.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..b0f9b2b9e 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -374,7 +374,7 @@ impl Node {
                 stream.reader_left = true;
                 stream.to_client = None;
             }
-            PipeEnd::FromClient => stream.done_writing = true,
+            PipeEnd::FromClient => {}
         }
         self.bridge(now);
     }

d20-a-gone-readers-end-is-kept.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..fedc6127c 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -370,10 +370,7 @@ impl Node {
     pub fn pipe_gone(&mut self, now: Instant, id: StreamId, end: PipeEnd) {
         let Some(stream) = self.streams.live.get_mut(&id).filter(|stream| !stream.connecting) else { return };
         match end {
-            PipeEnd::ToClient => {
-                stream.reader_left = true;
-                stream.to_client = None;
-            }
+            PipeEnd::ToClient => stream.reader_left = true,
             PipeEnd::FromClient => stream.done_writing = true,
         }
         self.bridge(now);

d21-the-nodes-deadline-leaves-streams-out.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index f7da5a43e..3769e1719 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -138,7 +138,7 @@ impl Node {
     // ---- the clock ----
 
     pub fn next_deadline(&self) -> Option<Instant> {
-        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(self.streams.next_deadline()).min()
+        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).min()
     }
 
     /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`].

d22-a-frame-ends-in-no-pass.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index f7da5a43e..c51c522f5 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -113,7 +113,6 @@ impl Node {
     pub fn receive(&mut self, now: Instant, frame: &[u8], mut draw: impl FnMut() -> u32) {
         self.stack.receive(now, frame);
         self.settle(now, &mut draw);
-        self.bridge(now);
     }
 
     /// A transmit opportunity with room for `credit` frames, each handed to `sink` as it is built.

d23-a-deadline-ends-in-no-pass.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index f7da5a43e..d439d0acc 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -148,7 +148,6 @@ impl Node {
         let out = self.client.timer(now, &mut draw);
         self.carry_out(now, out, None, &mut draw);
         self.settle(now, &mut draw);
-        self.bridge(now);
     }
 
     /// Hands the client what the shard reported and what reached its socket, and carries out

d24-a-refused-watch-resets-whatever-is-held.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..bd76da415 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -387,7 +387,7 @@ impl Node {
             PipeEnd::ToClient => stream.to_client.is_some(),
             PipeEnd::FromClient => stream.from_client.is_some(),
         };
-        if held {
+        if held || true {
             self.stack.tcp_abort(now, stream.conn);
             self.streams.live.remove(&id);
         }

d25-a-close-keeps-the-readers-end.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..b82a43a52 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -338,7 +338,6 @@ impl Node {
             self.streams.events.push_back(StreamEvent::Closed { id });
             return;
         }
-        stream.to_client = None;
         stream.reader_left = true;
         stream.done_writing = true;
         self.bridge(now);

d26-a-closed-connect-is-not-answered.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..1788ede71 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -335,7 +335,6 @@ impl Node {
         if stream.connecting {
             self.stack.tcp_abort(now, stream.conn);
             self.streams.live.remove(&id);
-            self.streams.events.push_back(StreamEvent::Closed { id });
             return;
         }
         stream.to_client = None;

d27-both-fins-end-the-clients-reading.patch

diff --git a/toyos-net-shard/tcp/src/stack.rs b/toyos-net-shard/tcp/src/stack.rs
index 3bdcbb9af..cde7da433 100644
--- a/toyos-net-shard/tcp/src/stack.rs
+++ b/toyos-net-shard/tcp/src/stack.rs
@@ -1008,10 +1008,7 @@ impl Tcp {
             Tcb::SynSent(_) | Tcb::SynRcvd(_) => Err(Error::WouldBlock),
             Tcb::Sync(sync) => sync.recv_with(take),
             Tcb::Ended(Ended { failure: Some(failure), .. }) => Err(Error::Failed(*failure)),
-            Tcb::Ended(Ended { failure: None, rx }) => match rx.as_mut() {
-                Some(rx) if rx.unread() > 0 => Ok(Received::Data(rx.read_with(take))),
-                _ => Ok(Received::End),
-            },
+            Tcb::Ended(Ended { failure: None, .. }) => Ok(Received::End),
         };
         self.settle(id.index, now);
         result

d28-a-refused-write-marks-no-reader-gone.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..f0cc67523 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -195,10 +195,7 @@ impl Stream {
                     self.held = true;
                     break;
                 }
-                (Ok(Received::Data(_)), Some(WriteRefusal::Gone)) => {
-                    self.reader_left = true;
-                    self.to_client = None;
-                }
+                (Ok(Received::Data(_)), Some(WriteRefusal::Gone)) => self.to_client = None,
                 (Ok(Received::Data(_)), Some(WriteRefusal::Broken)) => {
                     stack.tcp_abort(now, conn);
                     return false;

d29-the-kernels-word-marks-no-reader-gone.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..f316dd165 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -370,10 +370,7 @@ impl Node {
     pub fn pipe_gone(&mut self, now: Instant, id: StreamId, end: PipeEnd) {
         let Some(stream) = self.streams.live.get_mut(&id).filter(|stream| !stream.connecting) else { return };
         match end {
-            PipeEnd::ToClient => {
-                stream.reader_left = true;
-                stream.to_client = None;
-            }
+            PipeEnd::ToClient => stream.to_client = None,
             PipeEnd::FromClient => stream.done_writing = true,
         }
         self.bridge(now);

d30-a-close-marks-no-reader-gone.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..a9dbe950c 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -339,7 +339,6 @@ impl Node {
             return;
         }
         stream.to_client = None;
-        stream.reader_left = true;
         stream.done_writing = true;
         self.bridge(now);
     }

d31-a-transmit-ends-in-no-pass.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index f7da5a43e..35348ae7a 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -120,7 +120,6 @@ impl Node {
     /// Returns how many left.
     pub fn transmit(&mut self, now: Instant, credit: usize, sink: impl FnMut(&[u8])) -> usize {
         let sent = self.stack.transmit(now, credit, sink);
-        self.pass(now, true);
         sent
     }
 

d32-a-connects-deadline-outlives-its-answer.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..7911364b2 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -176,7 +176,6 @@ impl Stream {
                 return false;
             }
             self.connecting = false;
-            self.deadline = None;
         }
 
         self.held = false;

d33-a-connects-deadline-stays-the-streams.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..7911364b2 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -176,7 +176,6 @@ impl Stream {
                 return false;
             }
             self.connecting = false;
-            self.deadline = None;
         }
 
         self.held = false;

d34-a-connect-not-answered-connected-is-not-aborted.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..26b389534 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -172,7 +172,6 @@ impl Stream {
             };
             events.push_back(event);
             if !matches!(event, StreamEvent::Connected { .. }) {
-                stack.tcp_abort(now, conn);
                 return false;
             }
             self.connecting = false;

d35-a-closed-connect-is-not-aborted.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..121d41668 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -333,7 +333,6 @@ impl Node {
     pub fn close(&mut self, now: Instant, id: StreamId) {
         let Some(stream) = self.streams.live.get_mut(&id) else { return };
         if stream.connecting {
-            self.stack.tcp_abort(now, stream.conn);
             self.streams.live.remove(&id);
             self.streams.events.push_back(StreamEvent::Closed { id });
             return;

d36-every-stream-nobody-reads-is-timed.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..f5dc420be 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -251,7 +251,7 @@ impl Stream {
             stack.tcp_close(now, conn);
             return false;
         }
-        if self.reader_left && self.done_writing {
+        if self.to_client.is_none() {
             let left_now = self.deadline.is_none();
             if left_now {
                 let peers = extended.entry(self.remote).or_insert(0);

d37-a-writer-who-is-done-is-timed-with-its-reader-there.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..2fe29d59c 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -251,7 +251,7 @@ impl Stream {
             stack.tcp_close(now, conn);
             return false;
         }
-        if self.reader_left && self.done_writing {
+        if self.done_writing {
             let left_now = self.deadline.is_none();
             if left_now {
                 let peers = extended.entry(self.remote).or_insert(0);

d38-a-reader-that-left-is-timed-with-its-writer-there.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..5ff26f154 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -251,7 +251,7 @@ impl Stream {
             stack.tcp_close(now, conn);
             return false;
         }
-        if self.reader_left && self.done_writing {
+        if self.reader_left {
             let left_now = self.deadline.is_none();
             if left_now {
                 let peers = extended.entry(self.remote).or_insert(0);

d39-the-cuts-clock-is-not-restarted.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..e5123d4c4 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -260,7 +260,7 @@ impl Stream {
                     self.extended = true;
                 }
             }
-            if left_now || (self.extended && gave_up) {
+            if left_now {
                 self.deadline = Some(now.after(OWNERLESS_LIFE));
             } else if self.deadline.is_some_and(|at| at <= now) {
                 stack.tcp_abort(now, conn);

d40-a-peer-keeps-any-number-alive.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..4aa521473 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -255,7 +255,7 @@ impl Stream {
             let left_now = self.deadline.is_none();
             if left_now {
                 let peers = extended.entry(self.remote).or_insert(0);
-                if *peers < OWNERLESS_PER_PEER {
+                if *peers < usize::MAX {
                     *peers = peers.saturating_add(1);
                     self.extended = true;
                 }

d41-the-clock-restarts-for-a-connection-past-the-bound.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..6d8732e46 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -260,7 +260,7 @@ impl Stream {
                     self.extended = true;
                 }
             }
-            if left_now || (self.extended && gave_up) {
+            if left_now || gave_up {
                 self.deadline = Some(now.after(OWNERLESS_LIFE));
             } else if self.deadline.is_some_and(|at| at <= now) {
                 stack.tcp_abort(now, conn);

d42-a-pass-forgets-what-a-peer-already-keeps.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 8c9d90e2f..8fc65d785 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -321,10 +321,6 @@ impl Node {
         let Streams { live, events, .. } = &mut self.streams;
         let stack = &mut self.stack;
         let mut extended = BTreeMap::new();
-        for stream in live.values().filter(|stream| stream.extended) {
-            let peers = extended.entry(stream.remote).or_insert(0usize);
-            *peers = peers.saturating_add(1);
-        }
         live.retain(|id, stream| (connects && !stream.connecting) || stream.pass(*id, now, stack, events, &mut extended));
     }
 

s1-the-shards-take-settles-nothing.patch

diff --git a/toyos-net-shard/src/lib.rs b/toyos-net-shard/src/lib.rs
index 5d2f804c1..7a56ace07 100644
--- a/toyos-net-shard/src/lib.rs
+++ b/toyos-net-shard/src/lib.rs
@@ -481,7 +481,6 @@ impl Shard {
     /// [`Tcp::recv_with`]: `take` is shown the oldest bytes held and answers how many it took.
     pub fn recv_with(&mut self, now: Instant, id: ConnId, take: impl FnOnce(&[u8]) -> usize) -> Result<Received, toyos_net_tcp::Error> {
         let done = self.tcp.recv_with(now, id, take);
-        self.settle(now);
         done
     }
 

s2-the-shards-options-settle-nothing.patch

diff --git a/toyos-net-shard/src/lib.rs b/toyos-net-shard/src/lib.rs
index 5d2f804c1..e0cfe0c97 100644
--- a/toyos-net-shard/src/lib.rs
+++ b/toyos-net-shard/src/lib.rs
@@ -487,7 +487,6 @@ impl Shard {
 
     pub fn set_options(&mut self, now: Instant, id: ConnId, options: toyos_net_tcp::Options) -> Result<(), toyos_net_tcp::Error> {
         let done = self.tcp.set_options(now, id, options);
-        self.settle(now);
         done
     }
 

t1-a-take-lets-go-of-all-it-was-shown.patch

diff --git a/toyos-net-shard/tcp/src/rx.rs b/toyos-net-shard/tcp/src/rx.rs
index 8a5900d80..ec891d6e8 100644
--- a/toyos-net-shard/tcp/src/rx.rs
+++ b/toyos-net-shard/tcp/src/rx.rs
@@ -343,7 +343,8 @@ impl Rx {
     pub fn read_with(&mut self, take: impl FnOnce(&[u8]) -> usize) -> usize {
         let (held, _) = self.buf.slices(0, self.buf.len());
         let n = take(held).min(held.len());
-        self.buf.consume(n);
+        let shown = held.len();
+        self.buf.consume(shown);
         n
     }
 

t2-a-take-owes-no-window-update.patch

diff --git a/toyos-net-shard/tcp/src/conn.rs b/toyos-net-shard/tcp/src/conn.rs
index b0086dfff..65b0225c1 100644
--- a/toyos-net-shard/tcp/src/conn.rs
+++ b/toyos-net-shard/tcp/src/conn.rs
@@ -871,9 +871,6 @@ impl Sync {
             return if self.rx.closed { Ok(Received::End) } else { Err(Error::WouldBlock) };
         }
         let n = self.rx.read_with(take);
-        if n > 0 {
-            self.rx.after_read(self.smss());
-        }
         Ok(Received::Data(n))
     }
 

t3-a-takers-count-is-not-clamped.patch

diff --git a/toyos-net-shard/tcp/src/rx.rs b/toyos-net-shard/tcp/src/rx.rs
index 8a5900d80..047ddabec 100644
--- a/toyos-net-shard/tcp/src/rx.rs
+++ b/toyos-net-shard/tcp/src/rx.rs
@@ -342,7 +342,7 @@ impl Rx {
     /// many of them as it answers it took.
     pub fn read_with(&mut self, take: impl FnOnce(&[u8]) -> usize) -> usize {
         let (held, _) = self.buf.slices(0, self.buf.len());
-        let n = take(held).min(held.len());
+        let n = take(held);
         self.buf.consume(n);
         n
     }

t4-both-fins-end-the-reading.patch

diff --git a/toyos-net-shard/tcp/src/stack.rs b/toyos-net-shard/tcp/src/stack.rs
index 3bdcbb9af..cde7da433 100644
--- a/toyos-net-shard/tcp/src/stack.rs
+++ b/toyos-net-shard/tcp/src/stack.rs
@@ -1008,10 +1008,7 @@ impl Tcp {
             Tcb::SynSent(_) | Tcb::SynRcvd(_) => Err(Error::WouldBlock),
             Tcb::Sync(sync) => sync.recv_with(take),
             Tcb::Ended(Ended { failure: Some(failure), .. }) => Err(Error::Failed(*failure)),
-            Tcb::Ended(Ended { failure: None, rx }) => match rx.as_mut() {
-                Some(rx) if rx.unread() > 0 => Ok(Received::Data(rx.read_with(take))),
-                _ => Ok(Received::End),
-            },
+            Tcb::Ended(Ended { failure: None, .. }) => Ok(Received::End),
         };
         self.settle(id.index, now);
         result

@Japabu

Japabu commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review of wt/toyos-ownstack-d at ddf479738, round 2: git diff 8a2302e90 ddf479738 (6 files, +423, -100), against round 1 above. Read, not run.

Net lines of the stage over wt/toyos-ownstack-a at 9a99ee0ba: 12 files, +1,748, -5. Production 547 (streams.rs 413, lease/tcp.rs 64, lib.rs and lease.rs 14 net, toyos-net-tcp 40, the shard 13), tests 1,195 (tests/streams.rs 1,065, take.rs 130). This round: production +61 net, tests +256 net. Accepted as the stage's, less what the BLOCKER below adds.

Round 1's BLOCKERs

  1. Node::transmit ends in no pass — CLOSED. lib.rs:123 is self.pass(now, true). d31 is that line deleted; a_connect_nobody_answers_arp_for_fails_when_tcp_knows exits 101 at bd6273b5c on "the stream goes in the opportunity that failed its connect", the per-step assertion that ties streams() == 0 to tcp.next-hop-failed. timeout: None, driven by next_deadline, each deadline asserted before the renewal.
  2. self.deadline = None — CLOSED. d32 and d33 are my patch. d32 reds an_established_streams_connect_deadline_is_gone on the deadline 1 s in the past; d33 reds a_departed_clients_unsent_bytes_have_100_seconds (now connected with a 30 s timeout) on "from the moment nobody was left", 75 s early.
  3. Neither tcp_abort of an unanswered connect — CLOSED. d34 and d35 are my two patches; each reds its test on the count of SYNs 60 s on (6 against 2, 6 against 1).
  4. The cut's condition widened — CLOSED. d36 is my patch on the new condition; the_peers_fin_ends_the_clients_reading_and_not_its_writing exits 101 on the assertion after 150 s in CLOSE-WAIT, (0, [Cut]) against (1, []). d37 and d38 hold the two halves on their own tests.
  5. The read's Gone arm — CLOSED by type. ReadRefusal { Empty, Broken } has no Gone; the arm is gone and the fake cannot answer it.
  6. recv_with after both FINs — CLOSED. t4 and d27 are my patch. t4 reds take.rs's new test on its first take (Ok(End) against Ok(Data(40))); d27 reds text_the_pipe_had_no_room_for_outlives_both_fins on the stream let go with the text unread.
  7. Evidence — OPEN. cargo run -- --ci host has run at no head of this branch; the four gates ran at bd6273b5c (45 tests) and not at ddf479738; the pull request is a draft and its three checks read skipping. See "What closes the evidence" below.

d22 and d23: each is one self.bridge(now) deleted, from receive and from fire. The two new tests call Node::receive and Node::fire directly and nothing after but drain_stream_events and a read of the fake, so neither can pass by an opportunity's pass: both exit 101 at ddf479738, on the inbox empty and on (1, []) against (0, [Cut]). Under d22 established() itself still answers, through the opportunity's pass over the connects, so the red is the assertion and not the fixture.

BLOCKER

  • userland/netstack/node/src/streams.rs:208 with :254 and :400 — a client that dies after the peer's FIN is never timed, and no bound counts it — the pass lets to_client go at the peer's FIN without reader_left, Watch::reader is to_client.is_some(), so from then on nothing can tell the node the reader left: reader_left stays false for the stream's life and the cut never arms. The client writes a tail larger than [tcp]'s room and exits; pipe_gone(FromClient) sets done_writing; the peer holds its window shut and answers the probes. toyos-net-shard/tcp/src/conn.rs:640-645 restarts progress_since at every answered probe, so [tcp] never gives up either. The node then holds the stream, both 64 KiB buffers and the client's pipe for as long as the peer likes, for any number of connections per address: outside OWNERLESS_PER_PEER altogether. The round-1 head cut this at 100 s, and so does netstack on main; the fix of "a live client is cut" traded it for "a dead one is kept for ever", which is the hole the issue itself names ("a peer that acknowledges a byte at a time would hold a slot of a client that is gone for as long as it liked"). The module header's "a client that still holds its reading end is never timed" is not what the code decides: it decides on whether the node saw the reader leave, and after a FIN it has made itself unable to. Once the peer's FIN is read through and the client writes no more, nobody on the client's side can observe the connection again, alive or not, so that stream can be timed on progress under the same bound without cutting anything a client could see; or the leaving is learnt some other way. Wanted: a test whose peer sends its FIN, the client writes more than SEND_BUFFER, its writer leaves, the peer offers no window and answers every probe, and the clock runs past 100 s without a byte given up: Cut, streams() == 0, one reset. Today it stays streams() == 1 with no deadline of the stream's own.
  • Evidence — as round 1, item 7 above.

NOTE

  • streams.rs:255-262 — a connection past the sixteen at its client's leaving is never one of them afterwards — extended is decided once, in the pass that finds the client gone. Sixteen tails that finish in a second leave the seventeenth with its absolute 100 s although it is then the only one the peer holds, and a client that opened twenty uploads to one address and exited loses four of them on a slow link: the recorded defect, kept for those. The count is rebuilt every pass already; deciding extended while it is false, each pass, is less code than left_now guarding it. The existing test cannot tell the two apart: its seventeen leave together and the sixteen are cut in the pass that cuts the seventeenth.
  • issues/toyos-has-its-own-network-stack.md, the departed-client line — "meet the exit ... on a host only" is broader than the node: the seventeenth above and the BLOCKER's stream do not meet it. And the line records the 16 as an estimate but not what it bounds. From the code: per address, 16 connections; each kept for as long as its pipe gives up one byte per 100 s, which for the 2 MiB pipe the issue names is 2,097,152 x 100 s, over six years, then the tail in [tcp] at one acknowledgment per 60 s (the track's next line but one); addresses are not counted, so the total is 16 times however many addresses hold a departed client's stream, bounded only by the stream bound that does not exist. For a connect the client chose the address; for an accepted stream (toyos-net-node: listeners on the own stack's accept queue, and one bound on the streams, listeners and datagram sockets clients make the node hold (stage E) #777) the peer does, and on the link it can answer for as many as it likes. The line should say: no floor on the rate, no bound across addresses, and an exit a test can fail (peers at more addresses than the bound allows, each holding 16, and the node refuses or cuts past it) in place of "the stream bound sets the 16 or replaces it".
  • issues/toyos-has-its-own-network-stack.md, the reset-stream line, and the body's second "blocked" item — not true of the tree as written. sdk/std/sys/net/connection.rs:330-332 at this head already answers nodelay() with Ok(self.nodelay.load(Relaxed)) and asks netstack nothing; the only caller of toyos::net::tcp_get_option is userland/libc/src/socket.rs:587. So the exit's first alternative is met before the work starts and fails nothing. What the node carries is set_nodelay on a reset stream (false, where macOS refuses and Linux answers) and libc's getsockopt. That the node cannot keep the stream for the client is true from the code. The line names those two and the issue's guest test.
  • issues/toyos-has-its-own-network-stack.md, the every-stream line — true from the code: toyos-net-tcp hands out drain_events, drain_eligible and drain_gone and nothing that names a connection a segment, a timer or an ICMP error moved. Two gaps: it omits that every transmit opportunity now walks every stream too (lib.rs:123, streams.rs:324-328: one walk for the count, one that skips), and "the measurement shows it is not owed" has no number, so no measurement can fail it. Name the per-frame cost at 1,000 idle streams above which the change lands.
  • userland/netstack/node/tests/streams.rs, a_peer_keeps_sixteen_departed_clients_connections_alive_and_no_more — its sixteen are deaf and are cut at 100 s like the seventeenth, so nothing shows a second connection of one peer kept alive at once; that a peer restarts the clock is shown for one connection only (a_departed_clients_tail_arrives_whole_while_its_peer_takes_it). Every peer in the file is one address.
  • The body, behaviour 3 — "A live client is never timed by the node" is broader than the code, which also never times a dead client whose reader it did not see leave (the BLOCKER).

Asked in the brief

  • Is the rebuilt cut the issue's exit. Its second clause, yes, as a host test: one segment a second, 250,000 bytes and the FIN past 100 s, and d39 reds it at 146,000 bytes. Its first clause, for the first sixteen per address at their leaving; not for the seventeenth once the sixteen are done, and not for the stream of the BLOCKER, which is kept without any bound. The live client of the issue's second paragraph is no longer cut (d37).
  • The option set. set_nodelay writes the stream's own Options with one field changed; closed. A listener's child must be seeded with what it inherited, not Options::default(), or the first set_nodelay erases it again.
  • CI's clippy. Read for redundant_clone, unchecked_time_subtraction, manual_is_multiple_of, manual_isolate_lowest_one, drain_collect and wrong_self_convention in what this round adds: no new clone; every instant moves by Instant::after, max or min, none is subtracted; no remainder; events() collects an impl Iterator, not a Vec's drain; no new method named to_, from_, into_, as_ or is_. I expect none to red. transmit's let sent = ...; self.pass(...); sent has a statement between, so let_and_return does not apply.
  • What moves under toyos-net-node: listeners on the own stack's accept queue, and one bound on the streams, listeners and datagram sockets clients make the node hold (stage E) #777 and toyos-net-node: clients' datagram sockets and the machine's mDNS name on ToyOS's own UDP, host-tested and shipped in nothing #772 beyond the listed signatures. For listeners: an opportunity's pass visits only streams with connecting set, so an accepted stream is passed by a frame, a deadline or bridge and never by transmit; Stream::remote must be the accepted peer's address, since the per-address count reads it, and that count is no bound against a peer that picks its address; close and both ways a reader is found gone now set reader_left, and a stream built any other way is never timed; Stream::options is the whole option set; Node::pass builds the count from every live stream on every call. In the tests' fake: Far takes the node's port from a SYN only and clears received and fin at each, answers nothing from another port, and has absent, offers and Manner::Slow. For datagrams: transmit's body in lib.rs moved again, and Stack's forwards for TCP are tcp_*, so theirs take a prefix of their own. All of it moves once more with the BLOCKER's fix.

What closes the evidence

After the BLOCKER's fix is reviewed, #771 has landed, and this branch is rebased on main and marked ready: the host job's log of the pull request's head, concluded success, read whole and not by a result line, showing cargo run -- --ci host exit 0 and in it: toyos-net-node's streams target running N tests with N at least 32 (31 today, and the BLOCKER's test), lease running 18 tests, slirp running 3 tests; toyos-net-tcp's take running 5 tests; every toyos-net-shard target with a count above 0; the hostws::, userlandhost::, sourcegate:: and licence:: tests run and ok; clippy on the three crates under CI's toolchain with no warning. guest / suite concludes success and is not skipped; no guest test reaches the change, and none is asked for. On that log at the head that enters the queue the orchestrator may close item 7 without another round. He may not land on it while the first BLOCKER is open: that one needs its diff and its red and green reviewed.

SEND BACK

… whichever way its reading ended

The cut armed on `reader_left && done_writing`, and the pass lets the
to-client end go at the peer's FIN without the reader having left, after
which nothing can tell the node that it did. A client that wrote a tail
larger than [tcp]'s room after the peer's FIN and died was so never timed:
a peer that held its window shut and answered the probes kept the stream,
both buffers and the pipe for as long as it liked, for any number of
connections, outside the sixteen altogether.

The condition is now what the node can know: the to-client end is let go,
whichever way, and the client writes no more. Nothing of such a stream
reaches its client again, alive or not, so it is timed on progress under
the same count. `reader_left` said nothing the pipe end's absence does not
and is gone.

`extended` is decided in every pass while it is false, not once at the
leaving: a stream past the sixteen becomes one of them in the first pass
that begins with fewer, where before it kept its absolute 100 s although
it was the only one its peer still held.

The tests' peer is one of several: `Far` has an address and a MAC, a test
turns from one to the next (`Net::turn_to`) and each keeps its connection,
so sixteen of one address are shown kept alive at once, the seventeenth
taking a place one of them frees, and a second address keeping its own.

The track says what the sixteen bound and what they do not: an address,
not the node and not the rate; the bound across addresses is the node's
places, one stage on. The reset-stream line names what the node carries,
and the every-stream line the walk a transmit opportunity makes and the
number the measurement is held to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Mutation patches of round 2, against e0b917b04: the 13 regenerated because their lines moved, and d43 to d46, new. d28, d29 and d30 are dropped with Stream::reader_left, the field they forgot to set. The other 32 of the earlier comments apply unchanged. Each is run by build-request-5.sh against the one test named there.

d09-a-departed-clients-bytes-have-101-seconds.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..c8375bf62 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -50,7 +50,7 @@ use crate::Node;
 /// How long the pipe of a stream its client can see no more may give up no byte: R2, the time
 /// RFC 9293 §3.8.3 gives a segment's retransmission before the connection is closed, at the 100
 /// seconds it asks for at least.
-const OWNERLESS_LIFE: Duration = Duration::from_secs(100);
+const OWNERLESS_LIFE: Duration = Duration::from_secs(101);
 
 /// How many such streams one peer address keeps alive at once by taking their bytes. An
 /// estimate: no measurement sets it. It bounds an address, not the node, and not how slowly a

d10-a-stream-with-no-pipe-is-kept.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..faad1255c 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -250,10 +250,6 @@ impl Stream {
             self.from_client = None;
         }
         self.room = status.writable > 0;
-        if self.to_client.is_none() && self.from_client.is_none() {
-            stack.tcp_close(now, conn);
-            return false;
-        }
         // Nothing of the stream reaches its client again, alive or not.
         if self.to_client.is_none() && self.done_writing {
             if !self.extended {

d13-a-gone-reader-is-waited-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..6bfa34eba 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -201,7 +201,7 @@ impl Stream {
                     break;
                 }
                 // Nobody reads what the peer sends.
-                (Ok(Received::Data(_)), Some(WriteRefusal::Gone)) => self.to_client = None,
+                (Ok(Received::Data(_)), Some(WriteRefusal::Gone)) => break,
                 (Ok(Received::Data(_)), Some(WriteRefusal::Broken)) => {
                     stack.tcp_abort(now, conn);
                     return false;

d14-a-broken-write-end-is-waited-on.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..ff54bf05a 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -202,10 +202,7 @@ impl Stream {
                 }
                 // Nobody reads what the peer sends.
                 (Ok(Received::Data(_)), Some(WriteRefusal::Gone)) => self.to_client = None,
-                (Ok(Received::Data(_)), Some(WriteRefusal::Broken)) => {
-                    stack.tcp_abort(now, conn);
-                    return false;
-                }
+                (Ok(Received::Data(_)), Some(WriteRefusal::Broken)) => break,
                 // The peer's FIN after its last byte, or the connection's failure: the client
                 // reads the end.
                 (Ok(Received::End) | Err(Error::Failed(_)), _) => self.to_client = None,

d19-a-gone-writer-is-not-marked.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..02a515932 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -372,7 +372,7 @@ impl Node {
         let Some(stream) = self.streams.live.get_mut(&id).filter(|stream| !stream.connecting) else { return };
         match end {
             PipeEnd::ToClient => stream.to_client = None,
-            PipeEnd::FromClient => stream.done_writing = true,
+            PipeEnd::FromClient => {}
         }
         self.bridge(now);
     }

d20-a-gone-readers-end-is-kept.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..cb279401f 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -371,7 +371,7 @@ impl Node {
     pub fn pipe_gone(&mut self, now: Instant, id: StreamId, end: PipeEnd) {
         let Some(stream) = self.streams.live.get_mut(&id).filter(|stream| !stream.connecting) else { return };
         match end {
-            PipeEnd::ToClient => stream.to_client = None,
+            PipeEnd::ToClient => {}
             PipeEnd::FromClient => stream.done_writing = true,
         }
         self.bridge(now);

d25-a-close-keeps-the-readers-end.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..22dc49795 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -340,7 +340,6 @@ impl Node {
             self.streams.events.push_back(StreamEvent::Closed { id });
             return;
         }
-        stream.to_client = None;
         stream.done_writing = true;
         self.bridge(now);
     }

d36-every-stream-nobody-reads-is-timed.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..9e1bb826e 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -255,7 +255,7 @@ impl Stream {
             return false;
         }
         // Nothing of the stream reaches its client again, alive or not.
-        if self.to_client.is_none() && self.done_writing {
+        if self.to_client.is_none() {
             if !self.extended {
                 let kept = extended.entry(self.remote).or_insert(0);
                 if *kept < OWNERLESS_PER_PEER {

d37-a-writer-who-is-done-is-timed-with-its-reader-there.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..94400032f 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -255,7 +255,7 @@ impl Stream {
             return false;
         }
         // Nothing of the stream reaches its client again, alive or not.
-        if self.to_client.is_none() && self.done_writing {
+        if self.done_writing {
             if !self.extended {
                 let kept = extended.entry(self.remote).or_insert(0);
                 if *kept < OWNERLESS_PER_PEER {

d38-a-reader-that-left-is-timed-with-its-writer-there.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..9e1bb826e 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -255,7 +255,7 @@ impl Stream {
             return false;
         }
         // Nothing of the stream reaches its client again, alive or not.
-        if self.to_client.is_none() && self.done_writing {
+        if self.to_client.is_none() {
             if !self.extended {
                 let kept = extended.entry(self.remote).or_insert(0);
                 if *kept < OWNERLESS_PER_PEER {

d39-the-cuts-clock-is-not-restarted.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..43d579387 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -263,7 +263,7 @@ impl Stream {
                     self.extended = true;
                 }
             }
-            if self.deadline.is_none() || (self.extended && gave_up) {
+            if self.deadline.is_none() {
                 self.deadline = Some(now.after(OWNERLESS_LIFE));
             } else if self.deadline.is_some_and(|at| at <= now) {
                 stack.tcp_abort(now, conn);

d40-a-peer-keeps-any-number-alive.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..16c4acb84 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -258,7 +258,7 @@ impl Stream {
         if self.to_client.is_none() && self.done_writing {
             if !self.extended {
                 let kept = extended.entry(self.remote).or_insert(0);
-                if *kept < OWNERLESS_PER_PEER {
+                if *kept < usize::MAX {
                     *kept = kept.saturating_add(1);
                     self.extended = true;
                 }

d41-the-clock-restarts-for-a-connection-past-the-bound.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..d57b2d0ff 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -263,7 +263,7 @@ impl Stream {
                     self.extended = true;
                 }
             }
-            if self.deadline.is_none() || (self.extended && gave_up) {
+            if self.deadline.is_none() || gave_up {
                 self.deadline = Some(now.after(OWNERLESS_LIFE));
             } else if self.deadline.is_some_and(|at| at <= now) {
                 stack.tcp_abort(now, conn);

d43-a-place-freed-is-nobodys.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..bfc25d0ae 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -256,7 +256,7 @@ impl Stream {
         }
         // Nothing of the stream reaches its client again, alive or not.
         if self.to_client.is_none() && self.done_writing {
-            if !self.extended {
+            if self.deadline.is_none() {
                 let kept = extended.entry(self.remote).or_insert(0);
                 if *kept < OWNERLESS_PER_PEER {
                     *kept = kept.saturating_add(1);

d44-a-client-gone-after-the-peers-fin-is-not-timed.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..d8d650770 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -255,7 +255,7 @@ impl Stream {
             return false;
         }
         // Nothing of the stream reaches its client again, alive or not.
-        if self.to_client.is_none() && self.done_writing {
+        if self.to_client.is_none() && self.done_writing && !matches!(status.state, State::CloseWait) {
             if !self.extended {
                 let kept = extended.entry(self.remote).or_insert(0);
                 if *kept < OWNERLESS_PER_PEER {

d45-the-sixteen-are-the-nodes-not-an-addresss.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..97ab9e5e4 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -257,7 +257,7 @@ impl Stream {
         // Nothing of the stream reaches its client again, alive or not.
         if self.to_client.is_none() && self.done_writing {
             if !self.extended {
-                let kept = extended.entry(self.remote).or_insert(0);
+                let kept = extended.entry(Ipv4Addr::UNSPECIFIED).or_insert(0);
                 if *kept < OWNERLESS_PER_PEER {
                     *kept = kept.saturating_add(1);
                     self.extended = true;
@@ -324,7 +324,7 @@ impl Node {
         let stack = &mut self.stack;
         let mut extended = BTreeMap::new();
         for stream in live.values().filter(|stream| stream.extended) {
-            let peers = extended.entry(stream.remote).or_insert(0usize);
+            let peers = extended.entry(Ipv4Addr::UNSPECIFIED).or_insert(0usize);
             *peers = peers.saturating_add(1);
         }
         live.retain(|id, stream| (connects && !stream.connecting) || stream.pass(*id, now, stack, events, &mut extended));

d46-a-peer-keeps-fifteen.patch

diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index c940e0dae..c43c16413 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -55,7 +55,7 @@ const OWNERLESS_LIFE: Duration = Duration::from_secs(100);
 /// How many such streams one peer address keeps alive at once by taking their bytes. An
 /// estimate: no measurement sets it. It bounds an address, not the node, and not how slowly a
 /// peer may take.
-const OWNERLESS_PER_PEER: usize = 16;
+const OWNERLESS_PER_PEER: usize = 15;
 
 /// The most one read of a client's pipe takes.
 const CHUNK: usize = 4096;

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review of wt/toyos-ownstack-d at e0b917b04, round 3: git diff ddf479738 e0b917b04 (3 files, +240, -70), against round 2 above. Read, not run.

Net lines of the stage, git diff --shortstat origin/main...e0b917b04 with origin/main at 26f5ef205 (merge base 9a99ee0ba, #771's tip): 12 files, +1,918, -5. Production +542, -3 (streams.rs 411, lease/tcp.rs 64, lib.rs +12 -3, lease.rs 2, toyos-net-tcp 40, the shard 13); tests +1,367 (tests/streams.rs 1,237, take.rs 130); the track +8 -1, the manifest +1 -1. This round: production -2 net (a field and its three writes gone), tests +172 net. Accepted.

Round 2's BLOCKERs

  1. A client that dies after the peer's FIN is never timed, and no bound counts it — CLOSED. streams.rs:258 arms on self.to_client.is_none() && self.done_writing; reader_left is gone from the struct and from its three writers, so no path lets the end go without the cut seeing it. Measured in request 5 at e0b917b04: with ddf479738's streams.rs under this head's tests the target builds (exit 0) and a_client_done_writing_after_the_peers_fin_has_100_seconds_without_progress exits 101 on tests/streams.rs:1050, "the stream is let go", the assertion and not the build; at the head it exits 0. d44 (&& !matches!(status.state, State::CloseWait)) exits 101 on the same line. The test is the one asked for: the FIN arrives offering no window, 100,000 bytes written, the writer leaves, every probe answered and none taken; Cut at exactly left + 100 s, streams() == 0, one reset, the pipe still holding all [tcp] had no room for, and more than two probes heard.
    • It cuts nothing a client could still see. Past :253 the stream has from_client and no to_client; the to-client end went at :204, :211, close or pipe_gone, and the cut at :269 touches [tcp] and drops the from-client end only. What the client's pipe held at the peer's FIN is the pipe's: the node holds no end of it after :211 and cannot take a byte back. A client that shut its writing down after the peer's FIN and still holds the stream reads the end either way and has no call left that the connection answers; its tail is lost as a dead client's is, which the body says under "unsure".
    • The two round-1 halves still hold on this condition: d36 and d38 (to_client.is_none() alone) exit 101 on (0, [Cut]) against (1, []) after 150 s in CLOSE-WAIT and after 150 s with the reader gone; d37 (done_writing alone) exits 101 on a live reader cut.
    • d28, d29 and d30 each deleted one write of reader_left. Their rule is now that each of the three ways lets the to-client end go, held by d13 (:204, red at :930), d20 (pipe_gone, red at :914) and d25 (close, red at :873), all 101 in request 5. No rule is left without a control.
  2. Evidence — OPEN. cargo run -- --ci host has run at no head of this branch; the pull request is a draft and host, toolchain and guest at e0b917b04 conclude skipped. Request 5 is at this head: lock 0; toyos-net-tcp, toyos-net-shard and the node's tests 0, 0, 0 with streams 34, lease 18, slirp 3, take 5; the four gates 0 with 45 tests run and listed; the three clippies 0; 49 mutations, each built 0 and 101 on its named test; tree clean. That is not the host job. See "What closes the evidence".

Round 2's NOTEs

  • extended decided once — closed. :259-265 decides it in every pass while it is false. The count a pass starts from is every live stream already extended (Node::pass, :325-329), a stream let go during the pass is not taken off it, and each one made so adds one before the next is asked, so an address never has more than 16 extended alive at once and a freed place is taken one pass later, lowest id first. A stream made one of them keeps the deadline it had unless it gives up a byte in that pass. Red first: with ddf479738's source a_departed_clients_connection_past_the_sixteen_is_one_of_them_once_one_is_done exits 101 at :1117, "the FIN arrives: 146000 bytes did", which is 100 s of 1,460; at the head 0. d43 exits 101 on the same line.
  • The sixteen-peers test — closed. Sixteen slow readers of 250,000 bytes alive together; the seventeenth is cut at left + 100 s with (16, [Cut { id }], 1), and each of the sixteen then has its whole tail and FIN and no reset. d40, d41 and d42 exit 101 on the instant (26 s late), d46 on (15, [Cut 15, Cut 16], 1). a_peer_at_another_address_keeps_its_own_sixteen exits 0 on ddf479738's source, as a control should where the rule already held, and 101 under d45.
  • The track's departed-client line — closed. Per address, 16, an estimate; no floor on the rate; no bound across addresses; the bound named as the stream bound, with a test exit. 2,097,152 x 100 s is 6.6 years, and kernel/src/pipe.rs has PIPE_SIZE at 2 MiB.
  • The reset-stream line — closed. sdk/std/sys/net/connection.rs:330 and userland/libc/src/socket.rs:587 are what it says they are, and the cited issue's table says what the line says of macOS and Linux.
  • The every-stream line — closed. The two walks are Node::pass's; an idle stream's pass is one recv_with, two status and one pipe read, from :188-246. 1,538 bytes x 8 bits x 1 ns is 12,304 ns: 1,500 of MTU, 14 of header, 4 of FCS, 8 of preamble, 12 of gap. The exit is one a measurement fails or passes: netd's CPU time per frame at 1,000 idle streams less that at one, against 12.3 µs, on the T14. The line says which half is arithmetic and which an estimate.
  • Behaviour 3 of the body — closed.

BLOCKER

  • Evidence — item 2 above. Nothing else is open.

NOTE

Asked in the brief

  • CI's clippy, on what this round adds. No clone; no instant subtracted, each moves by Instant::after, max or min; no remainder; no bit trick; events() still collects an iterator; the new methods are at, update, turn_to and hears; :259 and :261 have a let between them and do not collapse; no child process. I expect none of redundant_clone, unchecked_time_subtraction, manual_is_multiple_of, manual_isolate_lowest_one, drain_collect, wrong_self_convention, let_and_return or zombie_processes to red.
  • What moves under toyos-net-node: listeners on the own stack's accept queue, and one bound on the streams, listeners and datagram sockets clients make the node hold (stage E) #777 beyond "Signatures that moved since ddf479738":
    • StreamEvent::Cut can now name a stream whose client still holds it and has not closed: reading ended at the peer's FIN, writing shut down. A later close, shutdown_write, set_nodelay or nodelay of that id names nothing (return, false, false, None), and whoever maps ids must expect the request after the Cut.
    • An accepted stream is timed by the same line with nothing set for it, so a server's usual shape is under the 16: the peer half-closes, the client writes its response and shuts down or closes. Stream::remote must be the accepted peer's address, and every connection that arrives through one translating or proxying address shares one 16.
    • A place is taken one pass after it is freed and in id order, so which accepted stream is kept depends on the order ids are given in.
    • In the fake: Far::hears asserts no destination (the NOTE above); a peer not parked records in segments, but does not answer, what the node sends its address from other ports, so texts() and first() on a Far that shares its address read every connection's; a SYN to its address moves the unparked Far to that connection and clears received and fin; run_slowly_until reopens only peers with Manner::Slow, no FIN and no reset.

What closes the evidence

The merge: origin/main has moved 28 files since 9a99ee0ba and this branch 12, and no file is in both, so git merge origin/main resolves nothing by hand. If it does stop, a resolution written by hand in any of these is new code and comes back for a round: userland/netstack/node/src/streams.rs, src/lib.rs, src/lease.rs, src/lease/tcp.rs, userland/netstack/node/Cargo.toml, userland/netstack/node/tests/streams.rs, toyos-net-shard/src/lib.rs, toyos-net-shard/tcp/src/stack.rs, conn.rs, rx.rs, toyos-net-shard/tcp/tests/take.rs, and the root Cargo.toml and Cargo.lock, which this stage does not touch. After the merge git diff --shortstat origin/main...<head> reads 12 files changed, 1918 insertions(+), 5 deletions(-), plus only the lines of the fake's assertion in tests/streams.rs if the NOTE is taken, and git diff e0b917b04 <head> outside those 28 files of main's is that assertion and nothing else.

The log: the host job of that head, concluded success, read whole and not by a result line, showing cargo run -- --ci host exit 0 and in it toyos-net-node's streams target running 34 tests with a_client_done_writing_after_the_peers_fin_has_100_seconds_without_progress, a_departed_clients_connection_past_the_sixteen_is_one_of_them_once_one_is_done, a_peer_at_another_address_keeps_its_own_sixteen and a_peer_keeps_sixteen_departed_clients_connections_alive_and_no_more each ok; lease running 18 tests; slirp running 3 tests; toyos-net-tcp's take running 5 tests; every toyos-net-shard target that has tests with a count above 0; the hostws::, userlandhost::, sourcegate:: and licence:: tests run and ok; clippy on the three crates under CI's toolchain with no warning. guest / suite concludes success and is not skipped; no guest test reaches the change and none is asked for.

On that log at the head that enters the queue the orchestrator may close item 2 and land without another round, the body's two lines corrected by him. The code needs no further review at e0b917b04.

SEND BACK

Japabu and others added 2 commits October 8, 2026 21:17
Each peer of the fake hears only the segments to its own address, so one
the node sent anywhere else was dropped by all of them and seen by nobody.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu Japabu changed the title toyos-net-node: streams, a connection of the own stack's bridged to its client's two pipes (stage D, stacks on #771) toyos-net-node: streams, a connection of the own stack's bridged to its client's two pipes (stage D) Oct 8, 2026
@Japabu
Japabu marked this pull request as ready for review October 8, 2026 19:18
@Japabu
Japabu marked this pull request as draft October 8, 2026 19:41
Resolved by hand, each to carry both stages:

- `userland/netstack/node/src/lib.rs`, the modules: `mod name;` of main's
  and `mod streams;` with its `pub use` of this branch's, both kept.
- `userland/netstack/node/src/lib.rs`, `Node::next_deadline`: one chain of
  the stack's, the DHCP client's, the name's and the streams' deadlines.
- `userland/netstack/node/Cargo.toml`, `description`: one sentence naming
  the datagram sockets, the streams and the mDNS name.
- `issues/toyos-has-its-own-network-stack.md`, the stage 5 paragraph: in
  the tree are the lease, the datagram sockets, the mDNS name and streams;
  still to build are the resolver and listeners.
- `issues/toyos-has-its-own-network-stack.md`, "What the node does not yet
  meet": this branch's six lines, then main's six, none changed.

Merged by git and read: `settle` ends in main's `serve_name`, and
`receive` and `fire` run the stream pass after it; `transmit` ends in the
pass over the connects; `lease.rs` has main's datagram forwards and
`mod tcp;`. `Stack`'s TCP forwards stay `tcp_*`; main's datagram forwards
(`bind`, `send_to`, `recv_from`, `close`, `set_ttl`, `join`) have no
prefix and are left as landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Mutation patches after the merge of main at 35d35859e, against da4a51968: the two the merge moved, both on Node::next_deadline. d21 is this stage's; n2 is stage B's (#772), regenerated here. The other 48 stream patches and stage B's other fourteen apply unchanged. build-request-7.sh ran the 49 and stage B's n1 to n10: 60 reds, exit 0.

d21-the-nodes-deadline-leaves-streams-out.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index 889299b73..0509fb095 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -149,7 +149,7 @@ impl Node {
 
     pub fn next_deadline(&self) -> Option<Instant> {
         let name = self.name.as_ref().and_then(name::Name::next_deadline);
-        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).chain(self.streams.next_deadline()).min()
+        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).min()
     }
 
     /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`].

n2-deadline-without-the-name.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index 889299b73..2bdd5c4c0 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -149,7 +149,7 @@ impl Node {
 
     pub fn next_deadline(&self) -> Option<Instant> {
         let name = self.name.as_ref().and_then(name::Name::next_deadline);
-        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).chain(self.streams.next_deadline()).min()
+        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(self.streams.next_deadline()).min()
     }
 
     /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`].

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review of wt/toyos-ownstack-d at da4a51968, round 4: the hand merge of origin/main at 35d35859e (#772) into 47231f0d7, and nothing else. git show --remerge-diff da4a51968, the merge against each parent, and lib.rs, lease.rs, lease/tcp.rs, streams.rs, name.rs, datagram.rs and the manifest whole at the merge. Read, not run.

Net lines of the stage, git diff --shortstat origin/main...da4a51968: 12 files, +1,924, -5. Production +542, -3; tests +1,373; the track +8, -1; the manifest +1, -1. The merge adds no line that is not one parent's: the three hand-resolved hunks of source and manifest replace one line each. git diff 47231f0d7 da4a51968 names 77 files and git diff 26f5ef205 35d35859e names the same 77, so outside what main touched the merge changes nothing. Root Cargo.toml and Cargo.lock at the merge are main's byte for byte.

Round 3's BLOCKER

  1. Evidence — CLOSED at 47231f0d7, OPEN at da4a51968. Run 37830990341 is at 47231f0d7 and concludes success in host, toolchain / build and guest / suite. That head is not the one that lands: da4a51968 carries five hand-resolved hunks, and its host, toolchain and guest conclude skipped (the pull request is a draft). Request 7 (ownstack-d-r7.log) is at da4a51968, tree clean before and after: lock 0; toyos-net-tcp, toyos-net-shard and the node's tests 0, 0, 0 with datagram 5, lease 18, name 13, slirp 3, streams 34, take 5; the four gates 0 with 45 tests run and listed; the three clippies 0; 60 mutation runs, each built 0 and 101 on its named test. That is not the host job. See "What closes it".

The hand-resolved hunks

  • userland/netstack/node/src/lib.rs:35-40, the modules — both sides whole: mod name;, mod streams; and its pub use; main's pub use datagram::… stands where toyos-net-node: clients' datagram sockets and the machine's mDNS name on ToyOS's own UDP, host-tested and shipped in nothing #772 put it. Nothing dropped, nothing doubled.
  • userland/netstack/node/src/lib.rs:150-153, Node::next_deadline — the minimum over the stack's, the DHCP client's, the name's and the streams' deadlines. Those are every source the node has: a datagram socket keeps no clock of its own outside the shard's, and Node holds no other timed state (counters, events and the datagram buffer have none). None is stale by the merge: the name's is read from the responder at the call, the streams' from live at the call. Each of the two that the merge joined has its control at this head: d21 (the chain without the streams') exits 101 on tests/streams.rs:595, the two instants 500 s apart, and n2 (the chain without the name's) exits 101 on tests/name.rs:170, "2s apart"; both are the assertion and not the build, whose step exits 0. The stack's and the client's are held by next_deadline_is_the_earliest_of_the_clients_and_the_stacks, green in request 7.
  • userland/netstack/node/Cargo.toml:3, description — says what the package is with both stages in it. The dependency list is main's.
  • issues/toyos-has-its-own-network-stack.md, the stage 5 paragraph — true of the tree at the merge: the node, the lease, the datagram sockets, the name and streams are in it; the resolver and listeners are not.
  • issues/toyos-has-its-own-network-stack.md, "What the node does not yet meet" — against 47231f0d7 the file gains main's lines and loses only the stage 5 paragraph it rewrites; against 35d35859e it gains this stage's six lines, the paragraph and the recv_with departure. No line of either side is reworded or lost.

What git merged where the stages meet

  • settle's tail against the stream pass — neither pass depends on the other having run. serve_name reads the lease and the responder's socket and calls send_to and recv_from; Stream::pass calls tcp_* and its two pipe ends. They share the shard and nothing in it: the name writes no connection and a stream no socket, no address and no route. The order settle then bridge in receive and fire decides only which flow becomes eligible first in the shard's round. In receive each runs once. In fire serve_name runs twice, with settle, as it did on main before the merge, and the stream pass once, after the second. link and answer_as end in serve_name and no stream pass, and the datagram calls in neither: each as its own stage left it and as the track records, and the merge moved none of it.
  • settle's break — toyos-net-node: clients' datagram sockets and the machine's mDNS name on ToyOS's own UDP, host-tested and shipped in nothing #772 turned the loop's return into break so the tail runs; the merge kept it. A return there would have dropped the name's pass, and every name test would red on it.
  • transmit's tail — main had nothing after self.stack.transmit; the merge has this stage's self.pass(now, true), which reads streams.live and calls tcp_status and tcp_abort only. An opportunity moves no name state the next settle does not find.
  • Node::pass against the datagram sockets — the per-address count is built from streams.live alone and Node::streams() is live.len(); datagram.rs adds no field to Node, a socket's id is [udp]'s, and name holds one id. Nothing of one is counted, timed or let go by the other.
  • lease.rs — the datagram forwards and mod tcp; are both there. Stack now has three families: the DHCP client's send and recv, the datagram bind, send_to, recv_from, close, set_ttl and join, and tcp_*. No call can take one for another: a ConnId is not a SocketId, the client's SocketId is a private field no forward hands out, and lease/tcp.rs, the one child that could reach it, does not name it. Naming is not a finding under this bar and nothing here must change. If it is made one scheme it is in one change that owns the datagram forwards, toyos-net-node: the resolver on ToyOS's own UDP, host-tested and shipped in nothing #774 or the move, not in this stage: renaming main's six here is outside the stage's twelve files' purpose and would conflict with toyos-net-node: the resolver on ToyOS's own UDP, host-tested and shipped in nothing #774's use of them.
  • toyos-net-shard/src/lib.rs — main added udp_counters, join, udp_port, udp_set_ttl and udp_close; this stage recv_with and set_options. Disjoint hunks, each settling as its side wrote it.

A rule that exists only in the merge

One: the combined deadline, and both of its joined terms have a patch and a red test at this head (d21, n2). The order of the hooks is no rule: no patch that swaps serve_name and bridge makes a defect, so none is named and no test is owed.

BLOCKER

  • Evidence — item 1 above. Nothing else is open; the merge is sound as resolved.

NOTE

None.

What closes it

The host job of the head that enters the queue, concluded success and not skipped, read whole and not by a result line, showing cargo run -- --ci host exit 0 and in it:

  • toyos-net-node: tests/datagram.rs running 5 tests, tests/lease.rs running 18 tests, tests/name.rs running 13 tests, tests/slirp.rs running 3 tests, tests/streams.rs running 34 tests, each ok with 0 failed; in them by name a_connect_past_its_deadline_is_timed_out_at_the_deadline, a_held_lease_is_announced_at_once_and_a_second_later and next_deadline_is_the_earliest_of_the_clients_and_the_stacks, which are the three that hold the merged line.
  • toyos-net-tcp: tests/take.rs running 5 tests, ok.
  • toyos-net-shard: every target that has tests with its count above 0 (request 7 has acquisition 5, drr 6, egress 19, icmp 4, log 3, net 5, udp 3 and 2 unit tests).
  • The hostws::, userlandhost::, sourcegate:: and licence:: tests run and ok.
  • Clippy on the three crates under CI's toolchain with no warning.

guest / suite concludes success and is not skipped; no guest test reaches the change and none is asked for.

On that log, at a head whose tree in these twelve files is da4a51968's, the orchestrator may close item 1 and land without another round. If the head moves by a merge git resolves alone in files this stage does not touch, the same reading closes it. If it moves by another hand resolution in lib.rs, lease.rs, the manifest or the track, that resolution is new code and comes back, as this one did.

The next hand merge: the resolver (#774) into this line

#774 lands first, so this branch merges it. What that resolution has to get right in userland/netstack/node/src/lib.rs:

SEND BACK

Resolved by hand, each to carry both stages:

- `userland/netstack/node/src/lib.rs`, the modules: `mod resolve;` of
  main's and `mod streams;` with its `pub use` of this branch's, both kept.
- `userland/netstack/node/src/lib.rs`, `Node::next_deadline`: one chain of
  five, the stack's, the DHCP client's, the name's, the resolver's and the
  streams' deadlines.
- `issues/toyos-has-its-own-network-stack.md`, the stage 5 paragraph: in
  the tree are the lease, the datagram sockets, the mDNS name, the resolver
  and streams; still to build are listeners.

Edited by hand where git saw no conflict: the node manifest's
`description`, which main left without the resolver, names it beside the
rest.

Merged by git and read: `settle`'s loop ends in `break` and its tail is
`serve_name`, then the resolver's pass; `receive` and `fire` run the
stream pass after `settle`; `transmit` ends in the pass over the connects,
main's having no tail of its own; `Node` and `Node::new` hold `name`,
`resolver` and `streams` once each; `lease.rs` has `mod tcp;`, and the
resolver's `Stack::connect` stands among the datagram forwards beside
`tcp_connect`; the track's list has this branch's six lines and all of
main's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu

Japabu commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Mutation patches after the merge of main at a4416fe6c, against 49e38ca4a: the three controls of Node::next_deadline's one line, regenerated. d21 is this stage's, n2 stage B's (#772), r16 the resolver's (#774). Every other patch applies unchanged. Run at 49e38ca4a: the 49 stream mutations, stage B's n1 to n10 and the resolver's r16 and r17, 62 reds, each log under the orchestrator's scratchpad, ownstack/d/logs8/.

d21-the-nodes-deadline-leaves-streams-out.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index cd211878c..24c8f3a1b 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -157,7 +157,7 @@ impl Node {
 
     pub fn next_deadline(&self) -> Option<Instant> {
         let name = self.name.as_ref().and_then(name::Name::next_deadline);
-        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).chain(self.resolver.next_deadline()).chain(self.streams.next_deadline()).min()
+        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).chain(self.resolver.next_deadline()).min()
     }
 
     /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`].

n2-deadline-without-the-name.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index cd211878c..619af9f66 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -157,7 +157,7 @@ impl Node {
 
     pub fn next_deadline(&self) -> Option<Instant> {
         let name = self.name.as_ref().and_then(name::Name::next_deadline);
-        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).chain(self.resolver.next_deadline()).chain(self.streams.next_deadline()).min()
+        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(self.resolver.next_deadline()).chain(self.streams.next_deadline()).min()
     }
 
     /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`].

r16-deadline-without-the-lookups.patch

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index cd211878c..691d63c2a 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -157,7 +157,7 @@ impl Node {
 
     pub fn next_deadline(&self) -> Option<Instant> {
         let name = self.name.as_ref().and_then(name::Name::next_deadline);
-        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).chain(self.resolver.next_deadline()).chain(self.streams.next_deadline()).min()
+        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).chain(self.streams.next_deadline()).min()
     }
 
     /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`].

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review of wt/toyos-ownstack-d at 49e38ca4a, round 5: the hand merge of origin/main at a4416fe6c (#774) into da4a51968, and nothing else. git show --remerge-diff 49e38ca4a, the merge against each parent, and lib.rs, lease.rs, lease/tcp.rs, streams.rs and resolve.rs whole at the merge; request 8's step logs read file by file. Read, not run.

Net lines of the stage, git diff --shortstat origin/main...49e38ca4a: 12 files, +1,924, -5. Production +542, -3; tests +1,373; the track +8, -1; the manifest +1, -1. git diff --name-only da4a51968 49e38ca4a and git diff --name-only 35d35859e a4416fe6c name the same 55 files, and git diff da4a51968 49e38ca4a over the eight of the stage's twelve that main did not touch (streams.rs, tests/streams.rs, lease/tcp.rs, the shard's lib.rs, [tcp]'s conn.rs, rx.rs, stack.rs, take.rs) is empty. Root Cargo.toml and Cargo.lock at the merge are main's byte for byte. The stage's diff of lib.rs against main is the same eleven changed lines as at da4a51968 but for the one next_deadline line.

Round 4's BLOCKER

  1. Evidence — OPEN. host, toolchain and guest of run 37839532113 at 49e38ca4a conclude skipping: the pull request is a draft. Request 8 is at 49e38ca4a, tree clean before and after, and is everything short of the host job: cargo metadata --locked 0; toyos-net-tcp 0 with take 5; toyos-net-shard 0 (acquisition 5, drr 6, egress 19, icmp 4, log 3, net 5, udp 3, 2 unit tests); toyos-dns 0 with 44; the node 0 with datagram 5, lease 18, name 13, resolve 23, slirp 3, streams 34, every test listed ok; the four gates 0 with 45 run and listed; four clippies 0 with no warning; 62 mutation runs, each built 0 and 101 on its named test; the node's tests and take again unmutated, 0 and 0. See "What closes it".

The list round 4 left for this merge, item by item

  • settle (userland/netstack/node/src/lib.rs:176-197) — the loop ends in break; the tail is serve_name(now) then self.resolver.pass(...), both after the loop. resolver.pass has one call site in the crate, this one. Met.
  • receive and fire (lib.rs:131-135, 163-171) — settle then bridge in each; fire settles twice and bridges once, after the second. The resolver's pass therefore runs twice in fire at one now, as on main, and the merge adds no third. A retry due at now goes out once: a_resolver_that_never_answers_is_asked_at_each_waits_end drives the node through Wire::fire and next_deadline alone and asserts the exact list of instants at which queries left, one per wait; a second send at one instant is a second entry. Green at this head, 23 of 23 in resolve, in both node runs. Met.
  • next_deadline (lib.rs:158-161) — five terms, the stack's, the client's, the name's, the resolver's, the streams'. Node holds no other timed state. Met; controls below.
  • transmit (lib.rs:139-143) — main gave it no tail; the merge has this stage's self.pass(now, true) and nothing lost. Met.
  • Node and Node::new (lib.rs:72-102) — name, resolver, streams once each in the struct and once each in the initializer. Met.
  • lease.rs — mod tcp; at line 16. Stack::connect(now, SocketId, Ipv4Addr, u16) is [udp]'s, in the datagram forwards; Stack::tcp_connect(now, Endpoint) is in lease/tcp.rs. One inherent connect and one close on Stack, so resolve.rs's stack.connect and stack.close can mean nothing else, and streams.rs calls only tcp_*. Node::connect is the stream's and resolve.rs adds resolve, take_resolved and let_go to Node: no name twice. No shadowing of send and recv. Met.
  • The manifest's description and the track's stage 5 paragraph — both name the lease, the datagram sockets, streams, the name and the resolver; "still to build" is listeners, then the move. True of the tree at the merge. The track against main gains this stage's six lines, the paragraph and the recv_with departure, and loses nothing of toyos-net-node: the resolver on ToyOS's own UDP, host-tested and shipped in nothing #774's. Met.
  • The stat — 12 files, +1,924, -5, unchanged; outside main's files the merge is empty. Met.

Whether a stream pass and the resolver's pass can affect each other

  • State of the node's: none shared. Node::pass builds the per-address count from streams.live alone and streams() is live.len(); a lookup's sockets are SocketIds kept in Resolver::asking, counted by MAX_LOOKUPS and by nothing of the streams'. Stream::pass takes no draw; the resolver's draws are settle's, so the order of draws toyos-net-node: the resolver on ToyOS's own UDP, host-tested and shipped in nothing #774 fixed is as it landed.
  • The lease: the resolver reads stack.lease(); no tcp_* forward writes it.
  • The shard: a query to a next hop not yet resolved waits in [ip]'s queue of PENDING_PER_NEIGHBOUR, which keeps the newest; a SYN does not enter that queue. toyos-net-shard/src/lib.rs:594-616 (hop) has [tcp] hold its own segment and wait on Wait::Hop, so a burst of lookups pushes no SYN out and a connect pushes out no query. Ports: [udp]'s ephemeral table and [tcp]'s are two.
  • The order settle then bridge decides only which flow becomes eligible first in the shard's round at one now. No patch that swaps them makes a defect, so none is named.

A rule that exists only in the merge

One, as last round: the combined deadline, now of five, and each term a merge joined has its patch regenerated on the merged line and red on its own test's assertion, the build step 0 before it:

  • d21, the chain without self.streams.next_deadline() — 101 at tests/streams.rs:595, assert_eq!(net.now, start.after(2_500 ms)), the two instants 500 s apart, in a_connect_past_its_deadline_is_timed_out_at_the_deadline.
  • n2, the chain without name — 101 at tests/name.rs:170, "2s apart", in a_held_lease_is_announced_at_once_and_a_second_later.
  • r16, the chain without self.resolver.next_deadline() — 101 at tests/resolve.rs:612, None where Some(Err(Failed(TimedOut))) is asserted, in a_resolver_that_never_answers_is_asked_at_each_waits_end.
  • The stack's and the client's terms: next_deadline_is_the_earliest_of_the_clients_and_the_stacks, green in both node runs.

r17 (resolve.rs's own .min() made .max()) is 101 at tests/resolve.rs:629, 7000 against 6000. No rule of the merge is without a test that fails without it.

BLOCKER

  • Evidence — item 1 above: no cargo run -- --ci host at 49e38ca4a. Nothing else is open; the merge is sound as resolved.

NOTE

  • The pull request body, request 8's row — it gives the step logs by a path under a home directory on the development machine; name them by request and step as rows 1 to 7 do.

What closes it

The host job of the head that enters the queue, concluded success and not skipped, read whole and not by a result line, showing cargo run -- --ci host exit 0 and in it:

  • toyos-net-node: tests/datagram.rs running 5 tests, tests/lease.rs running 18 tests, tests/name.rs running 13 tests, tests/resolve.rs running 23 tests, tests/slirp.rs running 3 tests, tests/streams.rs running 34 tests, each ok with 0 failed; in them by name the four that hold the merged line: a_connect_past_its_deadline_is_timed_out_at_the_deadline, a_held_lease_is_announced_at_once_and_a_second_later, a_resolver_that_never_answers_is_asked_at_each_waits_end and next_deadline_is_the_earliest_of_the_clients_and_the_stacks.
  • toyos-net-tcp: tests/take.rs running 5 tests, ok.
  • toyos-net-shard: acquisition 5, drr 6, egress 19, icmp 4, log 3, net 5, udp 3 and 2 unit tests, ok.
  • toyos-dns: running 44 tests, ok.
  • The hostws::, userlandhost::, sourcegate:: and licence:: tests run and ok.
  • Clippy on [tcp], the shard, toyos-dns and the node under CI's toolchain with no warning.

toolchain / build concludes success and guest / suite concludes success and is not skipped; no guest test reaches the change and none is asked for.

On that log, at a head whose tree under userland/netstack/node, toyos-net-shard and toyos-dns and in the track is 49e38ca4a's, the orchestrator closes item 1 and lands without another round. A later merge of main that git resolves alone and that touches none of those paths leaves that true. A merge that touches any of them, by hand or by git, is new code in the files this round judged and comes back.

The next merge: this head into the listener stage (#777, 643584d4b, based on da4a51968)

In the files the two share:

SEND BACK

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI at 49e38ca4a, read from each job's own log (the orchestrator). host: [ci] Host: 79 step(s), all green; the node's suites as the review asked: datagram 5, lease 18, name 13, resolve 23, slirp 3, streams 34, take 5, toyos-dns 44, each 0 failed; clippy with warnings denied clean. guest / suite: test result: ok. 36 passed, 36 total, [ci] Guest: 5 step(s), all green. Queued.

@Japabu
Japabu added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit d5de6fa Oct 9, 2026
6 checks passed
@Japabu
Japabu deleted the wt/toyos-ownstack-d branch October 9, 2026 00:45
Japabu added a commit that referenced this pull request Oct 9, 2026
…, the drivers' room and wake (#782) and the SMI_CMD call (#780), into the listeners stage

No file stopped the merge. One file is both sides': the track, where
main's two removed bullets and two added ones (#779) sit in the node's
and stage 3's lists and this stage's lines in stage 5's, merged by git
and read against both parents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
Japabu added a commit that referenced this pull request Oct 9, 2026
…ers (#777), the drivers' room and wake (#782), the SMI_CMD call (#780) and the guest waits (#786), into the resolver rules

One conflict, in issues/toyos-has-its-own-network-stack.md, resolved by hand
with every line of both sides accounted for:

- The stage 5 paragraph: main's "In the tree", which now names streams,
  listeners and the one bound, and this branch's "Still to build on it",
  less the streams and listeners that landed: datagram senders that wait on
  the hop, then the move.
- "What the node does not yet meet": this branch's two lines stand in place
  of the two lines they rewrote, which main had left as they were; the line
  on an answer to a 169.254/16 asker, which #779 deleted with the defect, is
  gone, as is the line on a silent next hop, which #779 deleted with its
  test (no conflict).
- "What stage 3 departs from its specifications": both sides added a line at
  the list's end; both stand, this branch's on NUD-04 and #779's on the
  scenarios the readers' specification lacks.

Every other file merged without a conflict. toyos-dns/src/lib.rs and
userland/netstack/node/src/resolve.rs are byte-identical to b2d9037.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
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.

1 participant