Skip to content

The shipped netstack runs ToyOS's own network stack and smoltcp leaves the tree; libc reads a stream's end as std does - #801

Merged
Japabu merged 20 commits into
mainfrom
wt/toyos-move
Oct 9, 2026
Merged

Japabu merged 20 commits into
mainfrom
wt/toyos-move

Conversation

@Japabu

@Japabu Japabu commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

The shipped netstack runs ToyOS's own stack, and smoltcp is deleted: cargo tree -p netstack names neither smoltcp nor managed, Cargo.lock loses both, and git grep -i smoltcp is empty outside issues/. With it, libc reads a stream's end as std does (#803's libc half), and one boot of tests/netcase reads every end in C and in std against a host peer.

Found on the way, not fixed here

Each is below netstack's shell or outside this change's fence (toyos/, sdk/, rust/, the stack's crates).

  1. A client that shut its sending half down read its peer's FIN as a reset, a regression this move made against what shipped, measured in round 1 on both cards (all 4,194,304 bytes back as sent, then connection reset). Closed. The node's half is The node keeps the pipe order std reads a stream's end by: a FIN after a shutdown reads as a FIN, a failure as a reset #803, now on main and merged here: the node keeps a send pipe whose FIN is queued until its writer leaves, and lets a failed stream's send pipe go first. Its libc half, which The node keeps the pipe order std reads a stream's end by: a FIN after a shutdown reads as a FIN, a failure as a reset #803 moved to this branch, is carried here unchanged (Decisions, and the guest tests' libc_sockets). netstack_streams reads the stream's end again, and issues/a-netstack-client-cannot-tell-a-reset-from-the-peers-fin.md closes by its exit.
  2. QEMU's forward drops a dial that arrives while one is queued. Not ToyOS's. Round 1: 3 of 8 runs of the whole suite red at a host load of about 30. Round 2 measured it again with the frames on the guest's card (-object filter-dump on the guest NIC, the round-0 back-to-back dials restored, a temporary patch reversed after): at a load of 44 the second suite run reddened on virtio; the guest's capture holds one SYN on the listener's port and none of the second peer's port, and no RST from the guest; both host connects returned Ok and the second peer's write_all returned Broken pipe (os error 32). Green runs captured the same way hold two SYNs 0.07 to 0.09 ms apart, both answered. The reading is on the pull request (comment "BLOCKER 4: the guest's frames"). The harness dials its second peer once QEMU's table shows the first carried, asking the table again after a back-off of 1 ms doubling to 64 ms. issues/a-connect-between-two-accepts-is-reset.md names the forward as a third candidate for its reading.
  3. libc's close of a socket ends the program. close_socket (userland/libc/src/socket.rs) has no caller. So close on a socket's descriptor reaches SYS_CLOSE with the socket table's index, and the kernel ends the program for naming a handle it does not hold (handle fault: ... syscall=10 no such handle, exit 139, seen on this round's first run of the C case). Already filed: issues/libc-close-of-a-socket-ends-the-process.md. The C case closes no socket; its exit lets each one go.
  4. Three lines of the track that named the move are carried, not closed, because each changes code the T14's reading of this head stands on: toyos_dns::Lookup::on_datagram's two parameters (the change is toyos-dns's); a waiting datagram whose link returns before a transmit opportunity (the change is toyos-net-udp's); and the cost of a pass at 1,000 idle streams, which cannot exist: the places end at 103. That last line now also carries Node::pipe_gone's pass of its own: each end-gone answer passes every stream, beside the one pass Sockets::bridge runs for a wake's readiness answers, so k clients leaving in one wake cost k + 1 passes. The fix is small (the answer marks the wake, as a readiness answer does), but it changes netstack's pass order under the T14's reading, so it is filed on that line and not made here.

What replaced what

userland/netstack is the card, the clock, the kernel's random source, the kernel's pipes and the clients' connections around toyos-net-node. It holds no protocol and reads no frame.

Net (git diff --shortstat origin/main...01bb4c8c3, main at d6298c83e): 66 files, +2,401 / −4,002. userland/netstack/src: +1,282 / −3,322. libc with its host copy: +114 / −7. Tests (tests/, the node's tests, tests/libc-arch): +822 / −127. issues/: +181 / −484.

Decisions

  • Every draw is the kernel's. One draw() calls toyos_abi::syscall::random and panics on a refusal; the shard's seven keys are built from it, no draw part of two, and every node call that takes a draw takes it, Node::transmit and toyos-mdns claims its name before it uses it: three probes, the tie-break, a conflict's outcome, RFC 6762 §8.1's bound on what a peer's messages cost, and the link's return in both callers #793's Node::answer_as among them. Held by reading.
  • The places, from memory. A place is priced at the largest thing it can be: a listener, whose peers fill LISTEN_READY = 128 finished connections with a receive buffer of 65,535 bytes each, and its 2 MiB wake pipe: 128 × 65,535 + 2,097,152 = 10,485,632 bytes. A stream (two pipes, two buffers: 4,325,374 bytes) and a datagram socket (two pipes, two queues of 16 datagrams bounded at 65,536 bytes: 4,325,376 bytes) are less. The node gets an eighth of physical memory over that price, at least 1 and at most 103, which is what one poller watches at two watches a place beside the acceptor, the card, 32 unspoken connections and 16 lookups' clients. A 4 GiB guest reads 50 (net.places.max, measured). Outside the places and not priced: at most toyos_dns::MAX_LOOKUPS = 16 lookups, each with one datagram socket for every query of its rounds.
  • A departed owner is heard. A listener's wake pipe and a datagram socket's receive pipe are watched for their other end's leaving, so neither outlives a client that sent no close.
  • What waits is bounded. One receive waits on a socket: a second while it waits is refused ERR_RESOURCE_EXHAUSTED, and a waiter whose client hung up is replaced, so a client that left does not shut its socket's receives out. A client's requests then hold no more of netstack's handles than its sockets do. Pending connections are bounded at 32, lookups at 16, connects by the places.
  • An accept the kernel refuses is passed over. The kernel has already taken the connection off the port's queue and told its client; netstack names the first refusal of a run and how many followed once an accept succeeds again. No expect on a client-driven resource remains.
  • The DHCP client's refusals are bounded as the stack's are. The node admits each through toyos-dhcp's own RefusalLog (the shard's mechanism, from toyos-net-wire's counters!): one line a rule in any 10 s, carrying how many it did not log. Without it any host on the link wrote one line of the stick's log a frame.
  • One pass over the streams a wake, for readiness. A ready stream watch marks the wake and Sockets::bridge passes the streams once after the poller's answers, however many it carried. An end-gone answer still runs a pass of its own (Node::pipe_gone): Found on the way, 4.
  • No wait without an event. The 1 ms pass while a receive or a connect was pending is gone: a connect's deadline is the node's, a waiting receive is tried again in the pass that took a frame, and one whose socket was closed is answered in the pass after the close.
  • A stream's ends outlive its id. A closed stream's pipe is still read until it is empty, so netstack keeps its ends for as long as the node holds either, and the id names nothing from the close on.
  • A shutdown of the receiving half alone reaches no node call. std keeps that half's state itself, and so does libc; the request is answered done for an id the table says names a stream.
  • The node ships nothing its tests alone read. Node::nodelay lost its last production caller with that request, and Node::listener_nodelay and Node::dhcp never had one: all three are deleted. The listener tests read the option from the accept's answer (Accepted::nodelay) and the stream tests on the wire, as nodelay_reaches_the_stack already did. The deadline test tells the client's wait from the stack's by Node::next_deadline against Node::shard's, which netstack's inspect reads. The refusal test counts the client's refusals by the Event::Dhcp lines the node drains, the second carrying 99 suppressed.
  • libc reads a stream's end as std does (The node keeps the pipe order std reads a stream's end by: a FIN after a shutdown reads as a FIN, a failure as a reset #803's libc half, applied unchanged from its comment on The node keeps the pipe order std reads a stream's end by: a FIN after a shutdown reads as a FIN, a failure as a reset #803). A read of 0 asks the send pipe with a zero-byte write: an answer from a reader is the peer's FIN, and a send pipe whose reader is gone is a reset, ECONNRESET. That is the order the node keeps. send after SHUT_WR answers EPIPE before it reaches the pipe, which the node keeps and reads no more. recv after SHUT_RD answers 0: that is BSD's reading of SHUT_RD with bytes in flight, not every host's; Linux returns the bytes already queued first, which is what m12 turns into a red. A refused write is ECONNRESET or EAGAIN. Its rule is userland/libc/src/streamend.rs, host-tested by toyos-libc-copies (48 tests). No SIGPIPE is raised, as issues/libc-answers-epipe-and-raises-no-sigpipe.md records.
  • The name's events are the node's since toyos-mdns claims its name before it uses it: three probes, the tie-break, a conflict's outcome, RFC 6762 §8.1's bound on what a peer's messages cost, and the link's return in both callers #793: netstack writes that change's two lines for Event::Name, and the responder's link is what Node::link was told.
  • tests/netcase/system.toml names the 82574L beside the virtio card, so one config boots on either; the harness has Profile::HeadlessE1000e.

Every word netstack writes for the node's

node pipe ABI
Refused::{AddrInUse, NotConnected, InvalidInput, PermissionDenied, ResourceExhausted} the word of the same name
ConnectRefused::Full ERR_RESOURCE_EXHAUSTED
ConnectRefused::Stack(Route(_)): no address or no route ERR_NOT_CONNECTED
ConnectRefused::Stack(NotUnicast), Tcp(InvalidRemote), a port of 0 ERR_INVALID_INPUT
ConnectRefused::Stack(Tcp(AddrInUse)) ERR_ADDR_IN_USE
StreamEvent::Failed: Refused, Reset, TimedOut ERR_CONNECTION_REFUSED, ERR_CONNECTION_RESET, ERR_TIMED_OUT
StreamEvent::Failed: Unreachable(_), Prohibited ERR_OTHER, named in the log: the pipe ABI has no word for an ICMP error (filed)
StreamEvent::TimedOut ERR_TIMED_OUT
StreamEvent::Closed ERR_CONNECTION_REFUSED, as on smoltcp
ListenRefused::{Full, NotLocal, InUse} ERR_RESOURCE_EXHAUSTED, ERR_INVALID_INPUT (the word a datagram bind to such an address gets), ERR_ADDR_IN_USE
AcceptRefused::{NoListener, Nothing}, NoPipes, Full ERR_NOT_CONNECTED, ERR_INVALID_INPUT, ERR_RESOURCE_EXHAUSTED
NotStarted::{NotConnected, ResourceExhausted} the word of the same name
Ended::Failed(NoSuchName | NoAddress) an answer of no address
Ended::Failed(TimedOut) ERR_TIMED_OUT
Ended::Failed(Unreachable), Ended::LeaseChanged ERR_NOT_CONNECTED: neither has a word of its own (filed); both are a machine on no network that answers the name
Ended::Failed(Truncated | ServerFailed | TooManyAliases), Ended::NoPort ERR_OTHER and ERR_RESOURCE_EXHAUSTED, named in the log, as on smoltcp
a set-option or shutdown on an id the node no longer holds ERR_NOT_CONNECTED

The rows in bold are choices of an existing word where the ABI has none: issues/the-pipe-abi-has-no-word-for-an-unreachable-host-or-a-lookup-to-try-again.md records the words owed (an ICMP-ended connect: network or host unreachable, port unreachable ECONNREFUSED, Prohibited EHOSTUNREACH; a lookup ending Unreachable: network unreachable; one ending LeaseChanged: a try-again word, getaddrinfo's EAI_AGAIN), owner the network track, exit an ABI change after the move. No caller in the tree branches on any of the three today.

The old shell's tests

24 host tests left with the two smoltcp harnesses and mdns.rs; netstack's own are virtio_net.rs's 5. Read by name and header against the node's tests, not run pairwise.

old test its node test, or why it is gone
a_finished_handshake_is_owed_one_wake a_listener_answers_a_syn_and_wakes_its_owner_when_the_handshake_ends
a_peer_that_closes_with_its_last_ack_is_a_connection gone: a state of the one-socket listener; the accept takes what [tcp]'s queue holds
a_peer_that_resets_before_it_is_taken_frees_the_port, a_wake_spent_on_a_reset_connection_announces_the_next a_wake_left_by_a_connection_its_peer_reset_stands_for_the_next
an_accept_refused_for_its_pipes_is_woken_again an_accept_spends_a_wake_whatever_it_answers
an_accept_refused_for_room_is_woken_again_when_room_returns more_places_wake_the_owner_of_a_connection_that_waits, a_wake_is_owed_only_for_a_connection_there_is_a_place_for
an_aborted_connection_is_spent_once_its_reset_has_left, a_connection_closed_by_both_ends_is_spent gone with spent, the shell's reading of a smoltcp socket: a_reset_ends_both_pipes_and_the_stream, a_client_that_left_is_finished_by_the_stack
the three …next_hop… and …neighbour_entry… ceiling tests gone with RESET_LIFE, the shell's wait on smoltcp's ARP for a reset; a reset's next hop is [ip]'s
a_peer_that_acknowledges_nothing_of_a_full_send_buffer_is_reset_at_the_ceiling, a_peer_gone_silent_is_reset_at_the_ceiling a_departed_clients_unsent_bytes_have_100_seconds, a_client_done_writing_after_the_peers_fin_has_100_seconds_without_progress
a_peer_that_answers_and_never_closes_is_reset_at_the_ceiling the rule changed: a_departed_clients_tail_arrives_whole_while_its_peer_takes_it, a_peer_keeps_sixteen_departed_clients_connections_alive_and_no_more; a client gone with nothing in its pipe is [tcp]'s 60 s, which the track records
a_server_that_answers_no_arp_holds_up_no_other_server a_query_for_a_resolver_ip_has_given_up_ends_its_lookup_in_the_opportunity_that_would_have_carried_it, and [ip]'s rfc_4861_7_2_2_a_silent_next_hop_holds_up_no_other
a_server_with_no_route_holds_up_no_other_server a_query_udp_refuses_is_counted_holds_no_port_and_the_next_resolver_is_asked_at_once
an_alias_answered_while_queries_are_stuck_restarts_the_lookup an_alias_answered_late_restarts_the_lookup_and_lets_the_old_names_queries_go
the_lookup_past_the_cap_is_refused_as_exhausted the_lookup_past_the_cap_is_refused_until_an_answer_is_taken
a_lookup_whose_client_left_is_let_go_at_once a_lookup_let_go_frees_its_ports_and_its_place_at_once
a_lookup_holds_a_socket_per_query_that_left_and_none_once_ended a_query_holds_its_port_while_its_answer_is_read_and_an_ended_lookup_holds_none
a_query_leaves_from_no_port_a_client_holds a_query_is_rfc_1035s_octets_from_a_port_and_with_an_id_of_its_own_draws; the port is [udp]'s to pick
a_server_that_never_answers_is_asked_at_each_waits_end, a_lookup_is_not_carried_by_a_later_ones_schedule the node's tests of the same two names
wake_in_asks_for_what_the_name_owes_and_nothing_else the node's next_deadline, in userland/netstack/node/tests/name.rs

Guest tests, and why a guest

netstack has no host build: it owns a card. The node's host tests answer it from the tests' own script. What only a guest shows is the kernel's pipes and watches under the real shell, and a network stack nobody here wrote on the far end.

  • netstack_streams, netstack_streams_e1000e (new): 4 MiB written to the host kernel's TCP through QEMU's user network, the sending half shut at once, every byte read back and compared, and then the stream's end read as an end; then two host peers dial the guest's listener before it accepts either, both are accepted in turn, answered and closed, and each reads its stream's end. The T14's bench may open no peer that echoes megabytes or dials in.
  • No lookup guest. Round 3's netstack_lookup and netstack_lookup_e1000e are cut. The e1000e arm reached nothing that netstack_streams_e1000e (the Intel driver in a guest) and the virtio arm did not. The virtio arm passed on any word a server gave, so it held only that a query leaves through serve.rs's lookup path and an answer comes back. A T14 row reads exactly that: The T14's outbound rows: its router and the internet, judged from the stick (lands behind the move; do not merge before it) #784's outbound_internet reads lookup=addresses through the same path on the I219, green on both of round 3's boots. The resolver's decisions are the node's host tests'. Until The T14's outbound rows: its router and the internet, judged from the stick (lands behind the move; do not merge before it) #784 lands behind this change, no test in the tree reaches serve.rs's lookup arm, as none did on main before the move.
  • netstack_socket_churn (changed): reads the node's counts. New parts: a listener and a datagram socket whose owner left with no close are let go; a receive asked before its datagram exists is answered by it, and a second receive on that socket while the first waits is refused ResourceExhausted (a guest because the bound is on handles netstack holds for connections the kernel moved, which no host test of the node holds); the connect past net.places.max is refused ResourceExhausted with every place held. Gone: the count of an ownerless connection after a write into a pipe netstack had let go, which the node refuses at the write; the node's a_departed_clients_… tests hold those rules on a host.
  • libc_sockets (changed): a host peer whose first byte names how it ends a stream, and two jobs on it on the same boot.
    • tests/netcase/stream_ends.c reads recv 0 at the peer's FIN after SHUT_WR, EPIPE for a send after SHUT_WR, ECONNRESET on a reset mid-stream and after SHUT_WR, and recv 0 after SHUT_RD. The C rows have no oracle: they are libc's rule as written, and shut_rd's 0 is BSD's choice, where Linux would answer the bytes in flight first.
    • stream_ends_std reports six ends in std. The harness first runs the same source on its host's TCP and requires that report, line for line: the oracle.
    • The rows are The node keeps the pipe order std reads a stream's end by: a FIN after a shutdown reads as a FIN, a failure as a reset #803's measurement's, less half_close, which netstack_streams holds on both cards.
    • Why a guest: an end is what netstack, the kernel's pipes and the library read together. netstack has no host build. The node's host tests hold the order but not what libc or std make of it. The T14's bench has no peer that resets on cue.
  • The e1000e arm is the one guest in which netstack runs its Intel driver.

Checks, at head 01bb4c8c3

The head is 409a4fa9b, which differs from 01bb4c8c3 by issues/toyos-has-its-own-network-stack.md alone (the link-down record above, and a line that cited the deleted Node::nodelay). At 409a4fa9b, cargo run -- --ci host exits 0: Host: 78 step(s), all green. The table below is 01bb4c8c3's.

origin/main merged at d6298c83e (#800, #803, #802). The machine was at a load of 77 to 86; each log's first line is uptime and its second the head.

check exit
cargo run -- --build-only 0
cargo run -- --ci host 0: Host: 78 step(s), all green
cargo run -- --clippy 0: clippy: 24 invocations clean
cargo test -p toyos-net-node -p toyos-dhcp -p toyos-mdns -p toyos-libc-copies 0: 320 tests
cargo test, the whole guest suite 0: test result: ok. 43 passed, 43 total
netstack_socket_churn, libc_sockets, iommu_virtio_platform, bar_map_again, netstack_streams, netstack_streams_e1000e by name 0: test result: ok. 6 passed, 6 total

01bb4c8c3 differs from 04a46fb74, the head the T14 read, by the three getters and their test reads, the two lookup guests and issues/: git diff --stat 04a46fb74 01bb4c8c3 is 11 files, +30 / −94, none under userland/netstack/src, and the node's three deletions are of functions nothing in netstack called.

Negative control (round 1, at 803dc0d41, the whole shell and the lockfile reverted to origin/main under this branch's tests, a checked patch reversed after):

  • netstack_streams and netstack_streams_e1000e: red, exit 1, on both cards, where the head is green. As landed the job reds at its first part, reading byte 2202108 of the bulk: connection reset (virtio: byte 2231324): the shell on smoltcp drops what the send pipe held at a shutdown, the defect of the closed a-shutdown-of-the-sending-half-… issue. With the job's shutdown removed so that it reaches the listener, also a checked patch: the listener was woken for 1 of two peers in 60s, and two accepts then answered [Ok(52199), Err(NotConnected)], exit 1, on both cards.
  • cargo tree -p netstack lines naming smoltcp or managed: 2 on the base, 0 at the head.

Mutations, each a checked patch, built, run, reversed, tree clean after; the patches are in comments on this pull request (round 1's seven, round 2's three, round 3's three and its control: #801 (comment)).

mutation test result
m1: the node is offered usize::MAX frames, not the card's room netstack_streams, both cards, at 14a1f5441 red on virtio, exit 1: netstack ends a frame was offered to a transmit queue that had said it has no room. Green on the e1000e: QEMU's model finishes a transmit inside the register write, so its ring never fills in a guest; toyos-i219's a_full_transmit_ring_answers_room_0, a_written_back_descriptor_returns_room and a_burst_past_the_ring_leaves_whole_on_the_room_it_is_told hold the Intel driver's room, and the T14 reads the live path
m2: a stream the node let go keeps its table entry netstack_socket_churn red, exit 1: netstack keeps a stream for a connection it let go
m3: a refused watch is logged and not told to the node netstack_socket_churn red, exit 1: net.piped.live is 1 and not 0 20s after the kernel refused the watch
m4: a listener's and a datagram socket's pipes are not watched netstack_socket_churn red, exit 1: net.sockets.listeners is 1 and not 0 20s after a listener's owner dropped its wake pipe
m5: a waiting receive is not tried again netstack_socket_churn red, exit 1: the job stalls at the receive
m6: the node is given twice the places netstack_socket_churn red, exit 1: netstack holds more connections than the 50 places it said it has
m7: a due deadline is not fired netstack_streams, both cards, at 01bb4c8c3 red, exit 1: STALLED: waiting for netstack's lease — it went quiet, 0 passed, 2 failed; tree restored, git status empty (patch: #801 (comment))
m8: the node's DHCP refusals bypass the RefusalLog toyos-net-node --test lease, at 14a1f5441 red, exit 101: a_hundred_refused_replies_are_one_line_and_a_count
m9: a second receive while one waits is not refused netstack_socket_churn, at 14a1f5441 red, exit 1: the job stalls at the second receive, STALLED: 794s of guard expired
m10: libc's recv answers a read of 0 as 0, without asking the send pipe libc_sockets, at 2d0b0aa44 red, exit 1: reset_mid_stream: then: 0, errno 0 <-- WRONG, and the same for reset_after_half_close
m11: libc's shutdown does not mark the sending half libc_sockets, at 2d0b0aa44 red, exit 1: shut_first: a send after it: 1, errno 0 <-- WRONG
m12: libc's recv does not answer 0 after SHUT_RD libc_sockets, at 2d0b0aa44 red, exit 1: shut_rd: then recv: 1 <-- WRONG
control: the node's #803 order reverted, libc as at head libc_sockets, netstack_streams, netstack_streams_e1000e, at 2d0b0aa44 all three red, exit 1: C shut_first: then the peer's FIN: -1 <-- WRONG; both cards the read after the bulk's last byte answered Err(... ConnectionReset ...) and not the stream's end

m10 and m12 as #803's review wrote them do not build under libc's -D warnings: the item each disables would be dead. Each patch touches that item with a let _ and keeps the behaviour named. 2d0b0aa44's tree matches the head in libc, the node and netstack.

The accept refusal's arm is reached by no test: with receives bounded, netstack's handles are bounded by its places, 32 unspoken connections and 16 lookups, far under the kernel's 4,096. Held by reading.

Independent oracles: QEMU's user network (its DHCP server, ARP and resolver), the host kernel's TCP behind it (and, for std's report of each end, that kernel's TCP read by the same source), QEMU's e1000e model; on metal, the T14's router and the internet (round 3's boots, below). Not yet: a link pull on the I219.

The own stack's first real guests

The tracker

Closed this round: a-netstack-client-cannot-tell-a-reset-from-the-peers-fin, by its exit. netstack runs on the node; stream_ends_std reads each end as the host kernel's TCP does; the C case reads all five rows, and each of the three mutations its exit names turns it red. Its two citing issues (a-streams-failure-reaches-its-client-as-a-reset-whatever-it-was, libc-answers-epipe-and-raises-no-sigpipe) and the track's line now point at the code.

The run counts #803's review named:

  • In this issue: "Ok(0) in two runs" and "in each of four runs".
  • In a-shutdown-of-the-sending-half-drops-what-the-send-pipe-still-holds: "65,536 bytes ... in two runs".

One run of each count was round 1's at the base, whose log was lost, so three runs are logged. Both files leave the tree with this branch: the first closes here, and the second closed in round 1, its exit met by netstack_streams. The merge resolutions keep neither count, and #803's measurements stand in its comments.

Closed in earlier rounds, each by its exit: a-dhcp-message-the-client-refuses-is-a-log-line-each (by a_hundred_refused_replies_are_one_line_and_a_count), a-handshake-nobody-finishes-holds-a-listeners-port-shut, a-handshake-reset-before-it-ends-hands-its-option-to-the-next-connection, netstack-passes-every-millisecond-while-a-request-is-pending, netstack-keeps-the-datagram-socket-of-a-client-that-sent-no-close, netstack-removes-a-closed-stream-before-its-fin-leaves, a-lease-kept-across-a-link-flap-is-not-verified-until-its-renewal, a-shutdown-of-the-sending-half-drops-what-the-send-pipe-still-holds, netstack-cuts-a-departed-clients-unsent-tail-at-the-ceiling. netstack-datagram-sockets-and-listeners-have-no-bound is renamed netstacks-places-are-one-number-for-every-client, which is what is left of it. Fifteen more are restated against the code that is there now. New: the-pipe-abi-has-no-word-for-an-unreachable-host-or-a-lookup-to-try-again. The track loses the lines the move closes, keeps the three it carries (the idle-stream line now exits on a guest count of netstack's pipe reads per frame at 1 and 100 idle streams, since 1,000 cannot exist under 103 places and the T14 holds no streams), and its written exit, rg smoltcp empty outside issues/, is met; it now reads that every stage is built and every line closed.

Present weaknesses, each recorded on the track or in its issue

  • A stream its client can see no more has no floor on the rate its peer may take at; the cut is 16 an address.
  • With the link down the DHCP client keeps its timers.
  • The node's places are one number for every client.
  • Every frame ends in a pass over every stream: linear, as the shell on smoltcp was.
  • A receive or a connect that waits is not let go when its client hangs up (issues/netstack-holds-a-pending-request-for-a-client-that-left.md); a receive's is replaced when the next receive on its socket arrives.
  • A stream's failure reaches its client as a reset whatever it was (issues/a-streams-failure-reaches-its-client-as-a-reset-whatever-it-was.md).
  • libc's close of a socket ends the program (issues/libc-close-of-a-socket-ends-the-process.md).
  • A frame is built by the node and copied into the card's slot.

The T14

Round 2's reading is the orchestrator's comment on this pull request: two boots from power-off of ae8adbca7. In both, boot:outbound exited 0 with 2 passed, 0 failed. The lease came 8,172 and 8,397 ms after netstack came up, against 13,337 ms on smoltcp. Both anchors connected, the gateway neighbour read reachable, and descriptors.sent / wire.sent / stranded read 20 / 20 / 0. These boots stand for nothing the node's #803 change touched.

The missing mDNS claim line is a correct absence (#801 (comment)):

  • Each boot powered off 148 ms and 166 ms after its lease, when the rows' job ended and the list ran reboot.
  • A claim cannot come before 750 ms after the lease (RFC 6762 §8.1, asserted by toyos-mdns's own test).
  • The responder started: a refused port panics netstack, and neither log has a panic.

Round 3's reading is the orchestrator's comment on this pull request (#801 (comment)): two boots of boot:outbound from power-off, wt/toyos-move-t14-r3 at 1e2cc9c05, which is 04a46fb74 with #784's rows and two measurement-only steps (the ring: line; after outbound: done, a wait of at most 15 s for netstack's mDNS line). Each image's sha256 matched the request; the judge exited 0 on both, 2 passed, 0 failed.

  • Link up 2,725 and 2,742 ms after the driver; the lease 7,206 and 3,419 ms after netstack came up.
  • The claim line 814 and 914 ms after the lease line, and name: claimed on both: inside RFC 6762 §8.1's 750 ms floor and the 1 s the request named.
  • Both anchors lookup=addresses connect=connected; gateway neighbour reachable; ring: sent equal to wire sent (20/20, 18/18), stranded 0.
  • No panic, no no-lease line, no loss line, no repeating refusal, no refused watch.

The link pull and return is not automated. The I219 is netstack's claim, and the running driver touches no PHY register after bring-up, so a flap needs a writer outside it:

  • BMCR power-down (I219 §9, register 0 bit 11) parks the MAC-PHY interconnect in electrical idle ("S0 and PHY Power Down", I219 Table 2-1, as toyos-i219/src/power.rs reads it), so the MDIC write that would clear it has nothing to carry it.
  • LANPHYPC (CTRL bits 16 and 17) takes the PHY's power away, and only the driver's open sequence (toyos-i219/src/wake.rs, then the MAC and PHY reset together) brings a PHY back within MDIC's reach and configured.
  • A restart of autonegotiation (BMCR bit 9) would drop the link on both ends and bring it back by itself, but it is an MDIC write under §4.5.2's arbitration. Done from netstack or toyos-i219, it is code shipped for a test. Done from a test-actuators kernel, it is a second implementation of toyos-i219's MDIC and arbitration, writing a device the kernel granted to netstack alone. And MDIC on this part has left the T14 needing a power-off by hand (ARBITRATION_PACE_NANOS, toyos-i219/src/phy.rs).

So the pull stays an attended step, the orchestrator's. Until it runs, the track records the node's link-down handling and its INIT-REBOOT as never run on a real card or against a real DHCP server (issues/toyos-has-its-own-network-stack.md), with that boot as the exit: the link-down line, then on return the held lease verified by a REQUEST and its ACK or taken again with no no-address line, then the name's claim line again.

Not measured

  • On metal: the I219's transmit ring filling and its wake (nothing in boot:outbound queues more than 15 frames in one pass, so transmit.full reads 0 there: unread, not red); a link pull and return, which needs a person at the machine (The T14, above).
  • Timing of any kind.
  • udp.tx-queue-full met by a program: no guest job sends sixteen datagrams past a hop that does not answer.
  • The mapping of the old tests to the node's, beyond their names and headers.

🤖 Generated with Claude Code

https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C

Japabu and others added 8 commits October 9, 2026 13:09
… the requests, and smoltcp is gone

`userland/netstack` is now the card, the clock, the kernel's random source,
the kernel's pipes and the clients' connections around `toyos-net-node`:
every frame goes to the node as the card handed it over, a frame leaves only
into room the card said it has, and one request is one call of the node's.

- `main.rs` is the loop. `serve.rs` is the requests onto node calls, the id
  table and `inspect`. `pipes.rs` is the kernel's pipe ends as the node's
  `ToClient`, `FromClient` and `Wake`.
- `dhcp.rs`, `listen.rs`, `mdns.rs`, `resolve.rs` and the two smoltcp test
  harnesses are deleted; `smoltcp` and `managed` leave `Cargo.lock`.
- The node's places come from a memory figure in which a place costs what a
  listener can be made to hold.
- A listener's wake pipe and a datagram socket's receive pipe are watched for
  their owner's leaving; the 1 ms pass while a request was pending is gone.
- `netstack_socket_churn` reads the node's counts. `netstack_streams` and
  `netstack_lookup` are new, on virtio and on QEMU's e1000e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
…he streams job reads its stream end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
…d against the node, and the track says what stage 5 left

Closed, each by its own exit:
- a-handshake-nobody-finishes-holds-a-listeners-port-shut: the node's
  `a_handshake_nobody_finishes_leaves_the_port_open_and_is_given_up`.
- a-handshake-reset-before-it-ends-hands-its-option-to-the-next-connection:
  the one-socket listener left with smoltcp; the node's
  `a_handshake_reset_before_it_ends_leaves_the_next_connection_its_listeners_option`.
- netstack-passes-every-millisecond-while-a-request-is-pending: the loop's
  timeout is the node's next deadline and no 1 ms constant remains.
- netstack-keeps-the-datagram-socket-of-a-client-that-sent-no-close: the wake
  pipe of a listener and the receive pipe of a datagram socket are watched for
  their owner's leaving; `netstack_socket_churn` reads both counts back.
- netstack-removes-a-closed-stream-before-its-fin-leaves: a host peer of
  `netstack_streams` reads its stream's end after the guest's close.
- a-lease-kept-across-a-link-flap-is-not-verified-until-its-renewal:
  `toyos-dhcp` verifies a kept lease by INIT-REBOOT when the link returns, and
  netstack reports the change (`Node::link`).
- a-shutdown-of-the-sending-half-drops-what-the-send-pipe-still-holds: the
  node's `a_shutdown_sends_what_the_pipe_held_and_then_the_fin`, and
  `netstack_streams` shuts down at once and reads every byte back.
- netstack-cuts-a-departed-clients-unsent-tail-at-the-ceiling: the node's
  `a_departed_clients_tail_arrives_whole_while_its_peer_takes_it`.

netstack-datagram-sockets-and-listeners-have-no-bound is renamed to what is
still true of it: the places are one number for every client.

Filed: a DHCP message the client refuses is a log line each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
QEMU's user network queues one connection on a forwarded port and resets a
dial that arrives while one is queued. Under load the harness's two dials,
back to back, met that: 3 of 8 runs of the whole suite red at a host load of
about 30, the second dial ending `Connection reset by peer (os error 54)` and
the frames recorded on the guest's card (`-object filter-dump`) holding one
SYN for the listener's port. The harness now reads QEMU's table of
connections after each dial and dials the next once it shows the last one
carried; 5 of 5 runs of the suite are green at the same load. A failed dial
is the test's first word, since it is why the job's wakes do not come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
The shell that #793 edited is gone: its mdns.rs told the responder its link,
drew the delay of each probing and said what became of the name. On the node
the first two are `name`'s, and netstack hands `Node::answer_as` its draw and
writes the two lines for `Event::Name` as that file wrote them, which
`boot_netcase` now waits on.

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

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

The seven mutation patches of the pull request's body, each against its head. Each was applied with git apply --check and git apply, built and run by cargo test --test toyos-build -- <test>, and reversed with git apply -R, after which git status --porcelain --ignore-submodules=none printed nothing.

m1-credit-past-room

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index d78413b34..422b9a48c 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -388,7 +388,7 @@ fn main() {
                 0 => card.wake_on_room(),
                 room => room,
             };
-            if room == 0 || node.transmit(now, room, |frame| card.tx(frame.len(), |slot| slot.copy_from_slice(frame)), draw) < room {
+            if room == 0 || node.transmit(now, usize::MAX, |frame| card.tx(frame.len(), |slot| slot.copy_from_slice(frame)), draw) < room {
                 break;
             }
         }

m2-no-sweep

diff --git a/userland/netstack/src/serve.rs b/userland/netstack/src/serve.rs
index 28a1f280b..6a9157afc 100644
--- a/userland/netstack/src/serve.rs
+++ b/userland/netstack/src/serve.rs
@@ -692,7 +692,7 @@ impl Sockets {
         // nothing from then.
         let (ids, by_stream) = (&mut self.ids, &mut self.by_stream);
         self.ends.retain(|socket_id, ends| {
-            let held = ends.to_client.held().is_some() || ends.from_client.held().is_some();
+            let held = true;
             if !held {
                 by_stream.remove(&ends.stream);
                 if matches!(ids.get(socket_id), Some(Socket::Stream(_))) {

m3-refused-watch-ignored

diff --git a/userland/netstack/src/serve.rs b/userland/netstack/src/serve.rs
index 28a1f280b..4d81239af 100644
--- a/userland/netstack/src/serve.rs
+++ b/userland/netstack/src/serve.rs
@@ -756,7 +756,6 @@ impl Sockets {
                     _ if !held => {}
                     Err(why) => {
                         say!("netstack: resetting a connection — the kernel refused the watch of its {end:?} pipe: {why:?}");
-                        node.pipe_broken(now, ends.stream, end);
                     }
                     Ok(met) if met & OTHER_END_GONE != 0 => node.pipe_gone(now, ends.stream, end),
                     Ok(_) => node.bridge(now),

m4-owners-not-watched

diff --git a/userland/netstack/src/serve.rs b/userland/netstack/src/serve.rs
index 28a1f280b..96a1a38fa 100644
--- a/userland/netstack/src/serve.rs
+++ b/userland/netstack/src/serve.rs
@@ -726,10 +726,10 @@ impl Sockets {
             match socket {
                 Socket::Listener { wakes, .. } => {
                     if let Some(pipe) = wakes.held() {
-                        poller.watch(&*pipe, OTHER_END_GONE, TOKEN_LISTENER | u64::from(*socket_id));
+                        let _ = (pipe, socket_id, TOKEN_LISTENER);
                     }
                 }
-                Socket::Datagram(socket) => poller.watch(&socket.to_client, OTHER_END_GONE, TOKEN_DATAGRAM | u64::from(*socket_id)),
+                Socket::Datagram(_) => {}
                 Socket::Stream(_) => {}
             }
         }

m5-receive-not-retried

diff --git a/userland/netstack/src/serve.rs b/userland/netstack/src/serve.rs
index 28a1f280b..e295cad5c 100644
--- a/userland/netstack/src/serve.rs
+++ b/userland/netstack/src/serve.rs
@@ -609,11 +609,7 @@ impl Sockets {
     /// the clients that waited for it, and the table entries of what the node
     /// let go.
     pub fn settle(&mut self, node: &mut Node, now: Instant) {
-        for waiting in std::mem::take(&mut self.receiving) {
-            if let Some(waiting) = self.deliver(node, now, waiting) {
-                self.receiving.push(waiting);
-            }
-        }
+        let _ = now;
 
         let events: Vec<StreamEvent> = node.drain_stream_events().collect();
         for event in events {

m6-places-doubled

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index d78413b34..0310e9acf 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -339,7 +339,7 @@ fn main() {
 
     let total_mem = total_memory();
     let places = places_for(total_mem);
-    node.set_places(clock(), places);
+    node.set_places(clock(), places * 2);
     let mut sockets = serve::Sockets::new(draw(), places);
     let mut leases = Leases { began, said: None, settled: false };
 

m7-deadlines-not-fired

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index d78413b34..b33dad75e 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -379,7 +379,7 @@ fn main() {
         while card.rx(|frame| node.receive(clock(), frame, draw)) {}
         let now = clock();
         if node.next_deadline().is_some_and(|at| at <= now) {
-            node.fire(now, draw);
+            let _ = now;
         }
         loop {
             // A card with no room is asked to say when it has some, and a

The negative control's second patch, applied over the shell's revert so that the job reaches its listener on the smoltcp base:

diff --git a/tests/toyos-rust-tests/src/bin/netstack_streams.rs b/tests/toyos-rust-tests/src/bin/netstack_streams.rs
index 0f39ae589..7c9a53c76 100644
--- a/tests/toyos-rust-tests/src/bin/netstack_streams.rs
+++ b/tests/toyos-rust-tests/src/bin/netstack_streams.rs
@@ -53,8 +53,7 @@ fn bulk(port: u16) {
             writing.write_all(&chunk[..len]).unwrap_or_else(|e| panic!("writing byte {sent} of the bulk: {e}"));
             sent += len;
         }
-        // At once: what the pipe still holds is the stack's to send first.
-        writing.shutdown(Shutdown::Write).expect("shutting the sending half");
+        let _ = Shutdown::Write;
     });
     let mut read = 0;
     let mut chunk = [0u8; 8192];

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 1, head 803dc0d41

Net (git diff --shortstat origin/main...803dc0d41): 51 files, +1,894 / −3,869. userland/netstack/src: +1,234 / −3,319. Tests: +443 / −74. issues/: +197 / −428. The production code shrinks, and the shell now holds only the loop, the id table and the mapping of requests. I accept the growth in serve.rs and pipes.rs.

BLOCKER

  • userland/netstack/node/src/streams.rs:262-266,275-276 — a client that half-closes its connection and then reads to the end gets ConnectionReset where its peer sent a FIN. The implementer measured this on both cards, and it is a regression against the server that ships today: shutdown(Write) followed by read_to_end is what every HTTP/1.0-style client does. The move does not land with this recorded. The fix belongs in the node. By the code, the ABI as it stands signals an end by the order in which netstack drops the two pipes. std's ended (sdk/std/sys/net/connection.rs:228-234) reads that order correctly. The node breaks the order: on an orderly end it drops the from-client end as soon as the FIN is queued (:263-264), and again on Closed|TimeWait (:275-276), even when the to-client end was dropped in the same pass. Fixing it in std, by not probing after its own shutdown, would make std read a peer's RST after our FIN as a clean EOF. That hides a loss, which is worse. A reason word on a pipe's close does not exist in the kernel, and nothing here needs one. What the node must do instead: on an orderly end, keep the from-client end (no longer read and no longer watched readable) until the to-client end has been let go after the peer's FIN. Only a failure, a Broken, or a reader that left drops it first. Owed in the fix: a node host test for both orders (client FIN first; both FINs in one pass) that reds without it; a test that a reset is still read as a reset; and netstack_streams's bulk going back to reading the stream's end. The job's module header now steers around the defect, and that line goes with the fix. The fix lands before this branch or inside it. It must not land after.

  • userland/netstack/src/serve.rs:688 with node/src/lib.rs:146 — any host on the link can write one log line per frame, with no limit. It sends DHCP messages the client refuses, and each refusal becomes a line. On smoltcp the shell logged no DHCP refusal at all (the old dhcp.rs had no such line), so this change is what ships a log that a wire peer controls and that is written to the stick. The tree already has the bound this needs: the shard's RefusalLog allows one line per rule in 10 s, with a count of the rest. The client's refusals bypass it. Filing an issue (a-dhcp-message-the-client-refuses-is-a-log-line-each.md) does not make this acceptable, because the move is the change that exposes it. The fix is the node's or toyos-dhcp's and lands before or with this branch, with the issue's own exit test: 100 refused replies give one line and a count.

  • userland/netstack/src/serve.rs:517-520, :613-617; main.rs:453 — a single client can end netstack with requests alone. It sends UdpRecvFrom on its own idle socket, once per connection, as often as it likes. Every receive with nothing to hand over pushes a Receiving that keeps the client's connection handle. Nothing bounds receiving and nothing watches it. When netstack's handle table reaches the kernel's MAX_HANDLES, acceptor.accept().expect("accept failed") panics netstack, and every program loses the network. smoltcp's shell had the same unbounded list (old main.rs:813), but serve.rs is new code written on this trust boundary. The brief's rule is "never panic netstack" and the fix is a few lines:

    • at most one waiting receive per socket, with a second refused ERR_RESOURCE_EXHAUSTED (or a small per-socket bound that places covers);
    • an accept the kernel refuses is logged and the pass goes on.

    Test: the churn job asks a second receive on a socket whose first is waiting, and reads the refusal.

  • Evidence: the capture behind "QEMU's forward reset the second dial" is not on the record. The commit message and the issue rest the diagnosis on frames recorded on the guest's card holding one SYN. No log names the capture or its reading: r2-05-pcap-suite-1 contains no reference to it. What the logs do show (r2-04, r2-05 and r2-06 on the e1000e arm, r2-02 on virtio) is [Ok(port), Err(NotConnected)]: [tcp] held exactly one finished connection. In r2-06 the host's dial ended os error 54. That fits QEMU never sending the second SYN. It also fits the own stack resetting that SYN. Only the guest-side frames tell the two apart. Post the capture's reading for a red run: the SYNs, SYN-ACKs and RSTs on the listener's port, by flags, ports and time only. Also post which call returned error 54 (connect or write_all). If the guest saw one SYN, the harness change fixes the test and does not mask the defect: the second dial still lands before any accept, because the job waits for two wakes. If the guest saw two, the listener resets a connect that arrives while an accept is in flight, and this is a stack defect that blocks the move.

NOTE

  • serve.rs:46,164-166,656, the pipe ABI's three missing words. These are the words owed:

    • An ICMP-ended connect (Failure::Unreachable) is network or host unreachable: std ErrorKind::NetworkUnreachable / HostUnreachable, libc ENETUNREACH / EHOSTUNREACH. Port unreachable is ECONNREFUSED, and Prohibited is EHOSTUNREACH, which is what Linux makes of an administratively filtered ICMP error. Today this answers ERR_OTHER: std Other, libc EIO.
    • A lookup ending Unreachable is network unreachable.
    • A lookup ending LeaseChanged is a try-again failure (getaddrinfo's EAI_AGAIN). Today both lookup cases answer ERR_NOT_CONNECTED: std NotConnected, libc ENOTCONN. That is the wrong family for a lookup.

    The words can follow as their own ABI change, since no caller in the tree branches on any of these today. But this compromise is recorded only in the pull request body. It needs an issues/ file in this diff, with an owner and an exit.

  • issues/toyos-has-its-own-network-stack.md:33 — the line's exit still asks for a T14 measurement at 1,000 idle streams. That cannot exist: places end at 103, and the T14 has no peer to hold streams. Restate it as the plan proposed: a guest count of pipe reads per frame at 1 and 100 idle streams.

  • serve.rs:769 — every ready stream watch calls node.bridge, and each call is a pass over every stream. A wake with k answers costs k × n stream passes. Bridge once per wake.

  • serve.rs:311 — shutdown(Read) is answered by asking node.nodelay(id), which is used as an existence check. A request should map onto one node call that means what it asks. Answer this from the id table, or from a node call that says whether it holds the stream.

  • tests/toyos.rs (dial's while carried() == before) — this spins QMP queries back to back on a loaded host until the 30 s ceiling. Pace the queries with the monitor's own round trip or a bounded wait.

Answers to the brief

  • Ambient ids: unchanged. Ids are still sequential from a random start, and any client can name any id. An id now stays reserved while its stream's ends are held (alloc_id checks ends), so a closed stream's id is not handed out while its pipes still flow. That is slightly better than before. The issue stands as written.
  • Client edges otherwise: answers are sent non-blocking (client.rs). A full client pipe stalls nothing: a stream's bytes stay in [tcp], and a datagram the pipe will not take ends that client's own socket. Pending connections are bounded at 32 and time out. Lookups are bounded at 16 and watched. Connects are bounded by the places. Every poller registration fits MAX_PLACES: 2 + 2·103 + 32 + 16 = 256. Client lengths bound reads and never allocations past a u16 or MAX_PAYLOAD. The one hole is the receiving blocker above.
  • The loop's waits: the transmit loop honours the room-and-wake contract of Both NIC drivers say their transmit room and wake netstack when it returns; a link change leaves the ring the part's #782. The wait is bounded by the node's next deadline, the lease report, the card's pass deadline and the pending connections. A spurious wake costs one pass. No flat wait remains.
  • Deleted tests: the table is accepted by reading. The lines kept byte for byte match the old dhcp.rs lines 139-140 and 178.
  • m1's e1000e arm: acceptable unmeasured in a guest. The mutated line is shell code both cards share, and the virtio arm reds it. The Intel driver's room is held by toyos-i219's a_full_transmit_ring_answers_room_0, a_written_back_descriptor_returns_room and a_burst_past_the_ring_leaves_whole_on_the_room_it_is_told. The T14 reads the live path (below).
  • Negative control and mutations: read from their logs. All are red with exit 1 as claimed, except m1's stated e1000e arm.
  • Track exit: I accept the replacement ("every stage built, every line closed or an issue of its own") once the 1,000-stream line is restated.

Owed after #796 and #797 merge

CI at the head that lands

host, toolchain / build and guest / suite each concluded success. Skipped is not success: all three read SKIPPED at this head, as a draft. The orchestrator may land on those three checks and the T14 reading, provided the T14 image's userland/netstack/, the node, toyos-dhcp, toyos-i219 and the virtio driver are byte-identical to the landing head. The node fixes owed above change the node, so the boot staged at 77df4c6bf does not qualify, and a new boot is owed after them.

The T14: what it must show to land (against the smoltcp "before")

  • The card is handed over and the link comes up. A lease line follows, and the no-lease line does not appear.
  • The lease's distance from link-up is recorded, and the outbound rows read it, for issues/most-t14-leases-land-one-dhcp-retry-late.md. It does not gate the landing. A distance at or above the old ~13 s means that issue's cause is not smoltcp's, and the issue is restated.
  • After the lease: netstack: mDNS: no host answered for toyos-t14.local; this machine answers as it. This is toyos-mdns claims its name before it uses it: three probes, the tie-break, a conflict's outcome, RFC 6762 §8.1's bound on what a peer's messages cost, and the link's return in both callers #793's owed reading.
  • The router row is green, with neighbour.router reading reachable, stale, delay or probe. The internet row has both anchors connected, as before.
  • Ring counters, from the card's inspect keys after the anchors' connects: transmit.full ≥ 1, wake_armed ≥ 1, wake_taken ≥ 1, descriptors.sent == wire.sent, and descriptors.stranded 0. full and wake_taken both at least 1 is the e1000e-green mutation arm read on the real card.
  • Sends it back: any netstack panic, including a frame was offered to a transmit queue that had said it has no room; the no-lease line; mDNS: another host answered; a netstack: refused or DHCP: refused line repeating; resetting a connection — the kernel refused the watch; neighbour.router reading none, incomplete, unreachable or failed; a router row that is red; full ≥ 1 with wake_taken 0, which is a ring that stalls on metal; or sent != wire.sent, or stranded > 0. If only one anchor connects, run the boot again before blaming the stack.
  • One boot is not enough to replace the stack on metal. Owed: two boots from power-off on the same image, because the earlier leases split 11 to 5 between on time and late. One link pull and return in either boot is also owed: link-down and INIT-REBOOT have never run on a real card, and the node's timers keep running while the link is down. No soak is owed for landing; the track's soak stage holds it.

SEND BACK

Japabu and others added 4 commits October 9, 2026 14:43
…dary (#796) and the NVMe install (#797), into the move

Conflicts, every hunk accounted for:
- Cargo.lock: netstack's dependencies take both sides, toyos-dhcp and the
  stack's crates from the move, toyos-device-memory and toyos-virtio from
  main; smoltcp stays gone.
- tests/common/qemu.rs: both new profiles kept, HeadlessE1000e beside
  HeadlessNoUsb, in the enum, the architecture match and the shape table.
- userland/netstack/src/virtio_net.rs: main's file, which moved the queue
  and its tests to toyos-virtio and deleted the test comment the move had
  reworded; the move's one remaining hunk, "smoltcp" to "the stack" in the
  transmit queue's comment, is applied again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…a rule in any 10 s, with a count of the rest

Any host on the link could send replies the client refuses, one a frame,
and each became a line of a log logkeeper keeps on the stick: the node
handed every refusal `toyos-dhcp` queued straight to netstack as
`Event::Dhcp`. The shard already bounds its crates' refusals with each
crate's `RefusalLog` (toyos-net-wire's `counters!`), and `toyos-dhcp`
declares its counters with the same macro, so it has one: the node now
holds it and admits each of the client's refusals through it where it
drains the stack's (`Node::log`), and `Event::Dhcp` carries the count of
the rule's refusals since its last line, which netstack writes as the
stack's lines are written.

`a_hundred_refused_replies_are_one_line_and_a_count` (node, lease) is the
issue's exit test: a hundred BOOTP replies give one line and a counter of
100, and one more after 10 s gives a line carrying 99.

Closes issues/a-dhcp-message-the-client-refuses-is-a-log-line-each.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
… is named and passed over, and a wake bridges the streams once

A client could end netstack with requests alone: every UdpRecvFrom on an
idle socket kept its connection's handle in `receiving`, which nothing
bounded, and once netstack's handle table was full the next
`acceptor.accept().expect(..)` panicked it, and every program lost the
network.

- `receiving` is a map from socket id to its one waiting receive. A
  second receive while the first waits is refused
  `ERR_RESOURCE_EXHAUSTED`; a waiter that hung up is replaced, so a client
  that left does not shut its socket's receives out. A client's requests
  now hold no more of netstack's handles than its sockets do.
- An accept the kernel refuses has already taken the connection off the
  port's queue and told its client (`sys_accept`), so netstack names the
  first refusal of a run, says how many followed when an accept succeeds
  again, and goes on.
- `netstack_socket_churn` asks a second receive on the socket whose first
  waits and reads `ResourceExhausted`.

Swept once more for anything a client or the wire reaches in `serve.rs`,
`pipes.rs`, `main.rs` and `client.rs`: every index is bounded by its
producer (`FrameRx`'s kept length, [udp]'s cut to the buffer), every cast
fits, and each `unreachable!` is the node's contract, not a client's
choice.

Two NOTEs of the review:
- A ready stream watch no longer calls `node.bridge`: it marks the wake,
  and `Sockets::bridge`, after the poller's answers, passes the streams
  once however many answers the wake carried.
- `shutdown(Read)` is answered from the id table, which already says the
  id names a stream; it no longer asks `node.nodelay` as an existence
  check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…eam line is a guest count, and the harness backs off between its QMP queries

- `issues/the-pipe-abi-has-no-word-for-an-unreachable-host-or-a-lookup-to-try-again.md`:
  an ICMP-ended connect, a lookup ending `Unreachable` and one ending
  `LeaseChanged`, each with the word owed, owner the pipe ABI, exit an ABI
  change after the move. `serve.rs` cites it where it answers them.
- The track's line on a pass over every stream exited on a T14
  measurement at 1,000 idle streams, which cannot exist: the places end at
  103 and the bench holds no streams. Its exit is now a guest count of
  netstack's pipe reads per frame at 1 and at 100 idle streams.
- `netstack_streams`'s dial asked QEMU's `info usernet` back to back
  until the forward carried its peer; it now waits 1 ms after each answer,
  doubling to 64 ms, under the same 30 s ceiling. QEMU raises no event for
  a carried connection.

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

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

BLOCKER 4: the guest's frames for a red netstack_streams

Reading: the guest saw one SYN. QEMU never handed the guest the second dial; the own stack sent no reset. The harness change stands.

How. A temporary, unregistered patch at 14a1f5441 (below, reversed after, tree clean): QEMU's -object filter-dump,id=dump0,netdev=net0,file=… on the guest NIC of every netcase boot, and netstack_streams back to its round-0 dials, both peers back to back with no wait on QEMU's table, with connect and write_all timed and reported apart. The whole guest suite was run under TOYOS_NETDUMP until a streams test went red: suite 1 green (load 42.65), suite 2 red on virtio (load 44.44), EXIT=1, test result: FAILED. 41 passed, 1 failed, 0 invalidated, 42 total.

Which call returned the error. Both connects returned Ok on the host within 0.06 ms of each other: QEMU's forward accepts on the host before it offers the guest anything. The second peer's write_all, 0.05 ms after its connect, returned Broken pipe (os error 32): QEMU had already closed its host end. (In round 1 the same dial surfaced as os error 54 on macOS, from the same ? chain; this run says the call: write_all.) The job: the listener was woken for 1 of two peers in 60s, and two accepts then answered [Ok(<first peer's port>), Err(NotConnected)].

The guest's frames on the listener's port, red run (virtio). Times from the first segment; ports are the host peers' ephemeral ports as QEMU forwards them; no address.

time direction ports flags payload
0.000 ms to guest 61088 → 7010 SYN 0
2.314 ms from guest 7010 → 61088 SYN, ACK 0
2.324 ms to guest 61088 → 7010 ACK 0
2.342 ms to guest 61088 → 7010 PSH, ACK 1
45.548 ms from guest 7010 → 61088 ACK 0
59934.070 ms from guest 7010 → 61088 FIN, ACK 0
59934.106 ms to guest 61088 → 7010 ACK 0
59967.223 ms to guest 61088 → 7010 FIN, ACK 0

The second peer's port, 61089, is in no frame of the capture (0 segments), and no frame from the guest carries RST. The FIN at 59.9 s is the job's own end after its 60 s wait.

The same capture on the three green runs (suite 1 virtio, suite 1 e1000e, suite 2 e1000e): two SYNs 0.067 to 0.089 ms apart, two SYN-ACKs, both peers' bytes, each answered and closed by the guest. For example suite 1 virtio:

time direction ports flags payload
0.000 ms to guest 60878 → 7010 SYN 0
0.067 ms to guest 60879 → 7010 SYN 0
1.795 ms from guest 7010 → 60878 SYN, ACK 0
1.861 ms from guest 7010 → 60879 SYN, ACK 0
11.173 ms from guest 7010 → 60878 PSH, ACK 1
22.063 ms from guest 7010 → 60879 PSH, ACK 1

So the listener queues a second connect that arrives while the first waits for its accept, whenever QEMU delivers one, and the red is QEMU's forward dropping the second host connection before it reached the guest. The harness's wait for QEMU's table to show the first carried, which round 2 now paces with a back-off, keeps the second dial from meeting that.

The frames were read with a 40-line reader of the pcap format that prints flags, ports, payload length and relative time per TCP segment to or from one port, and nothing else.

The temporary patch, applied with git apply at 14a1f5441 and reversed with git apply -R
diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs
index d0643a124..cbb5359f1 100644
--- a/tests/common/qemu.rs
+++ b/tests/common/qemu.rs
@@ -2179,6 +2179,14 @@ fn qemu_command(
             qemu.arg("-netdev").arg("user,id=net0").arg("-device").arg("e1000e,netdev=net0");
         }
     }
+    if !matches!(shape.nic, Nic::Absent) {
+        if let Ok(dir) = std::env::var("TOYOS_NETDUMP") {
+            static DUMPS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
+            let n = DUMPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+            let nic = if matches!(shape.nic, Nic::E1000e) { "e1000e" } else { "virtio" };
+            qemu.arg("-object").arg(format!("filter-dump,id=dump0,netdev=net0,file={dir}/{}-{n}-{nic}.pcap", std::process::id()));
+        }
+    }
     if shape.virtio.present() {
         if shape.virtio.sound() {
             // No guest test plays audio: the device is here as a DMA master and
diff --git a/tests/toyos.rs b/tests/toyos.rs
index bfd17d612..b14cf43e3 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -3012,52 +3012,32 @@ fn netstack_streams(profile: qemu::Profile) -> Result<(), String> {
     let options = BootOptions { qmp: true, profile, ..Default::default() };
     let mut qemu = boot_netcase(&[], &[(JOB.to_string(), bin)], options)?;
     let [to_listener] = forwards_into(&qemu, [LISTENER])?;
-    let mut monitor = qemu::QmpMonitor::open(qemu.qmp_socket());
-    // The connections QEMU's user network has taken off the forwarded port
-    // and is carrying to the guest's listener: every row of its table that
-    // names the guest's port, but the forward's own.
-    let mut carried = move || {
-        let table = monitor.human("info usernet");
-        table
-            .lines()
-            .map(|row| row.split_whitespace().collect::<Vec<_>>())
-            .filter(|row| row.first().is_some_and(|kind| kind.starts_with("TCP[") && *kind != "TCP[HOST_FORWARD]"))
-            .filter(|row| [3, 5].iter().any(|&at| row.get(at) == Some(&LISTENER.to_string().as_str())))
-            .count()
-    };
-    // Each peer says its own byte as soon as it has dialled, and the next
-    // dials once QEMU carries this one: its forward queues one connection,
-    // and resets a dial that arrives while one is queued.
-    let mut dial = |said: u8| -> Result<std::net::TcpStream, String> {
-        let before = carried();
-        let mut peer = std::net::TcpStream::connect(("127.0.0.1", to_listener)).map_err(|e| e.to_string())?;
-        peer.write_all(&[said]).map_err(|e| e.to_string())?;
-        peer.set_read_timeout(Some(ANSWERED)).map_err(|e| e.to_string())?;
-        let dialled = Instant::now();
-        // QEMU says nothing when it carries a connection, so its table is
-        // asked again after a wait that doubles to 64 ms.
-        let mut pause = Duration::from_millis(1);
-        while carried() == before {
-            if dialled.elapsed() > ANSWERED {
-                return Err(format!("QEMU's user network took no connection off its forward in {ANSWERED:?}"));
-            }
-            thread::sleep(pause);
-            pause = (pause * 2).min(Duration::from_millis(64));
-        }
-        Ok(peer)
-    };
+    let t0 = Instant::now();
+    let mut log = String::new();
     let mut peers = Vec::new();
     let result =
         qemu.run_test_paced(&format!("test_rs_{JOB} {port} {LISTENER}"), Duration::from_secs(240), |_, line| {
             if line.trim_end().ends_with(WAITS) {
-                peers.extend(b"12".iter().map(|&said| dial(said).map(|peer| (said, peer))));
+                for &said in b"12" {
+                    let at = t0.elapsed();
+                    let dialled = std::net::TcpStream::connect(("127.0.0.1", to_listener));
+                    let after = t0.elapsed();
+                    match dialled {
+                        Err(e) => { log += &format!("[dial {} connect at {at:?}..{after:?}: Err {e}] ", said as char); peers.push(Err(format!("connect: {e}"))); }
+                        Ok(mut peer) => {
+                            let wrote = peer.write_all(&[said]);
+                            log += &format!("[dial {} connect Ok at {at:?}..{after:?} local port {:?}; write_all at {:?}: {wrote:?}] ", said as char, peer.local_addr().map(|a| a.port()), t0.elapsed());
+                            match wrote { Err(e) => peers.push(Err(format!("write_all: {e}"))), Ok(()) => { peer.set_read_timeout(Some(ANSWERED)).unwrap(); peers.push(Ok((said, peer))) } }
+                        }
+                    }
+                }
             }
         });
-    // A dial that failed first: it is why the job's wakes did not come.
+    eprintln!("NETDUMP-DIALS {}: {log}", matches!(profile, qemu::Profile::HeadlessE1000e));
     let peers: Vec<_> = peers.into_iter().collect::<Result<_, _>>().map_err(|e| {
-        format!("the host could not dial the guest's listener: {e}\nthe job said:\n{}", result.stdout)
+        format!("DIALS {log}\nthe host could not dial the guest's listener: {e}\nthe job said:\n{}", result.stdout)
     })?;
-    job_ok(JOB, &result)?;
+    job_ok(JOB, &result).map_err(|e| format!("DIALS {log}\n{e}"))?;
     if peers.len() != 2 {
         return Err(format!("the job never said its listener waits:\n{}", result.stdout));
     }

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

The round-2 mutation patches, each against 14a1f5441: applied with git apply --check and git apply, built and run, reversed with git apply -R, after which git status --porcelain --ignore-submodules=none printed nothing (each log's last line, RESTORED status: []).

m8-dhcp-refusals-unbounded

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index 8f673c3aa..430348842 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -238,7 +238,7 @@ impl Node {
         let events = &mut self.events;
         self.stack.refusals(|refusal, suppressed| events.push(Event::Stack { refusal, suppressed }));
         for refusal in self.client.drain_refusals() {
-            if let Some(suppressed) = self.dhcp_log.admit(now, refusal.rule) {
+            if let Some(suppressed) = Some(0) {
                 events.push(Event::Dhcp { refusal, suppressed });
             }
         }

m9-second-receive-not-refused

diff --git a/userland/netstack/src/serve.rs b/userland/netstack/src/serve.rs
index 007ff58ad..7afef99a9 100644
--- a/userland/netstack/src/serve.rs
+++ b/userland/netstack/src/serve.rs
@@ -524,13 +524,6 @@ impl Sockets {
             return;
         };
         // A waiting client that hung up holds the socket's one wait for nobody.
-        if let Some(waiting) = self.receiving.remove(&req.socket_id) {
-            if !waiting.client.gone() {
-                self.receiving.insert(req.socket_id, waiting);
-                msg.client.error(ERR_RESOURCE_EXHAUSTED);
-                return;
-            }
-        }
         let waiting = Receiving { client: msg.client, max_len: req.max_len };
         if let Some(waiting) = self.deliver(node, now, req.socket_id, waiting) {
             self.receiving.insert(req.socket_id, waiting);

m1-credit-past-room

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index 619aa93f2..cac9f4d0f 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -391,7 +391,7 @@ fn main() {
                 0 => card.wake_on_room(),
                 room => room,
             };
-            if room == 0 || node.transmit(now, room, |frame| card.tx(frame.len(), |slot| slot.copy_from_slice(frame)), draw) < room {
+            if room == 0 || node.transmit(now, usize::MAX, |frame| card.tx(frame.len(), |slot| slot.copy_from_slice(frame)), draw) < room {
                 break;
             }
         }

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2, head 14a1f5441: the review of 803dc0d41, finding by finding

origin/main is merged at 558283168 (#797), which carries #796. #800 and the half-close fix had not landed when this round's gates ran.

BLOCKERs

  1. Half-close read as a reset (node/src/streams.rs): open, not fixed here. The orchestrator has it on another branch (wt/toyos-finreset), to land on main before this one. When it lands, this branch merges main and netstack_streams goes back to reading the stream's end; until then the job reads its byte count, as in round 1, and the T14 boots staged this round are taken again at the head that carries the fix.
  2. The DHCP refusal flood: fixed in 6e181c19a. toyos-dhcp declares its counters with toyos-net-wire's counters!, so it already has the shard's RefusalLog. The node now holds one and admits each of the client's refusals through it where it drains the stack's (Node::log). Event::Dhcp is { refusal, suppressed }, and netstack writes it the way it writes the stack's lines. The exit test is a_hundred_refused_replies_are_one_line_and_a_count (node/tests/lease.rs): 100 BOOTP replies give one line and a counter of 100, and one more after 10 s gives a line carrying 99. Mutation m8 (the admit replaced by Some(0)) reds it: test result: FAILED. 20 passed; 1 failed, EXIT=101. The issue is deleted, and the node's module header states the bound.
  3. One client could end netstack: fixed in a0b6f86c7.
    • receiving is now a map from socket id to its one waiting receive. A second receive while the first waits is refused ERR_RESOURCE_EXHAUSTED. A waiter whose client hung up (Client::gone) is replaced rather than refused, so a client that left does not shut its socket's receives out.
    • acceptor.accept().expect(..) is gone. A refusal has already taken the connection off the port's queue and told its client (sys_accept drops both halves on a failed install). netstack logs the first refusal of a run, says how many followed when an accept succeeds again, and goes on.
    • netstack_socket_churn asks a second receive on the socket whose first waits, and reads ResourceExhausted. Mutation m9 (the refusal deleted) reds it: the job stalls at the second receive, STALLED: 794s of guard expired, EXIT=1.
    • The accept arm is not reached by any test. With receives bounded, netstack's handles are bounded by its places, 32 unspoken connections and 16 lookups, far under the kernel's 4,096. It is held by reading.
    • The sweep of serve.rs, pipes.rs, main.rs and client.rs for anything a client or the wire reaches found the two above and nothing else:
      • deliver's payload[..datagram.len] is bounded by [udp]'s recv, which cuts to out.len().
      • len as u16 fits under MAX_PAYLOAD.
      • Request::payload's and main.rs's [..payload_len] are bounded by FrameRx's kept length (MAX_KEPT_REQUEST).
      • answer_lookup caps at MAX_ANSWERED before it casts.
      • The pending tokens add a u32 handle to 0x1_0000 in a u64.
      • alloc_id cannot run out while places bound the table.
      • The five unreachable!s are the node's contract, not a client's choice: a stream made of an accept that moved no pipes, a refused close of a datagram socket the table holds, a connect or a lookup answered that nobody waits for, and a stream the node holds whose ends netstack does not.
      • The panic!s in main.rs are the kernel's random source, the card's address, constants, and the snapshot's encoding of netstack's own counts.
  4. The "QEMU's forward" diagnosis: evidence posted in The shipped netstack runs ToyOS's own network stack and smoltcp leaves the tree; libc reads a stream's end as std does #801 (comment).
    • The method: filter-dump on the guest NIC, and the round-0 back-to-back dials restored by a temporary patch (posted there, reversed, tree clean).
    • The red came in the second suite run, on virtio, at a load of 44.
    • The guest saw one SYN. The second peer's port is in no frame of the capture, and no frame from the guest carries RST.
    • Both host connects returned Ok. The second peer's write_all returned Broken pipe (os error 32), 0.05 ms after its connect: QEMU had already dropped its host end.
    • Three green runs captured the same way show two SYNs 0.07 to 0.09 ms apart, both answered.
    • So the listener does not reset a connect that arrives while an accept is in flight, and the harness change stands.

NOTEs

  • The pipe ABI's three missing words: filed as issues/the-pipe-abi-has-no-word-for-an-unreachable-host-or-a-lookup-to-try-again.md, with the review's mapping. Its owner is the pipe ABI, and its exit is an ABI change after the move. serve.rs cites it.
  • The track's 1,000-idle-streams line is restated. Its exit is now a guest count of netstack's pipe reads per frame beside one bulk stream, at 1 and at 100 idle streams, closing when the two are equal.
  • node.bridge per ready answer: a ready stream watch now marks the wake. Sockets::bridge, called once after the poller's answers, passes the streams once.
  • shutdown(Read) is answered from the id table, which already says the id names a stream. Node::nodelay is now called only by the node's own tests. Deleting it touches streams.rs, which the half-close fix is changing, so it goes in the merge that brings that fix.
  • The harness's QMP loop waits 1 ms after each answer, doubling to 64 ms, under the same 30 s ceiling. QEMU raises no event when it carries a connection.

Gates at 14a1f5441

All are under the brief's logs/ as r2-fix-*. Each log's first line is uptime, and the host load was 25 to 44.

check exit
cargo run -- --build-only 0
cargo run -- --ci host 0: Host: 78 step(s), all green
cargo run -- --clippy 0: clippy: 24 invocations clean
cargo test -p toyos-net-node -p toyos-dhcp 0
cargo test, the whole guest suite, load 26.24 0: 42 passed, 42 total
the eight netstack tests by name 0: 8 passed, 8 total
m8, node --test lease red, 101
m9, netstack_socket_churn red, 1 (stall at the second receive)
m1, netstack_streams red on virtio, 1: a frame was offered to a transmit queue that had said it has no room; the e1000e arm green, as in round 1

The T14

Two boots of boot:outbound are staged, not run. They are built from wt/toyos-move-t14-r2 (ae8adbca7: this head, the rows merged, and one measurement-only line printing the ring counts). Its netstack, node, dhcp, i219, virtio and stack crates are byte-identical to 14a1f5441.

Two things the request says outright:

  • No line of the rows' job carried transmit.full and the other ring counts, so the measurement branch adds one.
  • Nothing in that boot queues more than 15 frames in one pass, so transmit.full is expected to read 0. By its issue's exit, that is unread rather than red, and it needs a boot that fills the ring.

The link pull is on the orchestrator's attended list.

@Japabu Japabu changed the title netstack runs ToyOS's own stack and smoltcp is deleted: the shell is the card, the clock, the kernel's pipes and the clients' requests around toyos-net-node The shipped netstack runs ToyOS's own network stack, and smoltcp is deleted Oct 9, 2026
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

The T14 on ToyOS's own network stack, end to end (the orchestrator's reading). Two boots from power-off of the two images staged from the measurement branch wt/toyos-move-t14-r2 at ae8adbca7 (this pull request's 14a1f5441 with the outbound rows merged; netstack, the node, dhcp, the I219 and virtio drivers and the stack crates byte-identical to 14a1f5441). Worktree clean before and after; each image's sha256 checked against the request in the command that flashed it; each toyos-metal exit 0; each judged with --metal --metal-readback <dir> boot:outbound: exit 0, 2 passed, 0 failed (outbound_router, outbound_internet), both boots.

before, smoltcp shell (#782) boot 1 boot 2
link up after the driver came up 2721 ms 2746 ms 2749 ms
lease after netstack came up 13337 ms 8172 ms 8397 ms
both anchors connected connected connected
gateway neighbour not asked reachable reachable
descriptors.sent / wire.sent / stranded 73 / 73 / 0 20 / 20 / 0 20 / 20 / 0
  • No netstack panic, no no-lease line, no repeating refusal line, no refused watch.
  • Not read: transmit.full, wake_armed, wake_taken are 0 on both boots: this job queues no burst, so the ring never filled (unread, not red), as the round said it would.
  • Missing: the mDNS claim line. Neither boot's kernel log carries a netstack: mDNS: line at all, after the lease or anywhere. On the guests boot_netcase waits on that line and passes. Whether this image names no host, the job ends the boot before the claim, or the name is never claimed on the T14 is not determined from these logs; the next round explains it.
  • The link pull and return is on the orchestrator's attended list.

So the T14 reached the internet with only ToyOS's own code from the Intel driver up, and took its lease about 5 s sooner than on the old stack. This reading stands for nothing the next rounds change (BLOCKER 1's merge changes the node), and is taken again at the head that lands.

Japabu and others added 6 commits October 9, 2026 16:33
… move

The node now keeps a send pipe whose FIN is queued until its writer leaves and
lets a failed stream's send pipe go first, so std reads a peer's FIN after the
client's shutdown as the end. The code merges clean.

Issues: `a-shutdown-of-the-sending-half-drops-what-the-send-pipe-still-holds`
stays deleted, its exit met by `netstack_streams` (every byte of 4 MiB read
back after a shutdown at once); #803's hunk to it was its measured evidence on
smoltcp's shell, which leaves with the shell. `a-netstack-client-cannot-tell-
a-reset-from-the-peers-fin` takes #803's text; the track keeps the move's
idle-stream line and takes #803's pipe-order line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
… IORT decode with the SMMUv3 and ITS encodings (#798) and the shipping members' rows (#799), into the move

No conflict; netstack draws from `toyos_abi::syscall::random`, whose source
#802 changes beneath the call and not the call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…s the pipe order

With #803's order merged, a client that shut its sending half down keeps the
send pipe's reader until it lets the pipe go, so the read after the bulk's
last byte meets the server's FIN as the end, which the job asserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…reads every end in C and in std against a host peer

#803's libc half, as posted on that pull request (comment 6082081208), applied
unchanged: `recv`'s read of 0 asks the send pipe with a zero-byte write and
answers `ECONNRESET` where its reader is gone; `send` after `SHUT_WR` answers
`EPIPE` before reaching the pipe; `recv` after `SHUT_RD` answers 0; a refused
write is `ECONNRESET` or `EAGAIN`. Its rule is `streamend.rs`, host-tested by
`toyos-libc-copies` (48 tests).

`libc_sockets` gains a host peer whose first byte names how it ends a stream,
and two jobs on it: `tests/netcase/stream_ends.c` reads recv 0 at the peer's
FIN after SHUT_WR, EPIPE for a send after it, ECONNRESET on a reset
mid-stream and after SHUT_WR, and recv 0 after SHUT_RD; `stream_ends_std`
reports six ends in std, and the harness first runs the same source on its
host's TCP and requires that report. The rows are #803's measurement's, less
`half_close`, which `netstack_streams` holds on both cards. The C job closes no
socket: libc's close of one ends the program, already filed.

`a-netstack-client-cannot-tell-a-reset-from-the-peers-fin` is closed by its
exit: netstack runs on the node, the std job reads each end as the host does,
and the C case reads all five rows. Its two citing issues and the track's
line now point at the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
… stream-end order (#803), into the move

#803 is the branch merged earlier here, now on main; nothing of it conflicts.

#800 changed `userland/netstack/src/mdns.rs`, which the move deletes with the
shell on smoltcp. Its two hunks are carried where the move writes the name's
events, `userland/netstack/src/serve.rs`: the loss line now says the name is
asked for again every `toyos_mdns::RETRY_MS / 1000` s, byte for byte the text
#800 wrote. The track's mDNS line takes #800's record (the owner's ruling and
what was built) under the move's wording of where the responder runs and
where `HOSTNAME` lives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…y's collapsible_match asks

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

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Round 3: why round 2's T14 boots carry no mDNS claim line

A correct absence: each boot powered off before the name could be claimed. No defect.

What the readbacks show. The lines are from the two readbacks at ae8adbca7. The lease's address, server, gateway and resolvers are elided, and the wall-clock prefix is dropped:

== boot-1
[25.806 netstack] netstack: DHCP: lease <address, server, gateway and resolvers elided>, 8172 ms after netstack came up
[25.951 test-runner] ===TEST_END test_rs_outbound exit=0===
[25.954 supervisor] supervisor: power: the machine stops, and logkeeper makes the log whole first (Reboot)
mDNS lines: 0
panic lines: 0
== boot-2
[26.125 netstack] netstack: DHCP: lease <address, server, gateway and resolvers elided>, 8397 ms after netstack came up
[26.288 test-runner] ===TEST_END test_rs_outbound exit=0===
[26.291 supervisor] supervisor: power: the machine stops, and logkeeper makes the log whole first (Reboot)
mDNS lines: 0
panic lines: 0

The rows' job waits for the lease, reads its anchors and ends. The image's list then runs reboot. The machine stops 148 ms after the lease on boot 1 and 166 ms after it on boot 2.

Why the claim needs longer. A claim cannot exist before 750 ms after a lease on a link that is up:

  • RFC 6762 §8.1 asks for a drawn delay of 0 to 250 ms, then three probes 250 ms apart, then 250 ms after the third.
  • toyos-mdns's rfc6762_8_1_three_probes_250_ms_apart_follow_the_drawn_delay_and_the_name_is_announced_250_ms_after_the_third asserts exactly this. With a delay of 0 it reads [] for said() at 749 ms and Claimed only after.

Why it is not a silent failure. The responder did start:

  • netstack calls Node::answer_as at start-up and panics if the node refuses the multicast DNS port (userland/netstack/src/main.rs): a new node refused the multicast DNS port. Neither log has a panic line.
  • The node logs nothing at any level before the claim. Its only name events are Claimed and Lost, and netstack writes each as a line.
  • So nothing was dropped by a log level, and no line was owed in the 148 or 166 ms the boot lasted.

What round 3 stages. The measurement branch wt/toyos-move-t14-r3 (1e2cc9c05) adds one measurement-only step to the outbound job, after its outbound: done line:

  • It waits at most 15 s on the served log for netstack's netstack: mDNS: line.
  • It then says name: claimed, name: lost or name: none within 15 s, on a line the rows do not read.
  • With that wait, each boot is expected to show netstack: mDNS: no host answered for toyos-t14.local; this machine answers as it 750 ms to 1 s after the lease line, followed by name: claimed.

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Round 3: the libc half's three named mutations and the node order's negative control

All four were run at 2d0b0aa44, which is this round's libc and test commit. That commit's tree matches the head 04a46fb74 in libc, the node and netstack.

The method was the same for each patch:

  • git apply --check, then git apply;
  • cargo test --test toyos-build -- <tests> --nocapture;
  • git apply -R, after which git status --porcelain --ignore-submodules=none printed nothing (each log's last line is RESTORED status: []).

They ran one at a time, at a host load of 48 to 101 (each log's first line is uptime).

The review on #803 named three mutations. Two of them, as written there, do not build: libc builds with -D warnings, and deleting the probe leaves read_end unused, while deleting the SHUT_WR mark leaves SHUT_WR unused. So each patch below touches the item it disables with a let _, and keeps the behaviour the review named. The first runs of the patches as written ended toyos-libc build failed, and they are not counted here.

patch test result
m10: recv's probe of the send pipe replaced by a plain 0 libc_sockets red, exit 1: reset_mid_stream: then: 0, errno 0 <-- WRONG, reset_after_half_close: then: 0, errno 0 <-- WRONG
m11: shutdown no longer marks the sending half libc_sockets red, exit 1: shut_first: a send after it: 1, errno 0 <-- WRONG
m12: recv no longer answers 0 after SHUT_RD libc_sockets red, exit 1: shut_rd: then recv: 1 <-- WRONG
control: the node's #803 order reverted (git diff 083d490f3 083d490f3^1 -- userland/netstack/node/src/streams.rs), libc as at head libc_sockets, netstack_streams, netstack_streams_e1000e all three red, exit 1. C: shut_first: then the peer's FIN: -1 <-- WRONG. Both cards: the read after the bulk's last byte answered Err(Error { kind: ConnectionReset, message: "connection reset" }) and not the stream's end

m10-recv-end-unprobed

diff --git a/userland/libc/src/socket.rs b/userland/libc/src/socket.rs
index 0992bf218..8cd10bc27 100644
--- a/userland/libc/src/socket.rs
+++ b/userland/libc/src/socket.rs
@@ -397,7 +397,7 @@ pub unsafe extern "C" fn recv(fd: i32, buf: *mut u8, len: usize, _flags: i32) ->
             }
             let data = core::slice::from_raw_parts_mut(buf, len);
             let read = syscall::read(RawHandle(entry.rx_fd as u32), data).map_err(|_| Refusal::Other).and_then(|n| match n {
-                0 => streamend::read_end(syscall::write_nonblock(RawHandle(entry.tx_fd as u32), &[])).map(|()| 0),
+                0 => { let _ = streamend::read_end; Ok(0) }
                 n => Ok(n),
             });
             match read {

m11-write-shut-unmarked

diff --git a/userland/libc/src/socket.rs b/userland/libc/src/socket.rs
index 0992bf218..d5230690c 100644
--- a/userland/libc/src/socket.rs
+++ b/userland/libc/src/socket.rs
@@ -534,7 +534,7 @@ pub unsafe extern "C" fn shutdown(fd: i32, how: i32) -> i32 {
             return -1;
         }
         entry.read_shut |= matches!(how, SHUT_RD | SHUT_RDWR);
-        entry.write_shut |= matches!(how, SHUT_WR | SHUT_RDWR);
+        let _ = SHUT_WR;
     }
     0
 }

m12-read-shut-unanswered

diff --git a/userland/libc/src/socket.rs b/userland/libc/src/socket.rs
index 0992bf218..9b60a6d4c 100644
--- a/userland/libc/src/socket.rs
+++ b/userland/libc/src/socket.rs
@@ -392,9 +392,7 @@ pub unsafe extern "C" fn recv(fd: i32, buf: *mut u8, len: usize, _flags: i32) ->
 
     match entry.kind {
         SocketKind::Tcp => {
-            if entry.read_shut {
-                return 0;
-            }
+            let _ = entry.read_shut;
             let data = core::slice::from_raw_parts_mut(buf, len);
             let read = syscall::read(RawHandle(entry.rx_fd as u32), data).map_err(|_| Refusal::Other).and_then(|n| match n {
                 0 => streamend::read_end(syscall::write_nonblock(RawHandle(entry.tx_fd as u32), &[])).map(|()| 0),
The control: the reverse of #803's node change, as merged here at 083d490f3
diff --git a/userland/netstack/node/src/streams.rs b/userland/netstack/node/src/streams.rs
index 6e5bc08f4..41e86527a 100644
--- a/userland/netstack/node/src/streams.rs
+++ b/userland/netstack/node/src/streams.rs
@@ -4,16 +4,10 @@
 //! **The node holds a stream for as long as it holds one of its pipes.** A pass
 //! ([`Node::bridge`]) moves what each side takes and lets go of an end that is finished: the
 //! to-client end once the peer's FIN or a failure has been read through it or its reader is gone,
-//! the from-client end once the connection failed, or its writer is gone and its last byte is
-//! queued with the FIN after it. With neither end left the node closes the connection, which
-//! [tcp] then finishes alone by its own rules for a user who let go (a reset if text is unread,
-//! RFC 9293 §3.6.1), and the stream's id names nothing.
-//!
-//! **How a stream ended is the order its ends go in**, which is all a pipe can say: a client that
-//! reads the end of the to-client pipe and finds the from-client pipe still read had the peer's
-//! FIN, and one that finds it gone had a failure. So a failure lets the from-client end go before
-//! the to-client end, and an orderly end keeps a from-client end whose last byte and FIN are
-//! queued, read no more, until its writer leaves or the client lets go of the stream.
+//! the from-client end once its last byte is queued and the FIN after it, or the connection is
+//! over. With neither end left the node closes the connection, which [tcp] then finishes alone
+//! by its own rules for a user who let go (a reset if text is unread, RFC 9293 §3.6.1), and the
+//! stream's id names nothing.
 //!
 //! **Nothing leaves the stack that the client's pipe did not take**, and **nothing leaves the
 //! pipe that the stack will not take**: a byte moved is a byte acknowledged to whoever sent it,
@@ -155,10 +149,8 @@ struct Stream {
     conn: ConnId,
     /// The peer's address: what [`OWNERLESS_PER_PEER`] counts by.
     remote: Ipv4Addr,
-    /// Before `to_client`, so a stream dropped whole lets its send pipe go first: the order a
-    /// failure owes its client.
-    from_client: Option<Box<dyn FromClient>>,
     to_client: Option<Box<dyn ToClient>>,
+    from_client: Option<Box<dyn FromClient>>,
     /// The connect is not answered yet, and no byte moves.
     connecting: bool,
     /// The connect's, while connecting; then the cut's, once the client can see the stream no
@@ -167,11 +159,6 @@ struct Stream {
     /// The client writes no more: its writer is gone, it shut its sending half down or it
     /// closed. An empty pipe is then the end.
     done_writing: bool,
-    /// The FIN is queued after the client's last byte. A from-client end still held is kept for
-    /// its client to find there, until its writer leaves.
-    fin_queued: bool,
-    /// The client let go of the stream, or nobody reads it: nobody asks how it ended.
-    left: bool,
     /// One of its peer address's [`OWNERLESS_PER_PEER`]: a byte given up restarts the cut's clock.
     extended: bool,
     /// The to-client pipe refused bytes [tcp] holds.
@@ -194,8 +181,6 @@ impl Stream {
             connecting: false,
             deadline: None,
             done_writing: false,
-            fin_queued: false,
-            left: false,
             extended: false,
             held: false,
             room: false,
@@ -242,23 +227,14 @@ impl Stream {
                     break;
                 }
                 // Nobody reads what the peer sends.
-                (Ok(Received::Data(_)), Some(WriteRefusal::Gone)) => {
-                    self.to_client = None;
-                    self.left = true;
-                }
+                (Ok(Received::Data(_)), Some(WriteRefusal::Gone)) => self.to_client = None,
                 (Ok(Received::Data(_)), Some(WriteRefusal::Broken)) => {
                     stack.tcp_abort(now, conn);
                     return false;
                 }
-                // The peer's FIN after its last byte: the client reads the end, and its send pipe
-                // stays.
-                (Ok(Received::End), _) => self.to_client = None,
-                // The connection's failure: the client's send pipe goes first, so that the end it
-                // reads finds no reader behind it.
-                (Err(Error::Failed(_)), _) => {
-                    self.from_client = None;
-                    self.to_client = None;
-                }
+                // 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,
                 (Err(Error::WouldBlock), _) => break,
                 (Err(refusal), _) => unreachable!("[tcp] refused the holder of a connection a read: {refusal:?}"),
             }
@@ -267,17 +243,16 @@ impl Stream {
         let mut gave_up = false;
         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; [tcp] has none once the FIN is queued.
+            // would be the end's.
             let room = stack.tcp_status(conn).writable.min(CHUNK);
             if room == 0 {
                 break;
             }
             let mut chunk = [0u8; CHUNK];
             let Some(space) = chunk.get_mut(..room) else { unreachable!("room is at most a chunk") };
-            // The read, and whether the pipe's writer is gone, which a read of 0 is.
-            let (read, writer_gone) = match pipe.read(space) {
-                Ok(read) => (read, read == 0),
-                Err(ReadRefusal::Empty) if self.done_writing => (0, false),
+            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);
@@ -286,10 +261,7 @@ impl Stream {
             };
             if read == 0 {
                 stack.tcp_shutdown_write(now, conn);
-                self.fin_queued = true;
-                if writer_gone {
-                    self.from_client = None;
-                }
+                self.from_client = None;
                 break;
             }
             let Some(bytes) = space.get(..read) else { unreachable!("a pipe read {read} bytes into room for {room}") };
@@ -298,15 +270,9 @@ impl Stream {
         }
 
         let status = stack.tcp_status(conn);
-        // A connection that failed takes no more of the client's bytes: its writes fail rather
-        // than fill a pipe nobody drains, and its end reads as a failure. One that ended in order
-        // ended after the client's FIN, so its writing was over already.
-        if status.failure.is_some() {
-            self.from_client = None;
-        }
-        // A finished send pipe is kept only for a client that can still read the end and look
-        // behind it.
-        if self.fin_queued && self.left {
+        // 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;
@@ -314,9 +280,8 @@ impl Stream {
             stack.tcp_close(now, conn);
             return false;
         }
-        // Nothing of the stream reaches its client again, alive or not, and its pipe still holds
-        // what may be bytes to send.
-        if self.to_client.is_none() && self.done_writing && !self.fin_queued {
+        // 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);
                 if *kept < OWNERLESS_PER_PEER {
@@ -408,7 +373,6 @@ impl Node {
         }
         stream.to_client = None;
         stream.done_writing = true;
-        stream.left = true;
         self.bridge(now);
     }
 
@@ -438,12 +402,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;
-                stream.left = true;
-            }
-            // A finished send pipe was kept for its writer alone.
-            PipeEnd::FromClient if stream.fin_queued => stream.from_client = None,
+            PipeEnd::ToClient => stream.to_client = None,
             PipeEnd::FromClient => stream.done_writing = true,
         }
         self.bridge(now);
@@ -468,12 +427,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 || stream.fin_queued),
-                writable: to && stream.held,
-                reader: to,
-            })
+            (*id, Watch { readable: from && stream.room, writer: from && !stream.done_writing, writable: to && stream.held, reader: to })
         })
     }
 

@Japabu Japabu changed the title The shipped netstack runs ToyOS's own network stack, and smoltcp is deleted The shipped netstack runs ToyOS's own network stack and smoltcp leaves the tree; libc reads a stream's end as std does Oct 9, 2026
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

T14 round 3, run by the orchestrator: two boot:outbound boots from power-off, measurement branch wt/toyos-move-t14-r3 at 1e2cc9c05 (the move's 04a46fb74 plus the outbound rows and two measurement-only commits; clean tree checked before each flash). Images' sha256 checked against request.txt before each flash: boot 1 b887baaf…703634a7, boot 2 765dae63…4d9db1cf; each toyos-metal --fat32-check exit 0. Judge cargo test --test toyos-build -- --metal --metal-readback <dir>/boot-N boot:outbound: EXIT=0 on both, [metal] 2 passed, 0 failed, 1 boot(s) each.

boot 1 boot 2
link up after driver 2725 ms 2742 ms
lease after netstack came up 7206 ms 3419 ms
mDNS claim after the lease (by timestamp) 814 ms 914 ms
job's name line name: claimed name: claimed
ring: full=0 wake_armed=0 wake_taken=0 sent=20 wire=20 stranded=0 full=0 wake_armed=0 wake_taken=0 sent=18 wire=18 stranded=0
card i219 link=up sent=10 received=4 i219 link=up sent=9 received=4
lease / router / resolver held, named, off-link held, named, off-link
anchors dns.google, dns.quad9.net: addresses, connected same
gateway neighbour reachable reachable
rows PASS outbound_router, PASS outbound_internet same

The claim line (netstack: mDNS: no host answered for toyos-t14.local; this machine answers as it) appears in both, inside the 750 ms – 1 s window after the lease that the request named; no panic, no no-lease line, no loss line, no repeating refusal, no refused watch. Leases against round 2 (8172, 8397 ms) and smoltcp (13337 ms): one boot each, no timing verdict.

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 3, head 04a46fb74

No round-2 review was posted, so this round judges round 1's four BLOCKERs and everything that changed from 803dc0d41 to 04a46fb74.

Net (git diff --shortstat origin/main...04a46fb74): 64 files, +2,421 / −3,958. Production in userland/netstack/src is +1,282 / −3,322. libc is +114 / −7: about 80 production lines for the stream-end rule, which I accept. Tests are +845 / −95. The production code shrinks.

Round 1's BLOCKERs

  • 1, half-close read as a reset: CLOSED. The node keeps the pipe order std reads a stream's end by: a FIN after a shutdown reads as a FIN, a failure as a reset #803's node order is merged at f3308fbf0. At 04a46fb74, netstack_streams and netstack_streams_e1000e both read the stream's end, green (r3-g-25-netstack-by-name.log: 8 passed, 8 total, EXIT=0). The control is the node order reverted at 2d0b0aa44, whose libc, node and netstack match the head (r3/mut/control-node-order-reverted.log). It is red on both cards with the read after the bulk's last byte answered Err(... ConnectionReset ...), and red in C with shut_first: then the peer's FIN: -1 <-- WRONG: 0 passed, 3 failed, EXIT=1, RESTORED status: []. libc's three named mutations are red in the same directory, each EXIT=1, with the tree restored after each.
  • 2, the DHCP refusal flood: CLOSED. The fix is Node::log through RefusalLog. m8 turns a_hundred_refused_replies_are_one_line_and_a_count red (move/logs/r2-fix-mut-m8-…log, EXIT=101), and the test is green at the head (r3-g-23-node.log).
  • 3, one client ends netstack: CLOSED. There is now one waiting receive per socket, refused ERR_RESOURCE_EXHAUSTED past that, and the accept refusal is passed over. m9 stalls netstack_socket_churn at the second receive (r2-fix-mut-m9-…log, EXIT=1). No test reaches the accept arm, and I accept it by reading: handles are bounded at 2·places + 32 + 16.
  • 4, the capture behind "QEMU's forward": CLOSED. The reading is comment 6081860777. The red run's guest frames hold one SYN on the listener's port, none from the second peer's port, and no RST from the guest. The second peer's write_all returned EPIPE.

BLOCKER

  • userland/netstack/node/src/streams.rs:432 (Node::nodelay), userland/netstack/node/src/listeners.rs:152 (Node::listener_nodelay), userland/netstack/node/src/lib.rs:136 (Node::dhcp): each now ships with only tests reading it. This diff is the one that makes the node shipped code. It also removed nodelay's last production caller (serve.rs's shutdown(Read)), and the other two never had one. "Nothing ships for tests alone." "This round's brief did not name it" is not a reason under that rule, and this diff already edits the node's source (lib.rs, datagram.rs). Delete all three:

    • The listener tests read the option from the accept's answer (accepted.nodelay).
    • The stream tests read it on the wire, as nodelay_reaches_the_stack already does.
    • The lease tests read toyos_dhcp's refusal through the Event::Dhcp the node drains.

    If the node's source is outside the fence, the orchestrator either widens the brief or lands the deletion first. Either way, the move does not land with these getters left in.

  • tests/toyos.rs:308-309,3409-3410, netstack_lookup and netstack_lookup_e1000e: a cheaper tier answers the reason the body gives. The body says the T14's rows "ask names whose answers are the internet's to change". But this guest job passes on any word a server gives, so a metal row asking toyos-test.invalid would be just as stable. outbound_internet already reads lookup=addresses through the same serve.rs lookup path on the Intel driver. netstack_lookup_e1000e reaches nothing that netstack_streams_e1000e (the Intel driver in a guest) and netstack_lookup (the lookup) do not already reach. Cut it. For netstack_lookup, either the body names what the guest reaches that a T14 row cannot, or it is cut too, with the cut named in the body.

  • Evidence, on hardware. This head's T14 reading is not on the pull request yet. The round-2 boots were at ae8adbca7, which predates The node keeps the pipe order std reads a stream's end by: a FIN after a shutdown reads as a FIN, a failure as a reset #803's node change, so they stand for nothing here. What the reading must show is set out below. The link pull and return that round 1 owed for landing is still owed: link-down and INIT-REBOOT have never run on a real card, and the node's DHCP timers run while the link is down. Moving it to an "attended list" does not discharge it.

NOTE

  • userland/netstack/src/serve.rs:785,787 with node/src/streams.rs:438,454: each pipe_gone and pipe_broken answer still runs Node::bridge, a pass over every stream. So "one pass over the streams a wake" holds for readiness answers only, and k leaving clients in one wake cost k passes. Mark the wake and let Sockets::bridge pass once, or file it against the track's linear-pass line.
  • issues/the-pipe-abi-has-no-word-for-an-unreachable-host-or-a-lookup-to-try-again.md:33: the owner is "whoever next changes the pipe ABI", which is nobody that exists. Name the track's holder (issues/toyos-has-its-own-network-stack.md stays open after the move).
  • issues/toyos-has-its-own-network-stack.md:43: "Not removed at the move, whose worker changed no source of the node's" is false of this diff. It changes userland/netstack/node/src/lib.rs and datagram.rs, and the merges bring The node keeps the pipe order std reads a stream's end by: a FIN after a shutdown reads as a FIN, a failure as a reset #803's streams.rs.
  • issues/most-t14-leases-land-one-dhcp-retry-late.md:34: "none has been taken on the T14 since" is false of the record. Round 2's two boots took leases 8,172 and 8,397 ms after netstack came up (comment 6082083543).
  • The C rows of tests/netcase/stream_ends.c have no oracle; only std's report is checked against the host's TCP. shut_rd's 0 is the BSD reading of SHUT_RD with bytes in flight. Linux returns the queued bytes first, which is exactly what m12 turns into a red. The body states the choice as libc's rule. It should say it is BSD's and not every host's.

What the T14 reading must show for the move to land

Two boots of boot:outbound from power-off, at 1e2cc9c05. Two stagings of the same commit serve: git diff --stat 04a46fb74 1e2cc9c05 touches only the rows, the outbound job, tests/outboundcase/system.toml and one line of src/build.rs's test list.

On both boots, each of these holds:

  • The sha256 of each image matches the request.
  • --metal-readback … boot:outbound exits 0 with 2 passed, 0 failed.
  • pcidev: PCI 00:1f.6 [8086:15fc] handed over appears, then the I219 link-up line, then netstack: DHCP: lease …, and the no-lease line does not appear.
  • netstack: mDNS: no host answered for toyos-t14.local; this machine answers as it appears after the lease line, followed by the job's name: claimed.
    • A claim earlier than 750 ms after the lease line breaks RFC 6762 §8.1 and sends the move back.
    • A claim later than about 1 s is read and explained. It does not by itself send the move back.
  • outbound: gateway neighbour= reads reachable, stale, delay or probe.
  • Both anchors read lookup=addresses connect=connected.
  • On the ring: line, descriptors.sent equals wire.sent and descriptors.stranded is 0.
  • The lease's distance from link-up is recorded for the late-lease issue. It does not gate.

Any one of these sends it back:

  • a netstack panic;
  • the no-lease line;
  • mDNS: another host answered, name: lost or name: none within 15 s;
  • a repeating netstack: refused or netstack: DHCP: refused line;
  • resetting a connection — the kernel refused the watch;
  • transmit.full ≥ 1 with transmit.wake_taken 0.

If only one anchor connects, boot again before blaming the stack.

Not required here:

  • transmit.full, wake_armed and wake_taken at 0 are unread, not red. Round 1's ask that they reach ≥ 1 is withdrawn. The shared shell line is held by m1's virtio red, the driver's room by toyos-i219's three host tests, and the metal reading by issues/the-intel-nics-room-and-wake-are-unread-on-hardware.md.

Still owed, separately: one link pull and return on either boot, read from the log. The link-down line comes first. On return, the lease is verified or taken again with no no-lease line, and the name is claimed again.

What else the reading stands for. It stands for a landing head that differs from 04a46fb74 only in deleting the three getters and their test reads and in cutting guest tests. Any other change under userland/netstack/, the node, toyos-dhcp, toyos-mdns, toyos-i219, toyos-virtio or the stack's crates owes new boots. At the landing head, host, toolchain and guest / suite must conclude success. They read SKIPPED now, as a draft.

SEND BACK

… guests go

The move made the node shipped code, and three of its getters had no
caller but its own tests: `Node::nodelay` (its last production caller left
with serve.rs's `shutdown(Read)`), `Node::listener_nodelay` and
`Node::dhcp`. Each is deleted. The listener tests read the option from
the accept's answer (`Accepted::nodelay`) and the stream tests on the
wire, as `nodelay_reaches_the_stack` did already; the deadline test tells
the client's wait from the stack's by `Node::next_deadline` against
`Node::shard`'s, and the refusal test counts the client's refusals by the
`Event::Dhcp` lines the node drains, the second line's 99 suppressed.

`netstack_lookup` and `netstack_lookup_e1000e` are cut. The e1000e arm
reached nothing that `netstack_streams_e1000e` (the Intel driver in a
guest) and `netstack_lookup` did not. The virtio arm passed on any word a
server gave, so a T14 row asking the same `.invalid` name is as stable:
the outbound rows' `outbound_internet` reads `lookup=addresses` through
the same serve.rs lookup path on the I219, and the resolver's decisions
are the node's host tests'.

Issues: the pipe ABI's owed words are the network track's; the track's
linear-pass line carries `Node::pipe_gone`'s pass of its own; two false
sentences, the node's source unchanged by the move and no T14 lease on
`toyos-dhcp`, are corrected, the second with the four leases taken since.

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

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Round 4, m7 moved to netstack_streams (its round-1 red was the cut netstack_lookup). At 01bb4c8c3, applied with git apply --check then git apply, run cargo test --test toyos-build -- netstack_streams netstack_streams_e1000e, reversed with git apply -R.

Result: EXIT=1, test result: FAILED. 0 passed, 2 failed, 0 invalidated, 2 total; both: STALLED: waiting for netstack's lease — it went quiet. After the reverse, git status --porcelain printed nothing.

m7-deadlines-not-fired

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -383,5 +383,5 @@ fn main() {
         let now = clock();
         if node.next_deadline().is_some_and(|at| at <= now) {
-            node.fire(now, draw);
+            let _ = now;
         }
         loop {

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 4, head 01bb4c8c3

Net (git diff --shortstat origin/main...01bb4c8c3): 66 files, +2,401 / −4,002. Production: +1,398 / −3,391. Tests: +822 / −127. issues/: +181 / −484. Production still shrinks. The merge base is d6298c83e, main's tip.

Round 3's BLOCKERs

  • The three test-only getters: CLOSED. Node::nodelay, Node::listener_nodelay and Node::dhcp are gone, and git grep at 01bb4c8c3 finds no caller of any of them.

    • The listener tests read Accepted::nodelay. The stream tests read the option on the wire (nodelay_reaches_the_stack).
    • The refusal test's dropped "each one counted" is held by the second event's suppressed: 99.
    • Node tests are green at the head (r4/logs/r4-13-net-crates.log, EXIT=0), and so is clippy (r4-12-clippy.log: 24 invocations clean, EXIT=0).
  • The lookup guests: CLOSED. Both were added by this branch: netstack_lookup is on no line of origin/main's tests/, so the cut takes nothing from main. The body says so and says what reaches serve.rs's lookup arm instead: The T14's outbound rows: its router and the internet, judged from the stick (lands behind the move; do not merge before it) #784's outbound_internet, open and marked to land behind this change. That gap is the track's first line, whose exit is that row.

    • m7, whose round-1 red was the cut test, is moved to netstack_streams. It is red on both cards at 01bb4c8c3 (r4-16-mut-m7.log: 0 passed, 2 failed, EXIT=1), and the tree's status was empty after the reverse.
  • Evidence, on hardware: CLOSED for the boots. Comment 6084588541 reads 1e2cc9c05, and every item round 3 required holds:

    • each image's sha256 matched;
    • the judge exited 0, 2 passed, 0 failed, on both boots;
    • the claim came 814 and 914 ms after the lease, then name: claimed;
    • the neighbour read reachable;
    • both anchors read addresses, connected;
    • ring: read 20/20/0 and 18/18/0;
    • none of the lines that would send the move back appeared.

    The reading still stands for this head, on its condition. git diff --stat 04a46fb74 01bb4c8c3 is 11 files, +30 / −94. Under the node, the only source change is the three deleted functions. Nothing changed under userland/netstack/src, toyos-dhcp, toyos-mdns, toyos-i219, toyos-virtio or the stack's crates.

    • The link pull is not landing's under the owner's ruling. What remains of it is the record, which is the BLOCKER below.

Checks at the head, every log's second line 01bb4c8c3…:

  • --build-only, EXIT=0;
  • --ci host, Host: 78 step(s), all green, EXIT=0;
  • the whole guest suite, 43 passed, 43 total, EXIT=0;
  • the six net guests by name, 6 passed, 6 total, EXIT=0.

Round 3's NOTEs

  • pipe_gone's pass of its own: filed on the track's linear-pass line (issues/toyos-has-its-own-network-stack.md:35), and true of the code. pipe_broken runs no pass.
  • The pipe-ABI issue's owner is now the network track, which exists and stays open.
  • The on_datagram line now says "left toyos-dns as it was". That is true: the branch changes no file under toyos-dns.
  • The late-lease issue now lists the four T14 leases, 3,419, 7,206, 8,172 and 8,397 ms, which match comments 6082083543 and 6084588541.
  • The body names shut_rd's 0 as BSD's choice and not every host's.

BLOCKER

  • issues/toyos-has-its-own-network-stack.md:40: the record the ruling rests on does not exist. The ruling defers the pull because "link-down and INIT-REBOOT are recorded as unread on hardware in an issue with that pull as its exit". No issue at 01bb4c8c3 says that.
    • This branch deletes issues/a-lease-kept-across-a-link-flap-is-not-verified-until-its-renewal.md, closed by host tests.
    • The only line that names the attended session is in the mDNS paragraph, and it is main's line, unchanged here. It covers "the link's return" for the name alone. Its exit, "the orchestrator's attended session with the owner", names when, not what a log must show.
    • issues/no-machine-has-read-an-intel-nic-across-a-link-change.md is about the driver's stranded ring. Its exit is a commanded link partner, not this pull.
    • So the node's link-down handling and its INIT-REBOOT have no hardware reading, and the tree does not say so. Under Zero silent debt, the deferral holds only once that is recorded.
    • The fix is one line on the track, owned by the track: the node's link-down and INIT-REBOOT have never run on a real card or against a real DHCP server. Its exit is one attended T14 boot with the cable pulled and returned. Its log must show the link-down line, then on return the held lease verified by a REQUEST and ACK, or taken again, with no no-lease line, then the name's claim line again.

NOTE

  • issues/toyos-has-its-own-network-stack.md:32: "Node::set_nodelay answers false and Node::nodelay None" cites a function this head deletes. That is false of the tree.

SEND BACK

…ardware, and cites no deleted getter

The owner's ruling defers the T14 cable pull on the condition that
link-down and INIT-REBOOT are recorded as unread on hardware, with that
pull as the exit. Round 4's review found no such record: the deleted
late-lease issue was closed by host tests, the mDNS paragraph's attended
session names when and not what a log must show, and the Intel link
issue is about the driver's ring. The track now carries the line, with
the exit the review spelled out: one attended boot, cable pulled and
returned, whose log shows the link-down line, the held lease verified
or taken again with no no-address line, then the name's claim again.

The reset-stream line cited `Node::nodelay`, which round 3 deleted.

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

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 5, head 409a4fa9b

git diff --stat 01bb4c8c3 409a4fa9b shows one commit and one file, issues/toyos-has-its-own-network-stack.md, +2 / −1. The merge base is still d6298c83e, which is origin/main's tip. No source, test or manifest changed since round 4. So round 4's measurements still stand, and so does the T14 reading it accepted (comment 6084588541, 1e2cc9c05).

cargo run -- --ci host at this head: the log's second line is 409a4fa9b…, it reads Host: 78 step(s), all green and EXIT=0 (r5/r5-ci-host.log).

Round 4's BLOCKER

  • The link-down and INIT-REBOOT record: CLOSED. It is now issues/toyos-has-its-own-network-stack.md:27, on the track and owned by it.
    • It records that Node::link and Client::link_up have never run on a real card or against a real DHCP server.
    • Its exit is one attended T14 boot with the cable pulled and returned.
    • Every name the line gives exists at 409a4fa9b:
      • Node::link is at userland/netstack/node/src/lib.rs:181.
      • Client::link_up is at toyos-dhcp/src/lib.rs:574.
      • netstack: I219: link down is at userland/netstack/src/i219.rs:339.
      • The two DHCP lines that say this machine has no address are at userland/netstack/src/main.rs:222 and :234.
      • The claim line is at userland/netstack/src/serve.rs:708, with HOSTNAME = "toyos-t14" at main.rs:77.
    • The body's deferral (body, "The link pull and return is not automated") now points at a record that exists and says what it says.

Round 4's NOTE

  • The stale Node::nodelay citation: CLOSED. The line now names only Node::set_nodelay, which exists at userland/netstack/node/src/streams.rs:425. At 409a4fa9b, git grep -nE 'Node::(nodelay|listener_nodelay|dhcp)\b' finds nothing.

BLOCKER

None.

NOTE

None.

LAND

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI at 409a4fa9b, read from each job's log: host 79 steps all green; toolchain (llvm a2cc063281d9f279, compiler 6c76c19e5ffcc869, freestanding 0f5a5faab7bbc78c restored; sysroot d00739f9d80a4c53 built); guest suite 43/43 ok. Merges clean with main d6298c83e. Queued.

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