Skip to content

A pipe watch can ask whether the other end is gone, and netstack decides a client has left by it - #760

Merged
Japabu merged 11 commits into
mainfrom
wt/toyos-netstackclose
Oct 8, 2026
Merged

Japabu merged 11 commits into
mainfrom
wt/toyos-netstackclose

Conversation

@Japabu

@Japabu Japabu commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Closes issues/netstack-keeps-the-socket-of-a-connection-whose-client-sent-no-close.md (filed by #755, deleted here). Head 62ac7c817; origin/main 1084ddc9a (#746, one workspace) is merged in, and it is the merge base.

CI at this head, run 37770538069: host success (job 113288745429, [ci] Host: 78 step(s), all green), toolchain / build success (113288746060), guest / suite success (113291240431: PASS netstack_socket_churn (3s), test result: ok. 33 passed, 33 total, [ci] Guest: 5 step(s), all green). Neither was run locally. The T14 at this head: nine boots, thirty rows, none failed (#760 (comment)), pipe_other_end_gone among them on the shipping kernel; the sections are listed below.

What changed, and why

The ABI: a pipe watch can ask whether the other end is gone

The owner's ruling, verbatim from the option he chose: "Yes, add the event (Recommended)" — "A pipe watch gains one condition: ready when the other end has no holder left, whatever the pipe still holds. The kernel already tracks both counts and wakes watchers at that moment, so it is a small ABI addition. The network stack then knows a client is gone in every case, and the 100-second bound covers all of them. It lands in #760 with the work that needs it, through review as an ABI change."

  • toyos_abi::inbox::OTHER_END_GONE (16) is a third OP_WATCH condition beside READABLE and WRITABLE, interest going in and result coming back. Of a pipe end: the pipe's other end has no holder left in any process, whatever the ring holds. Level-triggered as its siblings are: a watch armed after the last holder left is answered at once (arm's first look), and one armed before is fired by the post release already made on that end's own watch and answered by the submitter's look. No new object, no new syscall.
  • Asked of a handle that is no pipe end, the watch is refused NotSupported, whatever else it asks. A connection is not given the condition: no server needs it, each reads its connection to end of file.
  • Why the two older conditions could not carry it: READABLE is available > 0 || no writer and WRITABLE is space > 0 || no reader, so "the writer is gone" could be waited for only on an empty pipe and "the reader is gone" only on a full one.
  • Why the kernel and not userland: the counts are the kernel's; the ring header's closed flags are in a page the client maps writable (pipe_flag_forgery), and every other way to decide it is a poll, a timer, or draining a possibly-dead client's bytes into memory.
  • kernel/pure/pipe.rs now holds the two counts (Holders) and the three answers, pure and host-tested; kernel/src/pipe.rs keeps the ring, the lock and the posts, and its two close functions are one release. ops::pipe_end_watch and ops::other_end_gone dispatch with no _ arm.
  • toyos::poller::Poller::wait_answers hands a caller what each watch was answered beside its token: the conditions met, or the kernel's refusal. wait is that with the answer dropped.

netstack: a client is gone when the kernel says so of both its ends

Round 2's BLOCKER 1: netstack decided a client was gone by reading its send pipe to end of file, and it reads that pipe only while the socket takes bytes. A client that (a) shut its sending half down and exited, or (b) wrote more than the send buffer to a silent peer and exited, was never read again, never ownerless, and held its slot for the life of netstack.

  • Each pass watches both of a connection's pipes for OTHER_END_GONE for as long as netstack holds them, with READABLE while the socket has send room and WRITABLE while the receive pipe is holding bytes back. So a bridge is never without a watch armed for its client's leaving, which also closes round 2's NOTE about a client that dies with none armed.
  • A receive pipe with no reader is closed. A send pipe with no writer sets writer_gone and is still drained while the socket takes bytes; end of file still closes the socket after the last byte, and no longer decides anything about the client.
  • A connection is ownerless when clientless(): the receive pipe closed, and the send pipe closed or its writer gone. A client holding an end of a direction still open is never timed.
  • Watch tokens name the connection by socket id, not by its place in the list. The answers now carry meaning, and closing a pipe ends its watch with an answer of its own: a place in the list would hand that answer to whichever connection swap_remove moved there. An answer for a pipe netstack no longer holds is ignored.
  • A refusal of either watch resets that connection by name, as any other refusal of a client's handle does. A handle that is no pipe end is therefore refused on the first pass after it arrives. netstack_socket_churn now stages it.
  • The zero-byte write that asked the receive pipe on every pass is gone.
  • net.piped.ownerless in netstack's inspect snapshot counts connections whose client is gone.

Both bounds

  • OWNERLESS_LIFE, 100 seconds, from the pass that first found the client gone: R2 of RFC 9293 §3.8.3 at the 100 seconds SHLD-11 asks for at least; §3.8.6.1 leaves a connection its peer holds open "subject to the implementation's resource management concerns". Absolute, by the orchestrator's ruling in round 2, not the owner's; what it costs is filed (below).
  • RESET_LIFE, 3 seconds, from the cut (round 2's BLOCKER 2). A connection silent for 100 s has outlived its next hop's 60 s neighbour entry (smoltcp 0.12.0, iface/neighbor.rs), so the poll after the cut sends an ARP request and not the reset. The cut socket is now kept until the reset has left or RESET_LIFE is over, whichever is first. The number is address resolution's own budget: RFC 4861 §7.2.2, "If no Neighbor Advertisement is received after MAX_MULTICAST_SOLICIT solicitations, address resolution has failed", with MAX_MULTICAST_SOLICIT 3 transmissions and RETRANS_TIMER 1,000 milliseconds in §10. That is IPv6's; RFC 1122 §2.3.2.1 gives ARP "1 per second per destination" and no count, and smoltcp re-asks at that rate (SILENT_TIME) with no count either. Both RFC texts were fetched and read this round.
  • The log says which: netstack: the reset has left, or netstack: letting a connection go with its reset unsent — no next hop took it in 3s.
  • ownerless_wake_in bounds the loop's wait by whichever bound each connection is under.

From rounds 0 and 1, unchanged

A piped connection's socket and its id leave with its bridge; the socket stays until it has said its last (spent); a request naming a stream netstack has let go is answered as an unknown id is; netstack_socket_churn is back and reads net.sockets.untabled. Their measurements and controls are at c9a2df7ef and b27bb7f0a, in the earlier comments on this pull request.

Measured at e7a40645f: the ceiling through netstack's own loop

Not taken again at this head: the merge and this round's commit leave userland/netstack/ and every kernel file of the change byte for byte (git diff e7a40645f 62ac7c817 -- kernel/pure/pipe.rs kernel/src/pipe.rs kernel/src/inbox kernel/src/object/ops.rs userland/netstack toyos/src toyos-abi/src/inbox.rs prints nothing).

measure-guest.patch on netstack_socket_churn (posted below; exit 1 by construction, measure-guest.log): a host server holds every connection and reads nothing; each arm drops both pipe ends with no close request and asks net.piped.live until it is back. Run alone on the machine.

arm slot back after at the ceiling netstack said
both ends dropped 100.005 s resetting a connection — its client left 100s ago and its peer has not finished it, then the reset has left
(a) tcp_shutdown(id, 1), then dropped 100.004 s the same two lines
(b) 2,847,840 bytes written until three seconds passed without room, then dropped 100.007 s the same two lines

At b27bb7f0a arm (a) was still held 130 s after the drop, and the plain drop logged letting a connection go with its reset unsent against a gateway that answers ARP. These are a measurement on one run, not a test: no QEMU test asserts a duration.

Gates

All at 62ac7c817, run alone on the machine by the orchestrator from build-request.sh; logs under the scratchpad, orch/netstackclose-r4/, exits in results.txt.

command exit log
cargo metadata --locked --format-version 1 0 metadata.log
cargo run -- --build-only 0 build.log
cargo test -p kernel --lib --features sched-check pipe:: (as src/ci.rs runs the kernel's library, narrowed) 0 kernel-pipe.log, 5 passed
cargo test --manifest-path userland/netstack/Cargo.toml (as src/ci.rs runs a userland crate) 0 netstack-host.log, 27 passed
cargo test --test toyos-build -- netstack_socket_churn 0 guest-churn.log
cargo test --test toyos-build -- iommu_virtio_platform 0 guest-iommu.log
cargo run -- --ci host CI: success run 37770538069, job 113288745429
the whole guest suite CI: success, 33 passed, 33 total run 37770538069, job 113291240431

The T14: read, thirty rows green

pipe_other_end_gone is a discovered binary, so its green is a T14 reading: every section below was booted by the orchestrator at this head and judged green (comment 6058892938). Staged from 62ac7c817 with cargo test --test toyos-build -- --metal --metal-readback <dir> <filter>, each exit 2 (staged, machine untouched); t14/request.txt lists every image with its sha256. A metal filter is one substring and selects the shared boots' members too, so the rows review round 3 named take nine images:

filter boot rows it carries
pipe shared pipe_other_end_gone, poll_wake_pipe, abuse_pipe_map, abuse_pipe_owner, abuse_pipe_ring, pipe_flag_forgery
inbox shared inbox_cancel_wakes, inbox_empty_write, abuse_inbox, inbox_log_post
std_ shared std_io, std_process and the other std_ members
every_wait, wait_storm, blocking_read, poller, process_lifecycle shared, one each kill_ends_every_wait, exit_wait_storm, blocking_read_stress, poller_capacity, process_lifecycle
handle_lifetime shared-debug, the actuator kernel handle_lifetime

Checks of high-risk code

Each a checked patch, applied, run and reversed, tree clean after each; the patches are in the comments below.

This round, at 62ac7c817 (results.txt):

patch run exit what it showed
qemu-arm alone, the green arm pipe_other_end_gone on a QEMU boot of tests/testcases 0 all three of the binary's lines
w1, the wake: the three lines that register an OTHER_END_GONE poll on the end's own watch deleted netstack_socket_churn 0 it survives the churn test, as review round 3 predicted: every count() the test loops on wakes a pass, and each pass submits the watches again. The churn test holds the answer and not the wake.
w1 pipe_other_end_gone (with qemu-arm) 1 STALLED: 123s of guard expired, and the guest had said nothing: the binary never printed its first line, which follows the four parked arms. The log does not say which thread stood still; the green arm on the same tree is what makes it the mutation.
r1, the kernel's refusal: Some(None) => return Err(NotSupported) to Some(None) => None pipe_other_end_gone (with qemu-arm) 1 a watch of 0x11 on a connection, which has no other end, left: None, right: Some(Err(NotSupported)): the connection was served READABLE and kept armed. The parked arms had passed.
r2, netstack's refusal: Err(e) => self.refuse(..) to an arm that does nothing netstack_socket_churn 1 netstack still counts connection 942118025 live 20s after the kernel refused the watch of its receive end, which is no pipe end

From round 3, at e7a40645f, not rerun: every file those six patches touch is unchanged since (the git diff above), and all six still git apply --check at this head. The churn test they ran against gained one arm, placed before the two that turned red. c1 (the kernel never answers; churn, 1), c2 (the kernel reverted to main; churn, 1, red on InvalidArgument and not on the missing wake), m1 (the pure answer asks its own end; pipe::, 101), m2 (gone only when the ring is empty; churn, 1 on the 10-byte arm), m3 (no RESET_LIFE; netstack's host tests, 101), m5 (netstack never asks its send pipe; churn, 1). Their patches and deciding lines are in the round 3 comment.

What holds what. The answer's truth table is the host's (kernel/pure/pipe.rs). The wake is pipe_other_end_gone's: a thread parked in wait_answers(1, u64::MAX, ..) on a ring holding one OTHER_END_GONE watch and nothing else, the other end's last holder let go once the roster says the thread is blocked, a reader told of its writer and a writer of its reader, with the ring empty and with bytes left in it, and with a child process's exit as the leaving. The same binary holds the refusal on a connection and on a file, alone and beside READABLE; a watch made after the leaving, answered by its own submit; and the three places a false "gone" would reset a live connection: a duplicate held, the handle unreceived in a connection's queue, the handle in a living child's table. netstack's decision on a refused watch is the churn test's new arm. The reset's second bound is netstack's host bench.

Independent oracles: smoltcp's own state machine and neighbour cache on a wire; RFC 9293, RFC 4861 and RFC 1122 for the numbers; a host TCP server behind QEMU's user network; and the T14, which has answered (comment 6058892938).

Why the new binary is a T14 member, and where its mutations ran

A discovered binary under tests/toyos-rust-tests/src/bin/ runs on the T14's shared boots and on no QEMU or CI run. I did not also register it in MACHINE_TESTS: the behaviour needs no machine shape, a metal row reaches it, and root CLAUDE.md puts a metal row before a guest test. The price is real and stays: CI does not hold the wake, and a regression of it is seen at the next T14 run and not at the pull request that makes it.

w1 parks a thread for good and r1 panics, so neither was run on the T14. qemu-arm.patch is a throwaway MACHINE_TESTS entry that boots tests/testcases under QEMU and runs the binary; it was applied for the green arm and under each mutation, and ships nothing.

Why the changed guest test needs QEMU

netstack_socket_churn has three arms on a host server that holds every connection. Two from round 3: a client shuts its sending half down, leaves nothing or ten bytes in its send pipe, drops both ends, and net.piped.ownerless must rise by one. One new: a client moves netstack an acceptor where its receive end belongs and keeps its send end; the server sends nothing, so netstack never writes that end, the kernel's refusal of its watch is all that ends the connection, and net.piped.live must come back. Counts under a hang ceiling, never a duration. No type holds them. A host test cannot: pipe_answered's refusal drops kernel pipe handles and runs in netstack's loop, and neither has a host build. A metal row cannot: netstack owns its NIC and the T14's peer is the bench's network, with no server the harness controls. It is one boot the suite already makes.

Filed and corrected

  • Filed this round: issues/netstack-watches-both-pipes-of-every-connection-on-every-pass.md. Two watches are submitted per live connection on every pass, and every client read or write through the kernel posts the poll netstack keeps on that pipe. Read, not measured; owner the network-stack track; exit a watch submitted only on a change of interest, or a T14 throughput reading. Not fixed here: it would change userland/netstack/src/main.rs, and the six controls above stand on that file as it is.
  • Corrected this round: issues/netstack-spends-two-poller-slots-per-piped-connection.md (both pipes are watched on every pass; its exit now needs the condition on a connection), issues/netstack-cuts-a-departed-clients-unsent-tail-at-the-ceiling.md (clientless() also times a living client that holds only the read end of a receive pipe netstack closed), issues/a-close-of-one-handle-ends-every-rings-poll-on-its-object.md (wait is the form that drops a result; wait_answers hands it over).
  • From earlier rounds: the absolute ceiling's cost, the shutdown that drops the send pipe's tail, the option requests on a reset stream, datagram sockets and listeners of a dead client and their missing bound.

Size

Against origin/main: 23 files, +1333 −168. Production is as review round 3 counted it (+409 −101) and unchanged this round. Tests this round: pipe_other_end_gone.rs +198, netstack_socket_churn.rs +41, tests/toyos.rs +2. Issues: one filed, three corrected.

Unsure of

  • host, the guest suite and the T14 are read at this head, as said at the top; the merge queue runs the first two again on the merged tree.
  • CI does not hold the wake (above). A MACHINE_TESTS registration would, at one boot per run; it is the orchestrator's or the owner's to ask for.
  • w1's red is a stall with no line from the guest; that the parked waiter is what stood still is read from the test's order, not from the log.
  • The ceiling measurement is round 3's, at e7a40645f.
  • Socket ids wrap after 2^32 allocations; a watch answer older than that would name another connection.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A

Japabu and others added 4 commits October 8, 2026 10:23
This reverts commit 8dd55d0, which took the
test out red: the guest binary, its RUST_SKIP row, its registration, its
dispatch arm and body, and the `netstack` connector tests/netcase's runner
holds for it. The next commit is what turns it green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
A piped TCP stream was three things: the bridge in `piped_connections`, the
smoltcp socket with its two 64 KiB buffers, and the id's entry in `sockets`.
Only a client's close request removed all three. A connection that ended any
other way, its client dead or simply dropping its pipe ends, lost its bridge
in `bridge_piped` and kept the other two for the life of the process, outside
the count `max_piped_connections` bounds.

`bridge_piped` already ends a connection on what the kernel says of the
client's pipe ends (a write refused `Gone`, a read at end of file) and on what
the peer says on the wire. Where it lets the bridge go it now removes the
socket and the entry too, so the close request is no longer what a stream's
lifetime rests on.

The socket stays one pass longer where netstack aborted it: `abort` leaves
the socket `Closed` with a reset owed, and a socket removed before the next
poll sends nothing. Before this change that reset left only because the socket
was never removed. `spent` is that condition, and `TimeWait` counts as spent,
as it did for the bridge.

A request that names the id of a stream netstack has let go is answered as an
unknown id is: a shutdown or an option `ERR_NOT_CONNECTED`, a close `done`.

Measured on tests/netcase under QEMU: `netstack_socket_churn`, red at
06bbc23 on `netstack held 0 stream(s) before them and holds 4 after`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
Datagram sockets are released only by their close request, listeners only on
a pass something else causes; neither is bounded in number; and logkeeper
hears a reader leave only at its next write. All three are read from the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
The stream count reads the table alone, so a connection whose entry left and
whose socket stayed in the set passed it. `net.sockets.untabled` is the count
that moves then.

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

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

The negative control and the two mutations, as applied at c9a2df7ef by arms.sh (apply, run, record the exit, git apply -R, git status --porcelain empty).

head c9a2df7ef943a9a9d5d6e2ac8df6d206b8a93801
green guest EXIT=0
control-fix-reverted guest EXIT=1
control-fix-reverted restored, porcelain: []
m1-socket-kept-in-the-set guest EXIT=1
m1-socket-kept-in-the-set restored, porcelain: []
m2-spent-is-not-open host EXIT=101
m2-spent-is-not-open restored, porcelain: []
green host EXIT=0
DONE

control-fix-reverted.patch

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index c816d8ae4..0e611b67c 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -331,13 +331,7 @@ struct PendingUdpRecv {
 }
 
 /// A piped TCP connection: data flows through kernel pipes instead of IPC messages.
-///
-/// **The socket and its id live exactly as long as this does.** What ends a
-/// connection is what the kernel says of the client's pipe ends and what the
-/// peer says on the wire; a close request only asks for that end early, and a
-/// client that dies sends none.
 struct PipedConnection {
-    socket_id: u32,
     handle: SocketHandle,
     rx_write: Option<Pipe>,
     tx_read: Option<Pipe>,
@@ -425,15 +419,8 @@ fn send_room(socket: &tcp::Socket) -> bool {
     socket.can_send() && socket.send_capacity() > socket.send_queue()
 }
 
-/// Whether `socket` has said its last to its peer: it is closed, and the reset
-/// an abort owes has left. `TimeWait` only waits.
-fn spent(socket: &tcp::Socket) -> bool {
-    !socket.is_open() && !(socket.state() == tcp::State::Closed && socket.remote_endpoint().is_some())
-}
-
-fn piped_connection(socket_id: u32, handle: SocketHandle, pipes: DataPipes) -> PipedConnection {
+fn piped_connection(handle: SocketHandle, pipes: DataPipes) -> PipedConnection {
     PipedConnection {
-        socket_id,
         handle,
         rx_write: Some(pipes.to_client),
         tx_read: Some(pipes.from_client),
@@ -1259,7 +1246,7 @@ impl Netstack {
         let stream_id = self.alloc_id();
         self.sockets.insert(stream_id, SocketKind::TcpStream(old_handle));
 
-        self.piped_connections.push(piped_connection(stream_id, old_handle, pipes));
+        self.piped_connections.push(piped_connection(old_handle, pipes));
 
         // Create replacement listener
         let rx_buf = tcp::SocketBuffer::new(vec![0u8; TCP_SOCKET_BUFFER]);
@@ -1392,15 +1379,14 @@ impl Netstack {
                 }
             }
 
-            if conn.is_fully_closed() && spent(socket) {
+            // Fully clean up when both sides are done
+            if conn.is_fully_closed() && !socket.is_open() {
                 closed.push(i);
             }
         }
 
         for &i in closed.iter().rev() {
-            let conn = self.piped_connections.swap_remove(i);
-            socket_set.remove(conn.handle);
-            self.sockets.remove(&conn.socket_id);
+            self.piped_connections.swap_remove(i);
         }
     }
 
@@ -1488,7 +1474,7 @@ impl Netstack {
                 };
                 pc.client.result(&resp);
                 let pc = self.pending_piped_connects.swap_remove(i);
-                self.piped_connections.push(piped_connection(pc.socket_id, pc.handle, pc.pipes));
+                self.piped_connections.push(piped_connection(pc.handle, pc.pipes));
                 continue;
             }
             if socket.state() == tcp::State::Closed {

m1-socket-kept-in-the-set.patch

--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -1403,7 +1403,6 @@
 
         for &i in closed.iter().rev() {
             let conn = self.piped_connections.swap_remove(i);
-            socket_set.remove(conn.handle);
             self.sockets.remove(&conn.socket_id);
         }
     }

m2-spent-is-not-open.patch

--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -428,7 +428,7 @@
 /// Whether `socket` has said its last to its peer: it is closed, and the reset
 /// an abort owes has left. `TimeWait` only waits.
 fn spent(socket: &tcp::Socket) -> bool {
-    !socket.is_open() && !(socket.state() == tcp::State::Closed && socket.remote_endpoint().is_some())
+    !socket.is_open()
 }
 
 fn piped_connection(socket_id: u32, handle: SocketHandle, pipes: DataPipes) -> PipedConnection {

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 1 of wt/toyos-netstackclose at c9a2df7ef against origin/main 017451624. Read, not run.

Net: 8 files, +243 −6. Production userland/netstack/src/main.rs +20 −6; tests +142 (netstack_socket_churn.rs +67, tests/toyos.rs +37, listen/tests.rs +35, tests/netcase/system.toml +3); issues +81.

BLOCKER

  • userland/netstack/src/main.rs:430 (spent), :1395 — a bridge whose both pipe ends are gone waits on the wire with no bound, and the branch neither measured that nor filed it — the body records it only under "Unsure of … read from smoltcp's dispatch, not measured". By smoltcp 0.12.0: abort only sets Closed (socket/tcp.rs:1030), the tuple is cleared only after the reset is emitted (:2538), and a missing neighbour silences the socket for DISCOVERY_SILENT_TIME and tries again, for ever (iface/socket_meta.rs:91). So spent stays false, and the bridge, the socket, the id and the slot in piped_live stay, for as long as the next hop answers no ARP. Before this change that socket leaked and its slot came back; now the slot does not come back. Two things owed, both cheap. (1) Measure it: listen/tests.rs's bench answers ARP itself (:122); one host test that aborts after the neighbour entry's 60 s and answers no ARP says whether spent ever turns true, and with it whether the body's "one pass longer" is true. (2) Remove it, or file it in issues/ with owner, evidence and an exit, as reviewer.md Growth asks of a compromise the branch found. Ruling on the brief's question: this is not the audit's defect by another road — what is kept is counted by max_piped_connections, so memory is bounded where it was not — but it is a remainder that survives its client, and the owner of that slot table is denied TCP by it until netstack restarts.
  • same site, the wider road the survey ("The same shape elsewhere") does not name, and the same issue must — a client that is gone and a peer that says nothing. tcp::Socket::timeout is None (socket/tcp.rs:527) and netstack never sets one (git grep set_timeout c9a2df7ef -- userland/netstack: nothing), so a FIN nobody acknowledges is retransmitted for ever in FinWait1, and a peer that acknowledges it and never sends its own leaves FinWait2 for ever; is_open() is true in both, so spent is false. One program holding netstack dials a peer it controls that completes handshakes and then drops every segment, drops both pipe ends, repeats max_piped_connections times and exits: every later connect and accept on the machine is answered ERR_RESOURCE_EXHAUSTED, and killing the program gives nothing back. This needs no on-link accomplice and no refusal, where the ARP road needs both (a refusal is immediate, inside the neighbour entry's lifetime, unless a handle turns bad later). It is not introduced here — the old condition !socket.is_open() held the same bridge — so it is not this branch's to fix, but the branch claims "whoever ended it" and "the socket and its id live exactly as long as this does" over a lifetime that has no end on this road, and files three neighbours while leaving out the one in the lines it changed. File it with the ARP case: owner, the two states, and an exit a test can fail (a host test on the bench with a peer that goes silent, and the slot back).

NOTE

  • issues/netstack-keeps-the-socket-of-a-connection-whose-client-sent-no-close.md — not on origin/main (017451624; Three audit claims traced: a BAR asked for twice panics the kernel, netstack keeps a dead client's socket, and a PCI call's slot is not its claim #755 open at 23f36bce9), so this branch cannot delete it yet. Owed before landing, as the body says: merge origin/main after Three audit claims traced: a BAR asked for twice panics the kernel, netstack keeps a dead client's socket, and a PCI call's slot is not its claim #755, delete the file, git grep the slug and the bare name at that head; the gates are then re-measured at the new head. Its exit as worded at 23f36bce9 ("leaves no socket and no table entry, whoever ended it, and netstack_socket_churn is green") is met by what is measured here.
  • userland/netstack/src/main.rs:1065, :1084 — set_nodelay and its read on a stream the peer reset, by a client still holding it, now answer ERR_NOT_CONNECTED where they answered done. Linux answers setsockopt(TCP_NODELAY) on a reset socket with success; the body cites Linux only for shutdown. Say which host it follows, or keep the option requests answering until the client's close.
  • PR body, "Why the guest test needs QEMU" — "on the T14 the peer is the bench's network" is false of tests/lantalkcase, whose peer is the development host (tests/common/lan.rs:32). The reason that stands is the other two: the judge reads netstack's own counts after the kernel reported the pipe ends gone, and the metal loop has no host server a T14 job dials.
  • PR body, "Gates" — gates.txt in the scratchpad reads guest EXIT=1 beside ci host EXIT=0; guest-iommu.log itself ends EXIT=0 and 1 passed. The record beside the logs contradicts the body's table and should not.

Rulings the brief asked for

  • An id released and reused: no finding. alloc_id (main.rs:748) counts up from a random start and hands a number out again only after 2^32 allocations; bridge_piped's release adds no path to an id that handle_tcp_close did not already have. Every request arrives on its own connection and names the id in its payload, and after release sockets.get misses: shutdown and the options answer ERR_NOT_CONNECTED, a close answers done and removes nothing. The loop is one thread and requests are handled after bridge_piped in the same pass, so nothing is in flight inside netstack across a release. That an id is a number any client can name, and that alloc_id does not step over a live one at the wrap, is issues/netstack-socket-ids-are-ambient.md, open on main and unchanged by this.
  • Removal is sound against the other holders of a handle: a pending connect is moved, not copied, into the bridge (:1490); a listener's handle is replaced at accept (:1279); handle_tcp_close removes entry, socket and bridge together (:807); the watch loop (:1741) reads only bridges that remain. closed is ascending and removed in reverse.
  • The three issues: each true of the code at this head. Datagram sockets leave only by handle_udp_close/handle_tcp_close and deliver_datagram (:1003); a listener's Gone is read at :1427 on a pass nothing of its own wakes; handle_udp_bind and handle_tcp_bind_piped check no bound; feed (userland/logkeeper/src/serve.rs:346) waits on grew and learns of a departed sink only at write_all. None is the same few lines: a READABLE watch on a datagram's send pipe completes on every pass while a datagram waits for its request, and a zero-room notify pipe has no event for "reader gone". Owners named exist on main; frontmatter fits issues/README.md.
  • lan_talk: not owed as Evidence. Nothing in the diff is a card's or a clock's; reviewer.md asks for the hardware's reading where the change targets hardware, and this does not. It is the only run at any tier of the accept side (logkeeper's and sshserver's streams) through the new release, and it is the orchestrator's row: he should run it before he arms the merge, as his own check and not as this branch's gate.
  • Evidence at c9a2df7ef, read from the logs: ci-host.log ends Host: 77 step(s), all green; arm-green.log PASS; guest-iommu.log PASS, EXIT=0; the negative control is the whole of main.rs's change reverted and is red at netstack_socket_churn.rs:60 on the recorded line (4 after, 0 before); m1 red at :61 (6 against 2); m2 red on an_aborted_connection_is_spent_once_its_reset_has_left at listen/tests.rs:327. netstack_gone_mid_bind runs no netstack; the two guest tests named are the ones that do.

SEND BACK

Japabu and others added 3 commits October 8, 2026 10:53
A bridge whose pipe ends are both gone waited on the wire with no bound,
and held its slot in piped_live meanwhile. Measured on listen/tests.rs's
bench before this change, a second at a time for an hour, spent() never
true: an aborted socket whose next hop answers no ARP (no segment leaves),
a peer silent after the handshake (FIN-WAIT-1, 372 retransmissions), and a
peer that acknowledges the FIN and never sends its own (FIN-WAIT-2).

Such a connection is now `ownerless`: the wire finishing it is the event,
and OWNERLESS_LIFE the ceiling, counted from the pass that found both ends
gone. At the ceiling an open socket is reset and kept for the one poll that
sends the reset; one already aborted whose reset never left is let go. Both
say so. The number is RFC 9293 3.8.3's R2 at the 100 seconds it asks for at
least. It is counted from the client's leaving and not from the peer's last
word because smoltcp's own socket timeout, armed at the same moment, was
measured not to end a FIN-WAIT-2 whose peer acknowledges once a second;
RFC 9293 3.8.6.1 leaves a connection held open to the system's resource
management. A connection with either pipe end left is not ownerless and is
never cut.

The loop's wait is bounded by the first ceiling, which nothing on the wire
wakes a pass for.

The option requests on a stream its peer reset are filed: the write is
refused as macOS refuses it (measured), the read where hosts answer.

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

Its exit is met: a connection netstack lets go leaves no socket and no
table entry, whoever ended it, and netstack_socket_churn is green; the
negative control is the release reverted, red on the issue's recorded line
(4 streams after, 0 before). The rule it carried is PipedConnection's doc
in userland/netstack/src/main.rs. No other file cites the slug or the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu Japabu changed the title netstack lets a piped connection's socket and id go with it, whoever ended it netstack lets a piped connection's socket and id go with it, and one with no client left has 100 seconds to finish Oct 8, 2026
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Round 1's measurements, control and mutation, by arms.sh (apply a checked patch, run, record the exit, git apply -R, porcelain empty).

arms.txt

head b27bb7f0a1142f0ebd2a67232d276bc21c48e7bb
control-no-ceiling host EXIT=101
control-no-ceiling restored, porcelain: []
m3-cut-without-the-reset host EXIT=101
m3-cut-without-the-reset restored, porcelain: []
green host EXIT=0

gates.txt

head b27bb7f0a1142f0ebd2a67232d276bc21c48e7bb
ci host EXIT=0
guest netstack_socket_churn EXIT=0
guest iommu_virtio_platform EXIT=0
porcelain: []
DONE

control-no-ceiling.patch, at b27bb7f0a

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index a25aa3f6f..56b3152c2 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -464,16 +464,8 @@ enum Ownerless {
 /// The pass's answer for `socket`, whose client's pipe ends have both been
 /// gone for `gone`.
 fn ownerless(socket: &mut tcp::Socket, gone: Duration) -> Ownerless {
-    if spent(socket) {
-        Ownerless::Over
-    } else if gone < OWNERLESS_LIFE {
-        Ownerless::Waits
-    } else if socket.is_open() {
-        socket.abort();
-        Ownerless::Cut
-    } else {
-        Ownerless::Unsaid
-    }
+    let _ = gone;
+    if spent(socket) { Ownerless::Over } else { Ownerless::Waits }
 }
 
 fn piped_connection(socket_id: u32, handle: SocketHandle, pipes: DataPipes) -> PipedConnection {

m3-cut-without-the-reset.patch, at b27bb7f0a

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index a25aa3f6f..de0102893 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -469,7 +469,6 @@ fn ownerless(socket: &mut tcp::Socket, gone: Duration) -> Ownerless {
     } else if gone < OWNERLESS_LIFE {
         Ownerless::Waits
     } else if socket.is_open() {
-        socket.abort();
         Ownerless::Cut
     } else {
         Ownerless::Unsaid

measure-today.patch, at c9a2df7ef: the bench's clock and ARP switch, and three tests that ask spent once a second for an hour. Exit 101, all three red.

diff --git a/userland/netstack/src/listen/tests.rs b/userland/netstack/src/listen/tests.rs
index 79575e8f8..07508c523 100644
--- a/userland/netstack/src/listen/tests.rs
+++ b/userland/netstack/src/listen/tests.rs
@@ -84,6 +84,10 @@ struct Net {
     listener: SocketHandle,
     listening: Listening,
     sent: Vec<Sent>,
+    /// The clock every poll reads.
+    now: Instant,
+    /// Whether the far end answers ARP.
+    arp: bool,
 }
 
 impl Net {
@@ -95,7 +99,16 @@ impl Net {
         socket.listen(PORT).expect("a fresh socket listens");
         let mut sockets = SocketSet::new(Vec::new());
         let listener = sockets.add(socket);
-        Self { iface, wire, sockets, listener, listening: Listening::new(PORT), sent: Vec::new() }
+        Self {
+            iface,
+            wire,
+            sockets,
+            listener,
+            listening: Listening::new(PORT),
+            sent: Vec::new(),
+            now: Instant::from_millis(0),
+            arp: true,
+        }
     }
 
     fn socket(&mut self) -> &mut tcp::Socket<'static> {
@@ -103,10 +116,10 @@ impl Net {
     }
 
     /// One pass of netstack's loop: everything the wire holds, in one batch, and
-    /// the far end's ARP answered.
+    /// the far end's ARP answered while it answers any.
     fn pass(&mut self) {
         loop {
-            while self.iface.poll(Instant::from_millis(0), &mut self.wire, &mut self.sockets) != PollResult::None {}
+            while self.iface.poll(self.now, &mut self.wire, &mut self.sockets) != PollResult::None {}
             if self.wire.outbound.is_empty() {
                 return;
             }
@@ -122,6 +135,9 @@ impl Net {
             EthernetProtocol::Arp => {
                 let arp = ArpRepr::parse(&ArpPacket::new_checked(eth.payload()).unwrap()).unwrap();
                 let ArpRepr::EthernetIpv4 { operation: ArpOperation::Request, .. } = arp else { return };
+                if !self.arp {
+                    return;
+                }
                 let reply = ArpRepr::EthernetIpv4 {
                     operation: ArpOperation::Reply,
                     source_hardware_addr: PEER_MAC,
@@ -347,3 +363,58 @@ fn a_connection_closed_by_both_ends_is_spent() {
     assert_eq!(net.socket().state(), tcp::State::TimeWait, "the premise: the peer's FIN answered ours");
     assert!(crate::spent(net.socket()), "a connection both ends closed was kept");
 }
+
+/// The handshake from `from` finished, and the socket the client's.
+fn established(net: &mut Net, from: u16) -> u32 {
+    let isn = net.syn(from);
+    net.send(from, TcpControl::None, PEER_ISN + 1, Some(isn + 1));
+    net.pass();
+    assert_eq!(net.socket().state(), tcp::State::Established);
+    isn
+}
+
+/// MEASUREMENT: whether `spent` ever turns true, a second at a time for an hour.
+fn spent_within_an_hour(net: &mut Net) -> Option<u64> {
+    for second in 0..3600u64 {
+        net.pass();
+        if crate::spent(net.socket()) {
+            return Some(second);
+        }
+        net.now += smoltcp::time::Duration::from_secs(1);
+    }
+    None
+}
+
+#[test]
+fn measure_an_aborted_connection_whose_next_hop_answers_no_arp() {
+    let mut net = Net::new();
+    established(&mut net, 5001);
+    net.now += smoltcp::time::Duration::from_secs(61);
+    net.arp = false;
+    net.socket().abort();
+    let before = net.sent.len();
+    let at = spent_within_an_hour(&mut net);
+    assert!(at.is_some(), "the slot never comes back; {} segment(s) left in the hour", net.sent.len() - before);
+}
+
+#[test]
+fn measure_a_peer_that_goes_silent_after_the_handshake() {
+    let mut net = Net::new();
+    established(&mut net, 5001);
+    net.socket().close();
+    let before = net.sent.len();
+    let at = spent_within_an_hour(&mut net);
+    assert!(at.is_some(), "the slot never comes back; state {}, {} segment(s) left in the hour", net.socket().state(), net.sent.len() - before);
+}
+
+#[test]
+fn measure_a_peer_that_acknowledges_the_fin_and_goes_silent() {
+    let mut net = Net::new();
+    let isn = established(&mut net, 5001);
+    net.socket().close();
+    net.pass();
+    net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 2));
+    let before = net.sent.len();
+    let at = spent_within_an_hour(&mut net);
+    assert!(at.is_some(), "the slot never comes back; state {}, {} segment(s) left in the hour", net.socket().state(), net.sent.len() - before);
+}
thread 'listen::tests::measure_a_peer_that_acknowledges_the_fin_and_goes_silent' (180284362) panicked at netstack/src/listen/tests.rs:419:5:
the slot never comes back; state FIN-WAIT-2, 0 segment(s) left in the hour
--
thread 'listen::tests::measure_a_peer_that_goes_silent_after_the_handshake' (180284363) panicked at netstack/src/listen/tests.rs:407:5:
the slot never comes back; state FIN-WAIT-1, 372 segment(s) left in the hour
--
thread 'listen::tests::measure_an_aborted_connection_whose_next_hop_answers_no_arp' (180284364) panicked at netstack/src/listen/tests.rs:397:5:
the slot never comes back; 0 segment(s) left in the hour

measure-smoltcp-timeout.patch, at e2bf2564f: smoltcp's own set_timeout against a peer that keeps answering. Exit 101.

--- /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/netstackclose-r1/tests.rs.keep	2026-10-08 10:53:35
+++ userland/netstack/src/listen/tests.rs	2026-10-08 10:53:35
@@ -440,3 +440,20 @@
     assert_eq!(at_the_ceiling(&mut net, answer), Ownerless::Cut, "the slot of a connection with no client never comes back");
     the_cut_is_said(&mut net);
 }
+
+/// MEASUREMENT, not for the tree: smoltcp's own socket timeout, armed where
+/// the client leaves, against the peer that keeps answering.
+#[test]
+fn measure_smoltcp_timeout_against_a_peer_that_answers() {
+    let mut net = Net::new();
+    let isn = established(&mut net, 5001);
+    net.socket().close();
+    net.socket().set_timeout(Some(smoltcp::time::Duration::from_secs(100)));
+    for second in 0..3600u64 {
+        net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 2));
+        net.pass();
+        assert!(net.socket().is_open(), "smoltcp's timeout closed it after {second}s");
+        net.now += smoltcp::time::Duration::from_secs(1);
+    }
+    panic!("after an hour the socket is still {}", net.socket().state());
+}
thread 'listen::tests::measure_smoltcp_timeout_against_a_peer_that_answers' (180326193) panicked at netstack/src/listen/tests.rs:458:5:
after an hour the socket is still FIN-WAIT-2

nodelay/nodelay.rs on macOS (Darwin 27.0.0), a stream its peer reset:

// What a host answers set_nodelay and nodelay on a stream its peer reset.
use std::io::Read;
use std::net::{TcpListener, TcpStream};
fn main() {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
    let (peer, _) = listener.accept().unwrap();
    // Linger 0 then close: the peer's reset.
    socket_linger0(&peer);
    drop(peer);
    let mut buf = [0u8; 1];
    println!("read after the peer's reset: {:?}", client.read(&mut buf));
    println!("set_nodelay(true): {:?}", client.set_nodelay(true));
    println!("nodelay(): {:?}", client.nodelay());
    println!("shutdown(Write): {:?}", client.shutdown(std::net::Shutdown::Write));
}
fn socket_linger0(s: &TcpStream) {
    use std::os::fd::AsRawFd;
    #[repr(C)] struct Linger { on: i32, secs: i32 }
    unsafe extern "C" { fn setsockopt(fd: i32, level: i32, name: i32, val: *const Linger, len: u32) -> i32; }
    let (level, name) = if cfg!(target_os = "macos") { (0xffff, 0x0080) } else { (1, 13) };
    let l = Linger { on: 1, secs: 0 };
    assert_eq!(unsafe { setsockopt(s.as_raw_fd(), level, name, &l, 8) }, 0);
}
read after the peer's reset: Err(Os { code: 54, kind: ConnectionReset, message: "Connection reset by peer" })
set_nodelay(true): Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
nodelay(): Ok(false)
shutdown(Write): Err(Os { code: 57, kind: NotConnected, message: "Socket is not connected" })

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 2 of wt/toyos-netstackclose at b27bb7f0a against origin/main 2e781a49d. Read, not run; smoltcp is 0.12.0 as userland/Cargo.lock pins it.

Net: 10 files, +447 −51. Production userland/netstack/src/main.rs +93 −7; tests +238 −3 (listen/tests.rs +131 −3, netstack_socket_churn.rs +67, tests/toyos.rs +37, tests/netcase/system.toml +3); issues +116 −41.

Round 1's BLOCKERs

  • 1, an aborted socket whose next hop answers no ARP: CLOSED. Red at c9a2df7ef (measure-today.log: Closed, reset owed, 0 segments in the hour), green at b27bb7f0a (arm-host-green.log, an_aborted_connection_whose_next_hop_answers_no_arp_is_let_go_at_the_ceiling), and red again with the ceiling out (arm-control-no-ceiling.log, exit 101, Waits against Unsaid).
  • 2, a client that is gone and a peer that says nothing or half-closes: OPEN. Closed only for the client whose send pipe netstack could read to its end of file: the three bench rows are red at c9a2df7ef and green here, and control-no-ceiling turns them red. It is open for every departed client whose send pipe netstack cannot read, which the first BLOCKER below names; round 1's recipe still takes every slot for good with one more request per connection.

BLOCKER

  • userland/netstack/src/main.rs:1382, :1427, :1441 — a client that is gone is ownerless only if tx_read was closed, and tx_read is closed only by a read that answers Ok(0) or by !socket.is_open(); the read happens only while send_room is true — so a dead client's connection is never timed on two roads, each read from the code. (a) The client sends TcpShutdown with how 1 and then exits: handle_tcp_shutdown (:896) calls socket.close(), may_send is false from FinWait1 on, send_room is false for good, the pipe is never read again, and tx_read stays Some while the peer holds its half open or says nothing. rx_write goes on the kernel's Gone; is_fully_closed() stays false; ownerless is never asked. (b) The client writes more than the send buffer's 64 KiB to a peer that acknowledges nothing, or keeps its window shut, and exits: the buffer is full, send_room is false, the rest sits in the pipe, no read answers Ok(0), and smoltcp retransmits or probes for ever with no timeout set. Either way the bridge, the socket and the slot in piped_live outlive the program for the life of netstack, and round 1's recipe (max_piped_connections connections to a peer the program controls, then exit) works unchanged with one tcp_shutdown each. The type's doc and the body's "A client holding either end … is never timed" describe a client; here the holder of the end is netstack. Owed: remove it or file it with owner, evidence and an exit a test can fail, and the body and PipedConnection's doc claim no more than what is bounded. A measurement is cheap for (a): netstack_socket_churn's loop against a host server that holds its connections, tcp_shutdown(id, 1) before the drop, and net.piped.live read after OWNERLESS_LIFE.
  • userland/netstack/src/main.rs:466 (ownerless), :1443, :1749 — "reset at the ceiling and kept for the one poll that sends the reset" is false whenever the next hop's neighbour entry has run out at the ceiling, which is the ceiling's own principal case. smoltcp renews an entry only on ARP from that neighbour or a unicast IP packet whose source address is the entry's key (iface/interface/ipv4.rs:210, :291), and it lasts 60 s (iface/neighbor.rs:56). A connection in FIN-WAIT-2 against a silent peer sends and hears nothing for 100 s (measure-today.log: 0 segments); a peer behind the router never renews the router's entry at all. Pass N: ownerless aborts, Cut, logged. Pass N+1: iface.poll dispatches the reset, the neighbour is missing, smoltcp sends an ARP request and silences the socket for a second (iface/interface/mod.rs:746, iface/socket_meta.rs:91), the reset has not left; bridge_piped runs in the same pass, before any answer can be read off the NIC, finds Closed with the tuple kept and gone >= OWNERLESS_LIFE, answers Unsaid, removes the socket and logs "no next hop took it" of a next hop that was asked once and given no pass to answer. The peer holds its half for ever. No test can fail on this: the bench's Net::pass delivers the wire, answers ARP and polls again inside one call, which netstack's pass does not, and in both …_is_reset_at_the_ceiling rows the entry is fresh (one peer speaks every second, the other is re-asked by each FIN retransmission). Owed, the measurement first: on the bench, the peer acknowledges the FIN once and goes silent with arp true; at_the_ceiling(&mut net, |_| {}) is Cut; then netstack's pass and not the bench's, while net.iface.poll(net.now, &mut net.wire, &mut net.sockets) != PollResult::None {}, and ownerless(net.socket(), OWNERLESS_LIFE). I expect Unsaid with one ARP request on the wire and no reset. Then the fix, and a test in that shape that holds it. This is where the gap the body names under "Unsure of" lands: the decision is host-tested and the loop's order is not, and the claim rests on the order.

NOTE

  • userland/netstack/src/main.rs:448 — the absolute ceiling cuts a departed client's unsent tail (at most the 64 KiB send buffer) when delivery takes longer than 100 s. That is a compromise the branch found and it is recorded only in the body's "Unsure of"; reviewer.md Growth asks for it in issues/ with owner, evidence and an exit, or for its removal. File it. With the first BLOCKER open the rule is also uneven: a tail that fits the send buffer is cut at 100 s, and one that does not is never timed.
  • userland/netstack/src/main.rs:1433, :1815 — a client that dies while no watch is armed for it (its send pipe already closed or the send buffer full, and its receive pipe not held) is heard only on a pass something else causes, and the ceiling counts from that pass, not from the death. The listener's case of this is filed in issues/netstack-keeps-the-datagram-socket-of-a-client-that-sent-no-close.md; the bridge's is not. File it, or let the first BLOCKER's fix take it.
  • PR body, "What changed" — "At the ceiling an open socket is reset and kept for the one poll that sends the reset" and "nobody can end the connection but the wire … Now" claim more than the code does, by the two BLOCKERs.

Rulings the brief asked for

  1. A deadline, not a flat wait or a sweep. The event is spent; the ceiling is OWNERLESS_LIFE from the pass that first found both ends gone, read off std::time::Instant, and reaching it is said on the log. The wake is right by reading: ownerless_wake_in (:1467) joins the poller's timeout at :1843; after Cut the socket is Closed with its tuple kept, so poll_delay answers now (socket/tcp.rs:2560) and ownerless_wake_in answers zero; the pass after removes the bridge on either answer, so nothing spins. A wake a moment early reads Waits and asks again for what is left. No QEMU test asserts a duration; the bench steps smoltcp's clock and hands ownerless its gone as a value, which is the right tier. The read-only gap is not acceptable as it stands, for the reason in the second BLOCKER: what is unheld is the loop's order, and the order is where the defect is. A bench pass shaped as netstack's pass is the piece owed; ownerless_wake_in by itself owes no test, a reader of the diff sees it.
  2. The number. RFC 9293 §3.8.3 reads "The value of R2 SHOULD correspond to at least 100 seconds (SHLD-11)", and §3.8.6.1 leaves a connection a peer holds open "subject to the implementation's resource management concerns" (rfc/rfc9293.txt:2014, :2182). Both are quoted truly. R2 measures one segment's retransmission without progress; the branch borrows its floor for a count from the client's leaving, and its comment says which of the two it is. For FIN-WAIT-2 with no owner an absolute bound is what hosts have (Linux's tcp_fin_timeout, 60 s; from knowledge, not measured here). No finding.
  3. Absolute stands. Nothing in the tree's doctrine refuses it: the cut is loud, it is said to the peer as a reset and not as a short stream, and a slot table with no per-client share cannot be lent to a peer that acknowledges a byte at a time. Hosts do otherwise for an undelivered tail — an orphan is cut on no progress or under orphan pressure, not on a clock (from knowledge, not measured here) — so it is a difference from hosts that the tracker must hold; that is the first NOTE. The ruling is the orchestrator's, not the owner's, and the body presents it as neither.
  4. Ownerless is decided too narrowly, by the first BLOCKER: a process that exits has its ends closed by the kernel, and netstack still holds one for it. The other direction holds: a pipe end passed to another process is an owner that exists, and a live client idle on either end is the client's slot, bounded by issues/netstack-lookup-slots-have-no-per-client-share.md.
  5. The option issue is true of the code (handle_tcp_set_option :1111, handle_tcp_get_option, both ERR_NOT_CONNECTED on an id let go; a peer's reset closes both ends and the socket in one pass), says which of its rows is measured, has an owner that exists and an exit a test can fail, and fits issues/README.md. Rightly filed and not fixed: keeping the id for a client that may be dead is the leak this closes. Round 1's NOTE is closed.
  6. lan_talk is not owed as evidence. The change targets no hardware, and the row ends before any connection is 100 s without a client, so it cannot reach the ceiling either. It stays the only run of the accept side through the release and the orchestrator's own check.

Round 1's other NOTEs: the closed issue is deleted and git grep at b27bb7f0a finds its slug and its bare name nowhere; gates.txt now agrees with the logs.

Evidence at b27bb7f0a, read from the logs: ci-host.log ends Host: 77 step(s), all green and runs netstack's five new host tests (:7269–:7291); guest-churn.log and guest-iommu.log each 1 passed, exit 0; arm-control-no-ceiling.log exit 101 on the three ceiling tests; arm-m3-cut-without-the-reset.log exit 101 on the two reset tests. On GitHub nothing has run: host, toolchain and guest are SKIPPED for the draft. The landing rests on host and guest / suite at the head that lands, and neither runs the ceiling through netstack's loop.

SEND BACK

@Japabu

Japabu commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Round 2: both BLOCKERs measured red at b27bb7f0a; the fix of the first needs a kernel event that does not exist. Nothing is changed; the head is still b27bb7f0a.

What the kernel tells netstack about the other end of a pipe

  • A watch on a pipe end has two conditions and no third: toyos_abi::inbox::READABLE and WRITABLE (toyos-abi/src/inbox.rs:29). For a read end the kernel answers available > 0 || writers == 0 (kernel/src/pipe.rs:258, has_data); for a write end space > 0 || readers == 0 (:264, has_space). A poll re-reads the object and never the post (kernel/src/watch.rs, header), so the post close_write and close_read make when the last holder of an end goes (pipe.rs:293, :312) reaches a watcher only through those two levels.
  • So "the writer is gone" can be waited for on a read end only while the pipe is empty, and "the reader is gone" on a write end only while it is full. With bytes in the ring a READABLE watch completes at once and says nothing about the writer; with room a WRITABLE watch does the same about the reader.
  • As a question and not an event: a zero-byte write answers Gone whenever readers == 0 (try_write asks the count first), which is what bridge_piped already does for rx_write on every pass something else causes. The read end has no such question: with bytes in the ring every read answers bytes, and a zero-length read answers Ok(0) whether or not a writer is left.
  • The ring header's closed flags (Pipe::publish_ends) are in the page SYS_PIPE_MAP maps writable to the client, wake nobody, and bridge_piped already refuses them by name ("not the forgeable closed flags"; pipe_flag_forgery is the test).
  • The servers round 1's survey named hear a client leave as RxStep::Eof on a Connection they never stop reading (fileserver main.rs:488, compositor session.rs:683, supervisor, soundserver, diskserver). netstack is the one that stops reading a client's pipe by design, for backpressure, and its protocol keeps no connection per client: one request, one answer, closed.

What is missing, exactly: a watch condition on a pipe end that is ready when the other end has no holder left, whatever the ring holds: writers == 0 on a read end, readers == 0 on a write end. The kernel holds both counts and already posts both watches at that transition; what the ABI lacks is the flag that asks for it. With it, ownerless is one event for both ends and the read-to-EOF inference goes. Without it, every way to decide "the client is gone" for a pipe netstack is not reading is a poll, a timer, the forgeable flags, or draining a dead-or-alive client's bytes into memory to find the end. That is an ABI question for the owner, so I stopped here as briefed.

Case (a) alone has a userland road, and it is the inference again: keep reading the send pipe after the sending half is shut. It needs a decision about bytes found there, and it does nothing for (b).

BLOCKER 1, red

Host, the bench (measure-host.patch, cargo test --manifest-path userland/netstack/Cargo.toml --target <host> -- measure_, exit 101, measure-host.log): send_room, the only condition under which the send pipe is read or watched, a second at a time for an hour.

  • (a) after close() with the peer acknowledging the FIN and holding: no send room in an hour, state FIN-WAIT-2: the send pipe is never read.
  • (b) send buffer full, peer acknowledges nothing: no send room in an hour, state ESTABLISHED, 2604 segment(s) sent: the send pipe is never read.

Guest, netstack's own loop (measure-guest.patch, cargo test --test toyos-build -- netstack_socket_churn, exit 1 by construction, measure-guest.log): the host server holds every connection; each arm drops both pipe ends with no close request and asks net.piped.live until it returns or 130 s pass.

MEASURE both ends dropped: piped.live back after Some(100.018508853s) (None: still held 130s after the drop)
MEASURE shutdown(1), then both ends dropped: piped.live back after None (None: still held 130s after the drop)

BLOCKER 2, red twice

Host, in the reviewer's shape (same patch and log): the peer acknowledges the FIN once and goes silent, arp true, at_the_ceiling answers Cut, then iface.poll until PollResult::None and ownerless:

one netstack pass after the cut, with the far end answering ARP: frames on the wire [Arp], TCP segments since the cut 0
  left: Unsaid
 right: Over

Guest, the control arm of the run above, against QEMU's user network, whose gateway answers ARP. netstack said, in this order:

netstack: resetting a connection — its client left 100s ago and its peer has not finished it
netstack: letting a connection go with its reset unsent — no next hop took it

The host's end of that connection was never reset. This is the ordinary case of the ceiling, not a corner: every connection cut at 100 s of silence has a neighbour entry 40 s past smoltcp's 60 s lifetime (iface/neighbor.rs:56).

Its fix does not wait on the kernel question and I have not built it, since the brief says to stop: keep the cut socket until spent or a second bound after the cut, whichever is first, and say which. smoltcp asks a missing neighbour once a second (neighbor.rs:53, SILENT_TIME) and has no retry count of its own to cite, so the bound's number needs a source; RFC 1122 §2.3.2.1 recommends at most one ARP request a second per destination and, as I remember it, gives no count either (from knowledge, the text was not read this round).

Found on the way, read and not measured

handle_tcp_shutdown calls socket.close() while the client's send pipe may still hold bytes written before the shutdown; send_room is false from then on, so those bytes are never sent and the peer's stream ends short with a clean FIN. Not filed: the tree is untouched this round.

The patches

measure-host.patch:

diff --git a/userland/netstack/src/listen/tests.rs b/userland/netstack/src/listen/tests.rs
index 240f6b2df..413b27be0 100644
--- a/userland/netstack/src/listen/tests.rs
+++ b/userland/netstack/src/listen/tests.rs
@@ -440,3 +440,77 @@ fn a_peer_that_answers_and_never_closes_is_reset_at_the_ceiling() {
     assert_eq!(at_the_ceiling(&mut net, answer), Ownerless::Cut, "the slot of a connection with no client never comes back");
     the_cut_is_said(&mut net);
 }
+
+/// MEASUREMENT (review round 2, BLOCKER 2): the pass after `Cut`, shaped as
+/// netstack's loop and not as the bench's: poll until nothing moves, then ask.
+#[test]
+fn measure_the_pass_after_the_cut_with_the_neighbour_entry_run_out() {
+    let mut net = Net::new();
+    let isn = established(&mut net, 5001);
+    net.socket().close();
+    net.pass();
+    net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 2));
+    assert_eq!(at_the_ceiling(&mut net, |_| {}), Ownerless::Cut);
+    let said = net.sent.len();
+    while net.iface.poll(net.now, &mut net.wire, &mut net.sockets) != PollResult::None {}
+    let answer = crate::ownerless(net.socket(), OWNERLESS_LIFE);
+    let frames: Vec<_> = net
+        .wire
+        .outbound
+        .iter()
+        .map(|f| EthernetFrame::new_checked(&f[..]).unwrap().ethertype())
+        .collect();
+    assert_eq!(
+        answer,
+        Ownerless::Over,
+        "one netstack pass after the cut, with the far end answering ARP: frames on the wire {frames:?}, TCP segments \
+         since the cut {}",
+        net.sent.len() - said
+    );
+}
+
+/// MEASUREMENT (BLOCKER 1a): whether the bridge would ever read the client's
+/// send pipe again after `TcpShutdown` with `how` 1, the peer holding its half.
+#[test]
+fn measure_send_room_after_a_shutdown_of_the_sending_half() {
+    let mut net = Net::new();
+    let isn = established(&mut net, 5001);
+    net.socket().close();
+    net.pass();
+    net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 2));
+    let mut room = None;
+    for second in 0..3600u64 {
+        net.pass();
+        if crate::send_room(net.socket()) {
+            room = Some(second);
+            break;
+        }
+        net.now += smoltcp::time::Duration::from_secs(1);
+    }
+    assert!(room.is_some(), "no send room in an hour, state {}: the send pipe is never read", net.socket().state());
+}
+
+/// MEASUREMENT (BLOCKER 1b): the same with the send buffer full and a peer
+/// that acknowledges nothing.
+#[test]
+fn measure_send_room_with_a_full_buffer_and_a_peer_that_acknowledges_nothing() {
+    let mut net = Net::new();
+    established(&mut net, 5001);
+    let filled = net.socket().send_slice(&[0u8; 4096]).unwrap();
+    assert_eq!(filled, 4096, "the premise: the bench's send buffer is full");
+    let (mut room, said) = (None, net.sent.len());
+    for second in 0..3600u64 {
+        net.pass();
+        if crate::send_room(net.socket()) {
+            room = Some(second);
+            break;
+        }
+        net.now += smoltcp::time::Duration::from_secs(1);
+    }
+    assert!(
+        room.is_some(),
+        "no send room in an hour, state {}, {} segment(s) sent: the send pipe is never read",
+        net.socket().state(),
+        net.sent.len() - said
+    );
+}

measure-guest.patch:

diff --git a/tests/toyos-rust-tests/src/bin/netstack_socket_churn.rs b/tests/toyos-rust-tests/src/bin/netstack_socket_churn.rs
index 74a20a83c..5f69bc1ef 100644
--- a/tests/toyos-rust-tests/src/bin/netstack_socket_churn.rs
+++ b/tests/toyos-rust-tests/src/bin/netstack_socket_churn.rs
@@ -34,6 +34,28 @@ fn main() {
         .nth(1)
         .and_then(|p| p.parse().ok())
         .expect("usage: netstack_socket_churn <host port>");
+    // MEASUREMENT (review round 2, BLOCKER 1a): the host holds every connection
+    // it accepts. Each arm drops both pipe ends with no close request; the
+    // second shuts its sending half down first.
+    let held = count("net.piped.live");
+    for (arm, shut) in [("both ends dropped", false), ("shutdown(1), then both ends dropped", true)] {
+        let conn = toyos::net::tcp_connect(HOST, port, 0).unwrap_or_else(|e| panic!("{arm}: connect: {e:?}"));
+        if shut {
+            toyos::net::tcp_shutdown(conn.socket_id, 1).unwrap_or_else(|e| panic!("{arm}: shutdown: {e:?}"));
+        }
+        drop(conn);
+        let asked = Instant::now();
+        let back = loop {
+            if count("net.piped.live") == held {
+                break Some(asked.elapsed());
+            }
+            if asked.elapsed() > Duration::from_secs(130) {
+                break None;
+            }
+        };
+        println!("MEASURE {arm}: piped.live back after {back:?} (None: still held 130s after the drop)");
+    }
+    assert_eq!(port, 0, "MEASUREMENT over");
     let (streams, live) = (count("net.sockets.tcp"), count("net.piped.live"));
     let untabled = count("net.sockets.untabled");
     for round in 1..=ROUNDS {
diff --git a/tests/toyos.rs b/tests/toyos.rs
index ce1f2c55d..8ad7c5488 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -2720,14 +2720,19 @@ fn netstack_socket_churn() -> Result<(), String> {
     let server = std::net::TcpListener::bind(("127.0.0.1", 0)).map_err(|e| format!("the host server: {e}"))?;
     let port = server.local_addr().map_err(|e| format!("the host server's port: {e}"))?.port();
     // Ends with the process: a guest that never dials leaves it in `accept`.
-    thread::spawn(move || server.incoming().for_each(drop));
+    thread::spawn(move || {
+        let mut held = Vec::new();
+        for stream in server.incoming() {
+            held.push(stream);
+        }
+    });
 
     let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &compile::repo_root().join("tests/toyos-rust-tests"), JOB);
     let case = compile::repo_root().join("tests/netcase");
     let mut qemu = QemuInstance::boot_with_options(&case, &[], &[(JOB.to_string(), bin)], BootOptions::default());
     let mut console = qemu.boot_log().to_string();
     await_marker(&mut qemu, &mut console, LEASED, "netstack's lease").map_err(|e| format!("{e}\n{console}"))?;
-    let result = qemu.run_test(&format!("test_rs_netstack_socket_churn {port}"), Duration::from_secs(120));
+    let result = qemu.run_test(&format!("test_rs_netstack_socket_churn {port}"), Duration::from_secs(400));
     if let Some(why) = &result.error {
         return Err(format!("{why}\nthe job said:\n{}", result.stdout));
     }

Both were applied as checked patches and reversed; git status --porcelain --ignore-submodules=none printed nothing after each.

Japabu and others added 2 commits October 8, 2026 11:38
…nerless rests on it

Review round 2 of #760 measured two defects in the 100-second bound on a
connection with no client.

The first: netstack decided a client was gone by reading its send pipe to the
end of file, and it reads that pipe only while the socket takes bytes. A
client that shut its sending half down and exited, or filled the send buffer
against a silent peer and exited, was never read again, never ownerless, and
held its slot for the life of netstack. The kernel had no word for it: a watch
on a pipe end knew READABLE (bytes, or no writer and an empty ring) and
WRITABLE (room, or no reader), so "the writer is gone" could be waited for
only on an empty pipe. The owner ruled the event in.

toyos_abi::inbox::OTHER_END_GONE is a third OP_WATCH condition, of a pipe end
only: ready when the pipe's other end has no holder left, whatever the ring
holds, level-triggered like its siblings. The kernel already kept both counts
and already posted the end's own watch at that transition; the condition is
one more question the look asks, registered on that same watch. An object
that is no pipe end is refused the question. The counts and the three answers
moved to kernel/pure/pipe.rs, where the host runs their cases.
toyos::poller::Poller::wait_answers hands a caller what its watch was
answered beside the token.

netstack watches both of a connection's pipes for it for as long as it holds
them, keyed by socket id: an answer names its connection, and a connection
let go since is no place in a list another has taken. A receive pipe with no
reader is closed; a send pipe with no writer is still drained while the
socket takes bytes. The zero-byte write that asked the receive pipe on every
pass is gone.

The second: at the ceiling the reset was given one poll, and a connection
silent for 100 seconds has outlived its next hop's 60-second neighbour entry,
so that poll sent an ARP request and the socket was removed with the reset
unsent. A cut connection is now kept until the reset has left or RESET_LIFE,
three seconds, is over: RFC 4861's budget for address resolution, three
solicitations a second apart. The log says which.

Filed: the absolute ceiling cuts a departed client's unsent tail, and a
shutdown of the sending half drops what the send pipe still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu Japabu changed the title netstack lets a piped connection's socket and id go with it, and one with no client left has 100 seconds to finish A pipe watch can ask whether the other end is gone; netstack lets a connection with no client go after 100 seconds, its socket and id with it, and keeps a cut one until its reset leaves Oct 8, 2026
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Round 3 patches, all against e7a40645f. Each control and mutation was checked, applied, run and reversed by controls.sh; measure-guest.patch by the orchestrator's build request. Exits and what each was red on are in the body.

c1-kernel-never-answers.patch:

diff --git a/kernel/src/pipe.rs b/kernel/src/pipe.rs
index de4190997..013246c7d 100644
--- a/kernel/src/pipe.rs
+++ b/kernel/src/pipe.rs
@@ -271,7 +271,7 @@ pub fn has_space(pipe_id: PipeId) -> bool {
 
 /// Whether the end opposite `end` of this pipe has no holder left.
 pub fn other_end_gone(pipe_id: PipeId, end: End) -> bool {
-    with_pipes(|pipes| pipes.get(pipe_id).is_some_and(|p| p.holders.other_end_gone(end)))
+    with_pipes(|pipes| pipes.get(pipe_id).is_some_and(|p| p.holders.other_end_gone(end) && false))
 }
 
 /// Mark the pipe so the next consumer inherits RT priority.

c2-kernel-reverted-to-main.patch:

diff --git a/kernel/pure/lib.rs b/kernel/pure/lib.rs
index f3a288bec..86f6f211f 100644
--- a/kernel/pure/lib.rs
+++ b/kernel/pure/lib.rs
@@ -1,8 +1,7 @@
 //! What the kernel decides without touching the machine: the scheduler core
 //! ([`sched`]), the process and thread lifecycle ([`proclife`]), which PCID an
-//! address space is handed ([`pcid`]), what type the range registers give a
-//! range ([`mtrr`]) and what a pipe's ends are told of each other ([`pipe`]).
-//! The kernel binary links it; the host runs
+//! address space is handed ([`pcid`]) and what type the range registers give a
+//! range ([`mtrr`]). The kernel binary links it; the host runs
 //! its tests, because none of it reads a register, a clock or a kernel lock.
 
 #![no_std]
@@ -14,6 +13,5 @@ extern crate std;
 
 pub mod mtrr;
 pub mod pcid;
-pub mod pipe;
 pub mod proclife;
 pub mod sched;
diff --git a/kernel/pure/pipe.rs b/kernel/pure/pipe.rs
deleted file mode 100644
index 76b2b39ac..000000000
--- a/kernel/pure/pipe.rs
+++ /dev/null
@@ -1,166 +0,0 @@
-//! Who holds a pipe's two ends, and what a watch on one of them is told.
-//!
-//! A pipe end is held by counted references, one per object that names it: a
-//! handle duplicated or moved to another process is the same object or another
-//! reference, so an end is gone only when the last of them is. Every answer
-//! here is a function of the two counts and of what the ring holds, read under
-//! the kernel's pipe lock.
-
-#![forbid(unsafe_code)]
-
-/// One end of a pipe.
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub enum End {
-    Read,
-    Write,
-}
-
-impl End {
-    pub fn other(self) -> Self {
-        match self {
-            Self::Read => Self::Write,
-            Self::Write => Self::Read,
-        }
-    }
-}
-
-/// What a reference's release left of its pipe.
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-#[must_use = "a release that left an end unheld owes the other end a post"]
-pub enum Released {
-    /// Its end has another holder.
-    Held,
-    /// It was its end's last holder: the other end's watch is owed a post.
-    EndGone,
-    /// It was the pipe's last holder.
-    PipeGone,
-}
-
-/// How many references hold each end of one pipe.
-#[derive(Default)]
-pub struct Holders {
-    readers: u32,
-    writers: u32,
-}
-
-impl Holders {
-    pub const fn new() -> Self {
-        Self { readers: 0, writers: 0 }
-    }
-
-    fn count(&mut self, end: End) -> &mut u32 {
-        match end {
-            End::Read => &mut self.readers,
-            End::Write => &mut self.writers,
-        }
-    }
-
-    pub fn hold(&mut self, end: End) {
-        let count = self.count(end);
-        *count = count.checked_add(1).expect("pipe holder overflow");
-    }
-
-    pub fn release(&mut self, end: End) -> Released {
-        let count = self.count(end);
-        *count = count.checked_sub(1).expect("pipe holder underflow");
-        match (self.readers, self.writers) {
-            (0, 0) => Released::PipeGone,
-            _ if !self.held(end) => Released::EndGone,
-            _ => Released::Held,
-        }
-    }
-
-    pub fn held(&self, end: End) -> bool {
-        match end {
-            End::Read => self.readers > 0,
-            End::Write => self.writers > 0,
-        }
-    }
-
-    /// A read answers now: with bytes, or with the end of a stream no writer
-    /// can add to.
-    pub fn readable(&self, available: u32) -> bool {
-        available > 0 || !self.held(End::Write)
-    }
-
-    /// A write answers now: it takes bytes, or is refused for want of a reader.
-    pub fn writable(&self, space: u32) -> bool {
-        space > 0 || !self.held(End::Read)
-    }
-
-    /// The end opposite `end` has no holder, whatever the ring holds.
-    pub fn other_end_gone(&self, end: End) -> bool {
-        !self.held(end.other())
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    /// A pipe as `pipe::create` leaves it: one holder of each end.
-    fn made() -> Holders {
-        let mut pipe = Holders::new();
-        pipe.hold(End::Read);
-        pipe.hold(End::Write);
-        pipe
-    }
-
-    #[test]
-    fn an_end_whose_other_end_is_held_is_not_told_it_is_gone() {
-        let pipe = made();
-        assert!(!pipe.other_end_gone(End::Read));
-        assert!(!pipe.other_end_gone(End::Write));
-    }
-
-    /// **Whatever the ring holds.** `readable` answers for the bytes and says
-    /// nothing of the writer; `writable` for the room and nothing of the reader.
-    #[test]
-    fn an_end_dropped_with_the_ring_neither_empty_nor_full_is_gone() {
-        for (available, space) in [(0, 8), (3, 5), (8, 0)] {
-            let mut pipe = made();
-            assert_eq!(pipe.release(End::Write), Released::EndGone);
-            assert!(pipe.other_end_gone(End::Read), "the writer left with {available} byte(s) in the ring");
-            assert!(pipe.readable(available));
-            assert!(!pipe.other_end_gone(End::Write), "a reader that is held was told gone");
-
-            let mut pipe = made();
-            assert_eq!(pipe.release(End::Read), Released::EndGone);
-            assert!(pipe.other_end_gone(End::Write), "the reader left with room for {space} byte(s)");
-            assert!(pipe.writable(space));
-            assert!(!pipe.other_end_gone(End::Read), "a writer that is held was told gone");
-        }
-    }
-
-    /// The two older conditions do not stand in for it: each is ready with the
-    /// other end held.
-    #[test]
-    fn readable_and_writable_say_nothing_of_the_other_end() {
-        let pipe = made();
-        assert!(pipe.readable(1) && !pipe.other_end_gone(End::Read));
-        assert!(pipe.writable(1) && !pipe.other_end_gone(End::Write));
-        assert!(!pipe.readable(0) && !pipe.writable(0));
-    }
-
-    /// A handle duplicated, or moved to another process, is a second
-    /// reference: the end is gone with the last of them and not before.
-    #[test]
-    fn an_end_with_a_second_holder_outlives_the_first() {
-        for end in [End::Read, End::Write] {
-            let mut pipe = made();
-            pipe.hold(end);
-            assert_eq!(pipe.release(end), Released::Held);
-            assert!(!pipe.other_end_gone(end.other()), "{end:?} was told gone with a holder left");
-            assert_eq!(pipe.release(end), Released::EndGone);
-            assert!(pipe.other_end_gone(end.other()));
-        }
-    }
-
-    /// The pipe's last reference owes nobody a post: no end is left to watch.
-    #[test]
-    fn the_last_holder_of_the_pipe_ends_it() {
-        let mut pipe = made();
-        assert_eq!(pipe.release(End::Read), Released::EndGone);
-        assert_eq!(pipe.release(End::Write), Released::PipeGone);
-    }
-}
diff --git a/kernel/src/inbox/mod.rs b/kernel/src/inbox/mod.rs
index 32310a66a..dc3851599 100644
--- a/kernel/src/inbox/mod.rs
+++ b/kernel/src/inbox/mod.rs
@@ -4,7 +4,7 @@
 //! into both kernel and userspace. `OP_WATCH` fires once; userspace re-submits to re-arm.
 //!
 //! **A watch is a poll registered on the watched object's own
-//! [`Watch`](crate::watch::Watch)**, one entry per watch its interest names, and
+//! [`Watch`](crate::watch::Watch)**, one entry per direction it asked for, and
 //! the object's post fires it. There is no table of sources here: what a
 //! handle watches is `ops::read_watch`/`ops::write_watch`'s answer, and the
 //! poll holds no reference to the object at all.
@@ -109,11 +109,10 @@ pub struct WatchFlags(u32);
 impl WatchFlags {
     pub const READABLE: Self = Self(toyos_abi::inbox::READABLE);
     pub const WRITABLE: Self = Self(toyos_abi::inbox::WRITABLE);
-    pub const OTHER_END_GONE: Self = Self(toyos_abi::inbox::OTHER_END_GONE);
     /// Every bit `toyos_abi::inbox` defines for `Submission::op_flags`;
     /// hand-copied and unchecked, for the reason `syscall/vm.rs`'s
     /// `MMAP_PROT_KNOWN` gives for all four of these masks.
-    const KNOWN: u32 = Self::READABLE.0 | Self::WRITABLE.0 | Self::OTHER_END_GONE.0;
+    const KNOWN: u32 = Self::READABLE.0 | Self::WRITABLE.0;
 
     /// A bit outside [`Self::KNOWN`] is an interest this kernel would register
     /// for neither direction, so the whole watch is refused rather than served.
@@ -125,7 +124,6 @@ impl WatchFlags {
     }
     pub fn readable(self) -> bool { self.0 & Self::READABLE.0 != 0 }
     pub fn writable(self) -> bool { self.0 & Self::WRITABLE.0 != 0 }
-    pub fn other_end_gone(self) -> bool { self.0 & Self::OTHER_END_GONE.0 != 0 }
     pub fn raw(self) -> u32 { self.0 }
 }
 
@@ -136,7 +134,6 @@ impl WatchFlags {
 struct Readiness {
     readable: bool,
     writable: bool,
-    other_end_gone: bool,
 }
 
 impl Readiness {
@@ -144,12 +141,11 @@ impl Readiness {
         let mut flags = 0u32;
         if self.readable { flags |= WatchFlags::READABLE.raw(); }
         if self.writable { flags |= WatchFlags::WRITABLE.raw(); }
-        if self.other_end_gone { flags |= WatchFlags::OTHER_END_GONE.raw(); }
         flags
     }
 
     fn any(self) -> bool {
-        self.readable || self.writable || self.other_end_gone
+        self.readable || self.writable
     }
 }
 
@@ -536,17 +532,8 @@ fn arm(
     let ready = readiness_of(object, flags).any();
     let read = if flags.readable() { ops::read_watch(object) } else { None };
     let write = if flags.writable() { ops::write_watch(object) } else { None };
-    // A pipe end's own watch is the one its other end's last holder posts;
-    // an object with no other end is refused the question, whatever else the
-    // watch asks. A direction asked above has taken that watch already.
-    let own = match flags.other_end_gone().then(|| ops::pipe_end_watch(object)) {
-        Some(None) => return Err(SyscallError::NotSupported),
-        Some(Some(_)) if read.is_some() || write.is_some() => None,
-        Some(own) => own,
-        None => None,
-    };
-    // Nothing its interest names: nothing could ever answer this poll, so it is refused, not registered.
-    if !ready && read.is_none() && write.is_none() && own.is_none() {
+    // No readiness in either direction: nothing could ever answer this poll, so it is refused, not registered.
+    if !ready && read.is_none() && write.is_none() {
         return Err(SyscallError::NotSupported);
     }
 
@@ -579,21 +566,17 @@ fn arm(
     if let Some(watch) = &write {
         watch.add_poll(PollEntry { poll: poll.clone(), direction: WatchFlags::WRITABLE });
     }
-    if let Some(watch) = &own {
-        watch.add_poll(PollEntry { poll: poll.clone(), direction: WatchFlags::OTHER_END_GONE });
-    }
     if readiness_of(object, flags).any() {
         poll.fire(0);
     }
     Ok(())
 }
 
-/// What the object answers each condition, restricted to what was asked for.
+/// Per-direction readiness of the object, restricted to what was asked for.
 fn readiness_of(object: &KObjectRef, flags: WatchFlags) -> Readiness {
     Readiness {
         readable: flags.readable() && ops::has_data(object),
         writable: flags.writable() && ops::has_space(object),
-        other_end_gone: flags.other_end_gone() && ops::other_end_gone(object),
     }
 }
 
diff --git a/kernel/src/object/ops.rs b/kernel/src/object/ops.rs
index c1b1e1e2d..3ed962a68 100644
--- a/kernel/src/object/ops.rs
+++ b/kernel/src/object/ops.rs
@@ -15,7 +15,6 @@ use crate::drivers::serial;
 use crate::file_cache;
 use crate::time::Deadline;
 use crate::pipe::{self, PipeId};
-use kernel::pipe::End;
 use crate::process::PipeMap;
 use crate::user_ptr::{UserBytes, UserBytesMut};
 use crate::inbox::PollEntry;
@@ -313,32 +312,6 @@ pub fn write_watch(object: &KObjectRef) -> Option<WatchRef> {
     }
 }
 
-/// The watch of a pipe end, which its other end's last holder posts as it
-/// lets go; `None` for an object that is no pipe end.
-pub fn pipe_end_watch(object: &KObjectRef) -> Option<WatchRef> {
-    match object {
-        KObjectRef::PipeRead(_) => read_watch(object),
-        KObjectRef::PipeWrite(_) => write_watch(object),
-        KObjectRef::Connection(_) | KObjectRef::Acceptor(_) | KObjectRef::Process(_)
-        | KObjectRef::Console(_) | KObjectRef::Device(_) | KObjectRef::SysCap(_)
-        | KObjectRef::File(_) | KObjectRef::Inbox(_) | KObjectRef::Connector(_)
-        | KObjectRef::Namespace(_) | KObjectRef::SharedMem(_) => None,
-    }
-}
-
-/// Whether this pipe end's other end has no holder left; `false` for an
-/// object that is no pipe end.
-pub fn other_end_gone(object: &KObjectRef) -> bool {
-    match object {
-        KObjectRef::PipeRead(r) => pipe::other_end_gone(r.id(), End::Read),
-        KObjectRef::PipeWrite(w) => pipe::other_end_gone(w.id(), End::Write),
-        KObjectRef::Connection(_) | KObjectRef::Acceptor(_) | KObjectRef::Process(_)
-        | KObjectRef::Console(_) | KObjectRef::Device(_) | KObjectRef::SysCap(_)
-        | KObjectRef::File(_) | KObjectRef::Inbox(_) | KObjectRef::Connector(_)
-        | KObjectRef::Namespace(_) | KObjectRef::SharedMem(_) => false,
-    }
-}
-
 /// Whether closing one handle to this object ends what its watches watch, so
 /// every poll on them — in any ring — is answered as gone. `false` for the log
 /// and the keyboard, which the machine ends on its own and which other handles
diff --git a/kernel/src/pipe.rs b/kernel/src/pipe.rs
index de4190997..b0c8122a7 100644
--- a/kernel/src/pipe.rs
+++ b/kernel/src/pipe.rs
@@ -1,6 +1,5 @@
 use crate::mm::pmm;
 
-use kernel::pipe::{End, Holders, Released};
 use toyos_abi::ring::Ring;
 
 use alloc::sync::Arc;
@@ -40,7 +39,7 @@ pub use handle::{PipeReader, PipeWriter};
 /// take a counted reference, so no program point exists between the two where
 /// the last other end could close and free it.
 mod handle {
-    use super::{release, with_pipes_mut, End, Pipe, PipeId};
+    use super::{close_read, close_write, with_pipes_mut, Pipe, PipeId};
 
     /// One counted reference to a pipe's read end — `Arc`, for a reader slot.
     pub struct PipeReader(PipeId);
@@ -51,7 +50,8 @@ mod handle {
     impl PipeReader {
         /// The only constructor: taking and counting the reference is one statement.
         pub(super) fn acquire(pipe: &mut Pipe) -> Self {
-            pipe.hold(End::Read);
+            pipe.readers = pipe.readers.checked_add(1).expect("pipe reader overflow");
+            pipe.publish_ends();
             Self(pipe.id)
         }
 
@@ -60,7 +60,8 @@ mod handle {
 
     impl PipeWriter {
         pub(super) fn acquire(pipe: &mut Pipe) -> Self {
-            pipe.hold(End::Write);
+            pipe.writers = pipe.writers.checked_add(1).expect("pipe writer overflow");
+            pipe.publish_ends();
             Self(pipe.id)
         }
 
@@ -86,13 +87,13 @@ mod handle {
 
     impl Drop for PipeReader {
         fn drop(&mut self) {
-            release(self.0, End::Read);
+            close_read(self.0);
         }
     }
 
     impl Drop for PipeWriter {
         fn drop(&mut self) {
-            release(self.0, End::Write);
+            close_write(self.0);
         }
     }
 }
@@ -112,7 +113,8 @@ struct Pipe {
     id: PipeId,
     /// `None` until first use — allocating eagerly would charge every pending `SYS_CONNECT` 4 MiB before either end sent a byte.
     backing: Option<Backing>,
-    holders: Holders,
+    readers: u32,
+    writers: u32,
     /// The read end's and the write end's watches. Held by `Arc` so a blocking site or a
     /// poll registration can clone one out from under the table lock and hold it across its own park.
     read_watch: Arc<Watch>,
@@ -129,7 +131,8 @@ impl Pipe {
         Self {
             id,
             backing: None,
-            holders: Holders::new(),
+            readers: 0,
+            writers: 0,
             read_watch: Arc::new(Watch::new()),
             write_watch: Arc::new(Watch::new()),
             rt_boost_pending: false,
@@ -151,13 +154,8 @@ impl Pipe {
     /// Republish "is the other end gone?" into the mapped header, for netstack; the kernel itself decides from its own counts.
     fn publish_ends(&mut self) {
         let Some(backing) = self.backing.as_mut() else { return };
-        if self.holders.held(End::Read) { backing.ring.open_reader() } else { backing.ring.close_reader() }
-        if self.holders.held(End::Write) { backing.ring.open_writer() } else { backing.ring.close_writer() }
-    }
-
-    fn hold(&mut self, end: End) {
-        self.holders.hold(end);
-        self.publish_ends();
+        if self.readers == 0 { backing.ring.close_reader() } else { backing.ring.open_reader() }
+        if self.writers == 0 { backing.ring.close_writer() } else { backing.ring.open_writer() }
     }
 
     fn available(&self) -> u32 {
@@ -220,7 +218,7 @@ pub fn try_read(pipe_id: PipeId, buf: &mut UserBytesMut) -> Option<usize> {
             let boost = pipe.rt_boost_pending;
             pipe.rt_boost_pending = false;
             (Some(n), boost)
-        } else if !pipe.holders.held(End::Write) {
+        } else if pipe.writers == 0 {
             (Some(0), false)
         } else {
             (None, false)
@@ -243,7 +241,7 @@ pub enum PipeWrite {
 pub fn try_write(pipe_id: PipeId, buf: &UserBytes) -> Option<PipeWrite> {
     with_pipes_mut(|pipes| {
         let pipe = pipes.get_mut(pipe_id)?;
-        if !pipe.holders.held(End::Read) {
+        if pipe.readers == 0 {
             return Some(PipeWrite::BrokenPipe);
         }
         let Some(backing) = pipe.back() else {
@@ -259,21 +257,16 @@ pub fn try_write(pipe_id: PipeId, buf: &UserBytes) -> Option<PipeWrite> {
 
 pub fn has_data(pipe_id: PipeId) -> bool {
     with_pipes(|pipes| {
-        pipes.get(pipe_id).is_some_and(|p| p.holders.readable(p.available()))
+        pipes.get(pipe_id).is_some_and(|p| p.available() > 0 || p.writers == 0)
     })
 }
 
 pub fn has_space(pipe_id: PipeId) -> bool {
     with_pipes(|pipes| {
-        pipes.get(pipe_id).is_some_and(|p| p.holders.writable(p.space()))
+        pipes.get(pipe_id).is_some_and(|p| p.space() > 0 || p.readers == 0)
     })
 }
 
-/// Whether the end opposite `end` of this pipe has no holder left.
-pub fn other_end_gone(pipe_id: PipeId, end: End) -> bool {
-    with_pipes(|pipes| pipes.get(pipe_id).is_some_and(|p| p.holders.other_end_gone(end)))
-}
-
 /// Mark the pipe so the next consumer inherits RT priority.
 pub fn set_rt_boost_pending(pipe_id: PipeId) {
     with_pipes_mut(|pipes| {
@@ -283,22 +276,41 @@ pub fn set_rt_boost_pending(pipe_id: PipeId) {
     });
 }
 
-/// One reference to `end` lets go; its end's last posts the other end's watch.
-fn release(pipe_id: PipeId, end: End) {
-    let released = with_pipes_mut(|pipes| {
-        let pipe = pipes.get_mut(pipe_id).expect("release: pipe not found");
-        let released = pipe.holders.release(end);
+fn close_read(pipe_id: PipeId) {
+    // `true` when the pipe still lives and its write end is now the one to wake.
+    let wake_writers = with_pipes_mut(|pipes| {
+        let pipe = pipes.get_mut(pipe_id).expect("close_read: pipe not found");
+        pipe.readers = pipe.readers.checked_sub(1).expect("pipe reader underflow");
+        pipe.publish_ends();
+        if pipe.readers == 0 && pipe.writers == 0 {
+            let pipe = pipes.remove(pipe_id).unwrap();
+            free_pipe(pipe);
+            false // pipe freed, no one to wake
+        } else {
+            pipe.readers == 0
+        }
+    });
+    if wake_writers {
+        crate::scheduler::wake_pipe_writers(pipe_id);
+    }
+}
+
+fn close_write(pipe_id: PipeId) {
+    // `true` when the pipe still lives and its read end is now the one to wake.
+    let wake_readers = with_pipes_mut(|pipes| {
+        let pipe = pipes.get_mut(pipe_id).expect("close_write: pipe not found");
+        pipe.writers = pipe.writers.checked_sub(1).expect("pipe writer underflow");
         pipe.publish_ends();
-        if released == Released::PipeGone {
+        if pipe.readers == 0 && pipe.writers == 0 {
             let pipe = pipes.remove(pipe_id).unwrap();
             free_pipe(pipe);
+            false // pipe freed, no one to wake
+        } else {
+            pipe.writers == 0
         }
-        released
     });
-    match (released, end) {
-        (Released::Held | Released::PipeGone, _) => {}
-        (Released::EndGone, End::Read) => crate::scheduler::wake_pipe_writers(pipe_id),
-        (Released::EndGone, End::Write) => crate::scheduler::wake_pipe_readers(pipe_id),
+    if wake_readers {
+        crate::scheduler::wake_pipe_readers(pipe_id);
     }
 }
 

m1-pure-asks-its-own-end.patch:

diff --git a/kernel/pure/pipe.rs b/kernel/pure/pipe.rs
index 76b2b39ac..065a4132b 100644
--- a/kernel/pure/pipe.rs
+++ b/kernel/pure/pipe.rs
@@ -90,7 +90,7 @@ impl Holders {
 
     /// The end opposite `end` has no holder, whatever the ring holds.
     pub fn other_end_gone(&self, end: End) -> bool {
-        !self.held(end.other())
+        !self.held(end)
     }
 }
 

m2-kernel-gone-only-when-empty.patch:

diff --git a/kernel/src/pipe.rs b/kernel/src/pipe.rs
index de4190997..a57fc19fd 100644
--- a/kernel/src/pipe.rs
+++ b/kernel/src/pipe.rs
@@ -271,7 +271,7 @@ pub fn has_space(pipe_id: PipeId) -> bool {
 
 /// Whether the end opposite `end` of this pipe has no holder left.
 pub fn other_end_gone(pipe_id: PipeId, end: End) -> bool {
-    with_pipes(|pipes| pipes.get(pipe_id).is_some_and(|p| p.holders.other_end_gone(end)))
+    with_pipes(|pipes| pipes.get(pipe_id).is_some_and(|p| p.holders.other_end_gone(end) && p.available() == 0))
 }
 
 /// Mark the pipe so the next consumer inherits RT priority.

m3-netstack-no-reset-bound.patch:

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index fd74c9de5..4b4421810 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -507,7 +507,7 @@ enum Ownerless {
 fn ownerless(socket: &mut tcp::Socket, waited: Duration, cut: bool) -> Ownerless {
     if spent(socket) {
         Ownerless::Over
-    } else if waited < if cut { RESET_LIFE } else { OWNERLESS_LIFE } {
+    } else if waited < if cut { Duration::ZERO } else { OWNERLESS_LIFE } {
         Ownerless::Waits
     } else if cut {
         Ownerless::Unsaid

m5-netstack-never-asks-the-send-pipe.patch:

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index fd74c9de5..cce7bd151 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -1876,7 +1876,7 @@ fn main() {
             // room wakes the NIC. Its writer's leaving is asked until answered,
             // for the same reason: it stays so.
             let room = send_room(socket_set.get::<tcp::Socket>(conn.handle));
-            let send = if room { READABLE } else { 0 } | if conn.writer_gone { 0 } else { OTHER_END_GONE };
+            let send = if room { READABLE } else { 0 };
             if let (true, Some(pipe)) = (send != 0, &conn.tx_read) {
                 poller.watch(pipe, send, TOKEN_SEND_PIPE | u64::from(conn.socket_id));
             }

measure-guest.patch:

diff --git a/tests/toyos-rust-tests/src/bin/netstack_socket_churn.rs b/tests/toyos-rust-tests/src/bin/netstack_socket_churn.rs
index ca405615d..4233af45c 100644
--- a/tests/toyos-rust-tests/src/bin/netstack_socket_churn.rs
+++ b/tests/toyos-rust-tests/src/bin/netstack_socket_churn.rs
@@ -65,6 +65,41 @@ fn main() {
             .expect("usage: netstack_socket_churn <ending host port> <holding host port>")
     };
     let (port, holding) = (port(1), port(2));
+    // MEASUREMENT (review round 2, BLOCKER 1 and 2): the holding server keeps
+    // every connection and reads nothing. Each arm drops both pipe ends with
+    // no close request and asks `net.piped.live` until it is back.
+    let held = count("net.piped.live");
+    for arm in ["both ends dropped", "(a) shutdown(1), then dropped", "(b) written until the pipe is full, then dropped"] {
+        let conn = toyos::net::tcp_connect(HOST, holding, 0).unwrap_or_else(|e| panic!("{arm}: connect: {e:?}"));
+        if arm.starts_with("(a)") {
+            toyos::net::tcp_shutdown(conn.socket_id, 1).unwrap_or_else(|e| panic!("{arm}: shutdown: {e:?}"));
+        }
+        if arm.starts_with("(b)") {
+            let (mut written, mut stalls) = (0usize, 0);
+            while stalls < 3 {
+                match conn.tx.write_nonblock(&[0u8; 65536]) {
+                    Ok(n) if n > 0 => (written, stalls) = (written + n, 0),
+                    _ => {
+                        stalls += 1;
+                        std::thread::sleep(Duration::from_secs(1));
+                    }
+                }
+            }
+            println!("MEASURE {arm}: {written} byte(s) written before three seconds without room");
+        }
+        drop(conn);
+        let asked = Instant::now();
+        let back = loop {
+            if count("net.piped.live") == held {
+                break Some(asked.elapsed());
+            }
+            if asked.elapsed() > Duration::from_secs(130) {
+                break None;
+            }
+        };
+        println!("MEASURE {arm}: piped.live back after {back:?} (None: still held 130s after the drop)");
+    }
+    assert_eq!(port, 0, "MEASUREMENT over");
     let (streams, live) = (count("net.sockets.tcp"), count("net.piped.live"));
     let untabled = count("net.sockets.untabled");
     for round in 1..=ROUNDS {
diff --git a/tests/toyos.rs b/tests/toyos.rs
index 408feaa56..4056b4f87 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -2816,7 +2816,7 @@ fn netstack_socket_churn() -> Result<(), String> {
     let mut console = qemu.boot_log().to_string();
     await_marker(&mut qemu, &mut console, LEASED, "netstack's lease").map_err(|e| format!("{e}\n{console}"))?;
     let result =
-        qemu.run_test(&format!("test_rs_netstack_socket_churn {port} {holding}"), Duration::from_secs(120));
+        qemu.run_test(&format!("test_rs_netstack_socket_churn {port} {holding}"), Duration::from_secs(600));
     if let Some(why) = &result.error {
         return Err(format!("{why}\nthe job said:\n{}", result.stdout));
     }

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 3 of wt/toyos-netstackclose at e7a40645f against origin/main 1084ddc9a (merge base b6bcb9691, #757). Read, not run: no cargo, QEMU or test command was started for this review. First review of the kernel and ABI half; kernel/src/pipe.rs, kernel/src/inbox/mod.rs, kernel/src/inbox/polls.rs, kernel/src/object/{ops,pipe,mod,handle}.rs, kernel/src/watch.rs, kernel/pure/pipe.rs, toyos/src/poller.rs, toyos-abi/src/inbox.rs and netstack's main.rs were read whole at the head.

Net: 19 files, +1027 −145. Production +409 −101 (userland/netstack/src/main.rs +196 −40, kernel/pure/pipe.rs +96, kernel/src/pipe.rs +35 −47, kernel/src/object/ops.rs +27, kernel/src/inbox/mod.rs +23 −6, toyos/src/poller.rs +23 −5, toyos-abi/src/inbox.rs +5 −1, kernel/pure/lib.rs +4 −2). Tests +430 −3 (listen/tests.rs +212 −3, netstack_socket_churn.rs +101, kernel/pure/pipe.rs +70, tests/toyos.rs +44, tests/netcase/system.toml +3). Issues +188 −41. The kernel's growth is accepted: the counts moved to the library with their answers, and the two close functions became one.

Round 2's BLOCKERs

  • 1, a dead client whose send pipe netstack cannot read is never ownerless: CLOSED. measure-guest.log at e7a40645f: plain drop, (a) tcp_shutdown(id, 1) then drop, and (b) 2,847,840 bytes written then drop each give the slot back after 100.005 s, 100.004 s and 100.007 s. arm-c1-kernel-never-answers.log and arm-m5-… exit 1 on netstack was not told a client that shut its sending half down, left 0 byte(s)…; arm-m2-… exit 1 on the 10-byte arm only. These hold the answer. They do not hold the wake: first BLOCKER below.
  • 2, the reset at the ceiling never left when the neighbour entry had run out: CLOSED. a_cut_connection_whose_neighbour_entry_ran_out_is_kept_until_its_reset_leaves is the bench pass round 2 asked for (poll_only, one ARP request on the wire, Waits, then the reset); green in dev-netstack-host.log on the tree committed as 97e73d765, red with RESET_LIFE out (arm-m3-…, exit 101, Unsaid against Waits, both new rows). All three arms of measure-guest.log log the reset has left. The green run is from before the merge: it is redone by the host run the next BLOCKER owes.

BLOCKER

  • PR body, Gates — cargo run -- --ci host has no result at this head, the whole guest suite has none, and the kernel library's pipe:: tests have no kept green log at any head — reviewer.md Evidence names each missing one a BLOCKER. Owed at the head that lands, by the orchestrator, who alone compiles: cargo run -- --ci host with its exit and log (it carries the five pipe:: tests and netstack's 27), and guest / suite. The landing rests on those two checks; host, toolchain and guest are all skipping on the draft today.
  • kernel/src/inbox/mod.rs:582–584 — no test can fail on the wake, and the body says one does ("c1, the control for the wake"; "the wake itself … is reached only by the guest test"). c1 patches pipe::other_end_gone to false: that is the answer. The mutation that takes out the wake is deleting those three lines (if let Some(watch) = &own { watch.add_poll(…OTHER_END_GONE) }), and I expect netstack_socket_churn to stay green under it: after shutdown(1) both of netstack's watches are OTHER_END_GONE alone, so own is their only registration, but every count() the test loops on is a new connection to netstack, each wakes a pass, each pass submits both watches again, and arm's first look answers them. The measurement's 100.00x s was polled the same way. A missed wake here is a netstack that hears a client leave only when something else wakes it, which is round 2's NOTE come back. Owed: run that patch against netstack_socket_churn and post the exit; then a test it turns red. The tier exists and is cheaper than a guest test: a discovered binary on the shared boots beside poll_wake_pipe, one thread parked in wait_answers(1, u64::MAX, …) on a ring that holds one OTHER_END_GONE watch and nothing else, another dropping the last holder, in both directions (a reader told of the writer, a writer told of the reader) and with bytes left in the ring.
  • kernel/src/inbox/mod.rs:543, userland/netstack/src/main.rs:407 — the refusal is the ABI's contract (toyos-abi/src/inbox.rs: "a watch that asks this of it is refused") and the body's claim ("a handle that is no pipe end is therefore refused on the first pass"), and nothing in the tree asks either. Two mutations I expect every test to pass: Some(None) => return Err(SyscallError::NotSupported) to Some(None) => None (a connection asked READABLE | OTHER_END_GONE is then served READABLE and never told of its other end, silently); and Err(e) => self.refuse(socket, end, e) to Err(_) => {}. c2 reached refuse only because the kernel was reverted under it. Owed, in the same discovered binary: OTHER_END_GONE alone and with READABLE on a connection and on a file answers Err(NotSupported); a watch armed after the last holder left answers at once; and the end is not gone while a duplicate of the other end's handle is held, while that handle sits unreceived in a connection's queue, and after it moved to a child that still lives. Those last three are where a false "gone" would reset a live connection; by reading they hold (below), and no run says so. For netstack's half, a test at whatever tier reaches pipe_answered's decision, and the two patches run red.

NOTE

  • kernel/src/pipe.rs:287 — the T14's shared boots are the only tier that runs the tree's own tests of the rewritten close path and of the inbox's arm, and neither boot was run. Owed before landing, by the orchestrator: the shared boot on the shipping kernel, read for poll_wake_pipe, inbox_cancel_wakes, inbox_empty_write, kill_ends_every_wait, blocking_read_stress, abuse_inbox, abuse_pipe_map, abuse_pipe_owner, abuse_pipe_ring, pipe_flag_forgery, poller_capacity, process_lifecycle, exit_wait_storm, std_io, std_process and the binary the BLOCKERs owe; and the actuator boot, read for handle_lifetime. Neither needs the LAN.
  • userland/netstack/src/main.rs:1872–1886 — every pass now submits two watches for every live connection where an idle one submitted none, each an allocation and three takes of the global pipe lock in arm; and every read or write a client makes on a watched pipe posts the watch netstack's poll sits on, so its submitter is woken in the kernel for a look that arms again. Nothing measures it and nothing files it. File it with an owner and an exit (a watch submitted only when its interest changes, or a throughput reading on the T14 that says the cost is nothing), since the timing verdict is the metal's.
  • issues/netstack-spends-two-poller-slots-per-piped-connection.md — this diff makes it false: both pipes are watched on every pass and not "while it holds bytes its receive pipe refused", and its exit, one watch on a joined connection, is now refused by ops::pipe_end_watch. The body says so under "Unsure of" and leaves the file. Correct the issue in this diff, or give a connection the condition.
  • issues/netstack-cuts-a-departed-clients-unsent-tail-at-the-ceiling.md — "whose client holds neither pipe end" is narrower than clientless(): a client that dropped its write end and still holds the read end of a receive direction netstack closed at the peer's FIN is timed and cut the same way, alive.
  • issues/a-close-of-one-handle-ends-every-rings-poll-on-its-object.md — "drain hands the token of a negative completion to its caller as it does a ready one's" is no longer true of drain, and the first half of its exit is in the tree as wait_answers.
  • PR body — "origin/main 6f87cdb9c merged": the head's merge base with origin/main is b6bcb9691 (A return to userland takes an entry inside the user half, and nothing is placed in its last page #757).

Rulings the brief asked for

  1. The condition and its wake, by reading. Level-triggered: readiness_of is asked in arm before anything is registered, again after, and again by every look, and Holders cannot gain a holder from zero (acquire needs a live reference; Held::get is None after the release), so "it stays so" is true. No missed wake: release moves the count under the pipe lock and posts after it; arm registers and then reads the count under the same lock, so the post either follows the registration or the recheck sees the count. A spurious post (every write posts the read watch) is absorbed by the look. No false "gone": the count is a PipeReader or PipeWriter, released at the object's last handle through the deferred queue, and handle_count covers table slots, transfers in a queue and spawn endowments (HandleEntry moves whole through transfer); a duplicate is the same object, a joined connection a second reference. "Gone" is therefore late by a queue drain and never early. Both directions go through one release. A non-pipe handle is refused NotSupported whatever else it asks, stricter than its siblings, which serve the half they can; that is the right way round. An unknown bit was and is InvalidArgument from WatchFlags::from_raw, which is what c2 showed. release is the two old close functions line for line: decrement, publish, free at zero and zero, wake the other side otherwise.
  2. The ABI. 1, 4 and 16 are POLLIN, POLLOUT and POLLHUP; the doc is the contract and nothing else. wait is now wait_answers with the answer dropped, one drain, no second path; its thirty-odd callers are outside this fence, and removing the form that drops the answer is the exit of the issue named in the NOTE. Every other user of a pipe watch is unaffected: none passes bit 16, and userland/libc/src/pollreq.rs builds its interest from POLLIN and POLLOUT alone, so a C caller's POLLHUP in events reaches no kernel. libc's poll and std read a pipe to its end of file and need nothing here; nothing is owed.
  3. netstack. (a) and (b) are decided by the event, by the measurement above. A client that keeps an end, or hands it to another process, is a holder that exists and is not timed, as round 2 ruled. Socket ids only rise (alloc_id), so a token names one connection until 2^32 allocations; an answer for a pipe netstack let go is dropped by held.is_none(). A handle that is no pipe end is reset at its first watch, after one bridge_piped pass has already read or written it; that pass uses only what the client itself moved, so it is no hole. It is not tested: third BLOCKER.
  4. RESET_LIFE. RFC 4861 §7.2.2 and §10 say what the comment says they say, and the comment says they are IPv6's. A deadline: ownerless_wake_in joins the poller's timeout with whichever bound each connection is under, and a pass at the bound changes the connection's state, so nothing spins.
  5. The LAN. No claim of this change is about hardware or a duration, so the landing rests on no LAN row. lan_talk is still the only run in the tree, at any tier, of an accepted connection and of sshserver through the new watches; no guest test forwards a port in. It is the first row owed when the bench's LAN is back, and whether the landing waits for it is the orchestrator's call, not this review's.
  6. The two issues are true of the code, status: open, kind: defect, say they are read and not measured, name an owner that exists (issues/toyos-has-its-own-network-stack.md) and an exit a test can fail. The first's opening sentence is the NOTE above.
  7. The merge. git merge-tree origin/main e7a40645f is clean. Since this head's base, main changed none of kernel/src/pipe.rs, kernel/src/inbox/, kernel/src/object/ops.rs, netstack, toyos/src/poller.rs or toyos-abi/src/inbox.rs; it changed kernel/src/object/{mod,port,shm}.rs, toyos-abi/src/syscall.rs and, with One workspace, one lock: the root, the kernel, the loader, userland and the SDK resolve together #746, every manifest path the body's host commands name. A merge makes a new head: --build-only, netstack_socket_churn, iommu_virtio_platform and the mutations the BLOCKERs name are run on it, and the body's two --manifest-path commands are rewritten for the one workspace. The six controls of this round stand if the merge leaves their files' blobs as they are.

SEND BACK

Japabu and others added 2 commits October 8, 2026 12:35
…holders

`pipe_other_end_gone` is a discovered binary on the T14's shared boot beside
`poll_wake_pipe`. A thread parks on a ring holding one `OTHER_END_GONE` watch
and nothing else, and the other end's last holder goes once the roster says
the thread is blocked: a reader told of its writer and a writer of its reader,
with the ring empty and with bytes left in it, and with a child process as the
last holder. A watch made after the leaving is answered by its own submit. A
connection and a file are refused the question alone and beside `READABLE`.
The watch stays armed while a duplicate of the other end's handle is held,
while that handle sits unreceived in a connection's queue, and while it lives
in a child's table.

`netstack_socket_churn` gains an arm for netstack's half: a client moves
netstack an acceptor where its receive end belongs and keeps its send end, on
the server that holds every connection and sends nothing. netstack never
writes that end, so the kernel's refusal of its watch is all that ends the
connection, and `net.piped.live` must come back.

Review round 3's NOTEs: the per-pass cost of the two watches is filed
(`issues/netstack-watches-both-pipes-of-every-connection-on-every-pass.md`),
and three issues are made true of the tree: the two poller slots' exit now
needs the condition on a connection, `clientless()` also times a client that
still holds the read end of a receive pipe netstack closed, and `wait` is the
form that drops a completion's result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu Japabu changed the title A pipe watch can ask whether the other end is gone; netstack lets a connection with no client go after 100 seconds, its socket and id with it, and keeps a cut one until its reset leaves A pipe watch can ask whether the other end is gone, and netstack decides a client has left by it Oct 8, 2026
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Round 4 patches and deciding lines, at 62ac7c817. Each was git apply --checked, applied, run and reversed by build-request.sh; exits from results.txt:

head 62ac7c817e8380d2221ac1c04684e082b309807f, porcelain []
cargo metadata --locked EXIT=0
build-only EXIT=0
kernel lib pipe:: EXIT=0
netstack host EXIT=0
guest netstack_socket_churn EXIT=0
guest iommu_virtio_platform EXIT=0
qemu-arm alone (control, expect 0) EXIT=0 (cargo test --test toyos-build -- other_end_gone_under_qemu)
qemu-arm alone (control, expect 0): restored, porcelain []
w1 on netstack_socket_churn (to know) EXIT=0 (cargo test --test toyos-build -- netstack_socket_churn)
w1 on netstack_socket_churn (to know): restored, porcelain []
w1 on pipe_other_end_gone (expect red: a parked waiter, the ceiling) EXIT=1 (cargo test --test toyos-build -- other_end_gone_under_qemu)
w1 on pipe_other_end_gone (expect red: a parked waiter, the ceiling): restored, porcelain []
r1 on pipe_other_end_gone (expect red) EXIT=1 (cargo test --test toyos-build -- other_end_gone_under_qemu)
r1 on pipe_other_end_gone (expect red): restored, porcelain []
r2 on netstack_socket_churn (expect red) EXIT=1 (cargo test --test toyos-build -- netstack_socket_churn)
r2 on netstack_socket_churn (expect red): restored, porcelain []
staging at 62ac7c817e8380d2221ac1c04684e082b309807f, porcelain []
stage pipe EXIT=2
stage inbox EXIT=2
stage std_ EXIT=2
stage every_wait EXIT=2
stage wait_storm EXIT=2
stage blocking_read EXIT=2
stage poller EXIT=2
stage process_lifecycle EXIT=2
stage handle_lifetime EXIT=2
porcelain []
DONE

w1-kernel-no-registration-on-the-ends-own-watch.patch

diff --git a/kernel/src/inbox/mod.rs b/kernel/src/inbox/mod.rs
index 32310a66a..33fd7a7f3 100644
--- a/kernel/src/inbox/mod.rs
+++ b/kernel/src/inbox/mod.rs
@@ -579,9 +579,6 @@ fn arm(
     if let Some(watch) = &write {
         watch.add_poll(PollEntry { poll: poll.clone(), direction: WatchFlags::WRITABLE });
     }
-    if let Some(watch) = &own {
-        watch.add_poll(PollEntry { poll: poll.clone(), direction: WatchFlags::OTHER_END_GONE });
-    }
     if readiness_of(object, flags).any() {
         poll.fire(0);
     }

Deciding lines:

arm-w1-churn.log: 11:05:15   PASS  netstack_socket_churn  (3s)
arm-w1-gone.log: 11:07:36 FAIL other_end_gone_under_qemu: STALLED: 123s of guard expired, and the guest had said nothing for the last 123s of it — the ceiling caught a machine that had stopped, which is not an answer to what this test asked

r1-kernel-serves-a-handle-that-is-no-pipe-end.patch

diff --git a/kernel/src/inbox/mod.rs b/kernel/src/inbox/mod.rs
index 32310a66a..8e3134a04 100644
--- a/kernel/src/inbox/mod.rs
+++ b/kernel/src/inbox/mod.rs
@@ -540,7 +540,7 @@ fn arm(
     // an object with no other end is refused the question, whatever else the
     // watch asks. A direction asked above has taken that watch already.
     let own = match flags.other_end_gone().then(|| ops::pipe_end_watch(object)) {
-        Some(None) => return Err(SyscallError::NotSupported),
+        Some(None) => None,
         Some(Some(_)) if read.is_some() || write.is_some() => None,
         Some(own) => own,
         None => None,

Deciding lines:

arm-r1-gone.log: assertion `left == right` failed: a watch of 0x11 on a connection, which has no other end
arm-r1-gone.log:   left: None
arm-r1-gone.log:  right: Some(Err(NotSupported))

r2-netstack-ignores-a-refused-watch.patch

diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index fd74c9de5..6933393fb 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -404,7 +404,9 @@ impl PipedConnection {
         let (held, end) = if send { (&self.tx_read, "send") } else { (&self.rx_write, "receive") };
         match answer {
             _ if held.is_none() => {}
-            Err(e) => self.refuse(socket, end, e),
+            Err(_) => {
+                let _ = (&socket, end);
+            }
             Ok(met) if met & OTHER_END_GONE == 0 => {}
             Ok(_) if send => self.writer_gone = true,
             // Nobody is left to read it.

Deciding lines:

arm-r2-churn.log: netstack still counts connection 942118025 live 20s after the kernel refused the watch of its receive end, which is no pipe end

qemu-arm.patch

diff --git a/tests/toyos.rs b/tests/toyos.rs
index f5a648f74..e40d32d2b 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -261,6 +261,7 @@ const MACHINE_TESTS: &[&str] = &[
     // connections: netstack is one binary that owns its NIC, with no host
     // build, and the T14's peer is the bench's network.
     "netstack_socket_churn",
+    "other_end_gone_under_qemu",
     // The nested-NMI report is a raw write to the 16550, which the T14 does not
     // have.
     "nested_nmi_is_loud",
@@ -2831,12 +2832,30 @@ fn netstack_socket_churn() -> Result<(), String> {
     Ok(())
 }
 
+/// `pipe_other_end_gone`, the T14 shared boot's member, on a QEMU boot of the
+/// same config: where its mutations are run.
+fn other_end_gone_under_qemu(case: &Path) -> Result<(), String> {
+    const JOB: &str = "pipe_other_end_gone";
+    let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &compile::repo_root().join("tests/toyos-rust-tests"), JOB);
+    let mut qemu = QemuInstance::boot_with_options(case, &[], &[(JOB.to_string(), bin)], BootOptions::default());
+    let result = qemu.run_test("test_rs_pipe_other_end_gone", Duration::from_secs(60));
+    if let Some(why) = &result.error {
+        return Err(format!("{why}\nthe job said:\n{}", result.stdout));
+    }
+    if result.exit_code != Some(0) {
+        return Err(format!("the job ended {:?}:\n{}", result.exit_code, result.stdout));
+    }
+    eprintln!("  [other-end-gone] {}", result.stdout.trim());
+    Ok(())
+}
+
 /// Run the machine-shape test, which owns its QEMU: the machine shape *is* the
 /// test.
 fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> {
     match name {
         "iommu_virtio_platform" => common::iommu::iommu_virtio_platform(test_config),
         "netstack_socket_churn" => netstack_socket_churn(),
+        "other_end_gone_under_qemu" => other_end_gone_under_qemu(test_config),
         "nested_nmi_is_loud" => faults::nested_nmi_is_loud(test_config),
         "machine_shutdown" => power::machine_shutdown(test_config),
         "acpi_power_button" => power::acpi_power_button(test_config),

Deciding lines:

arm-qemu-control.log: 11:04:58   PASS  other_end_gone_under_qemu  (4s)

t14/request.txt (staged, machine untouched)

## filter pipe, head 62ac7c817e8380d2221ac1c04684e082b309807f
# One boot per image. Each invocation is `cargo <words>` from this worktree.

shared
  image: <scratchpad>/t14/pipe/shared/image.img
  cargo run --bin toyos-metal -- --image <scratchpad>/t14/pipe/shared/image.img --readback <scratchpad>/t14/pipe/shared --fat32-check
  sha256: 89d3d66c697309f727a3aaa7304222fad21c99bcc25b048565ccd856003d4258  <scratchpad>/t14/pipe/shared/image.img

## filter inbox, head 62ac7c817e8380d2221ac1c04684e082b309807f
# One boot per image. Each invocation is `cargo <words>` from this worktree.

shared
  image: <scratchpad>/t14/inbox/shared/image.img
  cargo run --bin toyos-metal -- --image <scratchpad>/t14/inbox/shared/image.img --readback <scratchpad>/t14/inbox/shared --fat32-check
  sha256: 367258384675c7ed8b9e70401293fa03045c3b6a9c589c88d64ab02c9cf431ac  <scratchpad>/t14/inbox/shared/image.img

## filter std_, head 62ac7c817e8380d2221ac1c04684e082b309807f
# One boot per image. Each invocation is `cargo <words>` from this worktree.

shared
  image: <scratchpad>/t14/std_/shared/image.img
  cargo run --bin toyos-metal -- --image <scratchpad>/t14/std_/shared/image.img --readback <scratchpad>/t14/std_/shared --fat32-check
  sha256: ea6b84101cee6d915edc03018118af5c295781fda84b181fbc7182f7bf79f605  <scratchpad>/t14/std_/shared/image.img

## filter every_wait, head 62ac7c817e8380d2221ac1c04684e082b309807f
# One boot per image. Each invocation is `cargo <words>` from this worktree.

shared
  image: <scratchpad>/t14/every_wait/shared/image.img
  cargo run --bin toyos-metal -- --image <scratchpad>/t14/every_wait/shared/image.img --readback <scratchpad>/t14/every_wait/shared --fat32-check
  sha256: 8bbd3d2aa9628181b75904a11d87eb5ac1dba13f58e6afa54db36c5d3b9cff2c  <scratchpad>/t14/every_wait/shared/image.img

## filter wait_storm, head 62ac7c817e8380d2221ac1c04684e082b309807f
# One boot per image. Each invocation is `cargo <words>` from this worktree.

shared
  image: <scratchpad>/t14/wait_storm/shared/image.img
  cargo run --bin toyos-metal -- --image <scratchpad>/t14/wait_storm/shared/image.img --readback <scratchpad>/t14/wait_storm/shared --fat32-check
  sha256: 73b151b809a770286dac17f5eea77800504d065c81a5cfb6002ff975dbb01bf3  <scratchpad>/t14/wait_storm/shared/image.img

## filter blocking_read, head 62ac7c817e8380d2221ac1c04684e082b309807f
# One boot per image. Each invocation is `cargo <words>` from this worktree.

shared
  image: <scratchpad>/t14/blocking_read/shared/image.img
  cargo run --bin toyos-metal -- --image <scratchpad>/t14/blocking_read/shared/image.img --readback <scratchpad>/t14/blocking_read/shared --fat32-check
  sha256: 9d6ec771adae3155bac41c8975595ca8e6d3f6f3a362cb812f9297fe2e3c3f8d  <scratchpad>/t14/blocking_read/shared/image.img

## filter poller, head 62ac7c817e8380d2221ac1c04684e082b309807f
# One boot per image. Each invocation is `cargo <words>` from this worktree.

shared
  image: <scratchpad>/t14/poller/shared/image.img
  cargo run --bin toyos-metal -- --image <scratchpad>/t14/poller/shared/image.img --readback <scratchpad>/t14/poller/shared --fat32-check
  sha256: 8bfcde76bd75baa7ce6a4b4909a45d6e0fcdc6dc39a97475738be92e4617e737  <scratchpad>/t14/poller/shared/image.img

## filter process_lifecycle, head 62ac7c817e8380d2221ac1c04684e082b309807f
# One boot per image. Each invocation is `cargo <words>` from this worktree.

shared
  image: <scratchpad>/t14/process_lifecycle/shared/image.img
  cargo run --bin toyos-metal -- --image <scratchpad>/t14/process_lifecycle/shared/image.img --readback <scratchpad>/t14/process_lifecycle/shared --fat32-check
  sha256: 4097e38b4d9630ff7ce49f9ceaf36d0c63dea0032400ca64283ce75382071984  <scratchpad>/t14/process_lifecycle/shared/image.img

## filter handle_lifetime, head 62ac7c817e8380d2221ac1c04684e082b309807f
# One boot per image. Each invocation is `cargo <words>` from this worktree.

shared-debug
  image: <scratchpad>/t14/handle_lifetime/shared-debug/image.img
  cargo run --bin toyos-metal -- --image <scratchpad>/t14/handle_lifetime/shared-debug/image.img --readback <scratchpad>/t14/handle_lifetime/shared-debug --fat32-check
  sha256: 0c9d750c2e72914a6bc38369bf5492c76eb4855f2b74440fad1d3ddc21889456  <scratchpad>/t14/handle_lifetime/shared-debug/image.img

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

T14 at head 62ac7c817 (orchestrator's run; nine images staged from the clean tree at that head, each sha256 checked against the request and printed by the command that flashed it; each section judged with --metal --metal-readback <dir> <filter> from the clean tree; every boot exit 0, every judge exit 0):

section (filter) boot, image sha256 judge members, each ===TEST_END … exit=0===
pipe shared, 89d3d66c…003d4258 6 passed pipe_other_end_gone (the new binary), poll_wake_pipe, abuse_pipe_map, abuse_pipe_owner, abuse_pipe_ring, pipe_flag_forgery
inbox shared, 36725838…9cf431ac 4 passed abuse_inbox, inbox_cancel_wakes, inbox_empty_write, inbox_log_post
std_ shared, ea6b8410…bf79f605 14 passed std_io, std_process and the twelve other std_ members
every_wait shared, 8bbd3d2a…3b9cff2c 1 passed kill_ends_every_wait
wait_storm shared, 73b151b8…dbb01bf3 1 passed exit_wait_storm
blocking_read shared, 9d6ec771…2e3c3f8d 1 passed blocking_read_stress
poller shared, 8bfcde76…4617e737 1 passed poller_capacity
process_lifecycle shared, 4097e38b…82071984 1 passed process_lifecycle
handle_lifetime shared-debug, 0c9d750c…21889456 1 passed handle_lifetime

Thirty rows over nine boots, none failed. No LAN row was booted: the bench's wired path to the development machine is down.

@Japabu
Japabu marked this pull request as ready for review October 8, 2026 11:30
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 4 of wt/toyos-netstackclose at 62ac7c817 against origin/main 47cbd2e5b (merge base 1084ddc9a, #746). Read, not run: no cargo, QEMU or test command was started for this review. pipe_other_end_gone.rs and netstack_socket_churn.rs were read whole at the head, with tests/toyos-rust-tests/src/roster.rs, inbox_cancel_wakes.rs, kernel/src/pipe.rs, kernel/src/inbox/mod.rs arm, kernel/src/object/{mod,handle}.rs and the orchestrator's logs under orch/netstackclose-r4/.

Net: 23 files, +1333 −168. Production +409 −101, unchanged since round 3 (git diff e7a40645f 62ac7c817 over kernel/pure/pipe.rs, kernel/src/pipe.rs, kernel/src/inbox, kernel/src/object/ops.rs, userland/netstack, toyos/src, toyos-abi/src/inbox.rs prints nothing). Tests +671 −3 (listen/tests.rs +212 −3, pipe_other_end_gone.rs +198, netstack_socket_churn.rs +142, kernel/pure/pipe.rs +70, tests/toyos.rs +46, tests/netcase/system.toml +3). Issues +253 −64. This round's commit touches no production line.

Round 3's BLOCKERs

  • 1, host, the guest suite and the pipe:: tests unmeasured: CLOSED for pipe::, OPEN for the two checks, and open in nothing the branch can change. kernel-pipe.log: cargo test -p kernel --lib --features sched-check pipe:: exit 0, 5 passed, at this head. netstack-host.log: exit 0, 27 passed. cargo run -- --ci host and the whole guest suite have no result at this head: run 37770538069 is in progress, host pending, toolchain / build pending, guest / suite not started (the guest and toolchain rows that read skipping belong to the draft's run 37768578803). See the one finding below.
  • 2, no test could fail on the wake: CLOSED. arm-w1-churn.log: w1 (the three lines at kernel/src/inbox/mod.rs:582–584 deleted) on netstack_socket_churn, exit 0, as predicted: the churn test never held the wake. arm-w1-gone.log: w1 on pipe_other_end_gone under the throwaway registration, exit 1, STALLED: 123s of guard expired; arm-qemu-control.log: the same tree and registration without w1, exit 0 in 4 s with all three of the binary's lines. The T14 at this head ran the binary green on the shipping kernel (t14/pipe/judge.log: 6 passed, 0 failed; the readback's kernel log carries the three lines and ===TEST_END test_rs_pipe_other_end_gone exit=0===). Ruling on the stall below.
  • 3, the refusal and the false-"gone" arms asked by nothing: CLOSED. arm-r1-gone.log: Some(None) => None, exit 1, a watch of 0x11 on a connection, which has no other end, left: None, right: Some(Err(NotSupported)), after the first line had printed. arm-r2-churn.log: netstack's refuse arm emptied, exit 1, netstack still counts connection 942118025 live 20s after the kernel refused the watch of its receive end, which is no pipe end, after the churn rounds' line. The three not-gone arms are in the binary and green on QEMU and on the T14.

Round 3's NOTEs

  • T14 boots: done. Nine sections, 6 + 4 + 14 + 1 + 1 + 1 + 1 + 1 + 1 = 30 members; each section's judge.log says N passed, 0 failed, each verdict.txt says passed, and each kernel log has exactly N TEST_END … exit=0=== lines and none with another exit. handle_lifetime on the actuator kernel, the rest on the shipping one.
  • Per-pass watch cost: filed as issues/netstack-watches-both-pipes-of-every-connection-on-every-pass.md, status: open, kind: defect, read and says so, an owner that exists, an exit a change or a T14 reading can meet. Not fixing it here is right: it is main.rs, and six controls stand on that file's bytes.
  • The three issue texts: each now true of the code. The body's base: corrected.

BLOCKER

None in the branch.

NOTE

  • PR body, Gates — cargo run -- --ci host and the guest suite are the Evidence rule's two measurements and neither exists at 62ac7c817. By the letter this is round 3's first BLOCKER still open; it is written here as the named change, as the brief allows, because no commit closes it and the checks are running on this head. The landing rests on exactly these, and on nothing else: run 37770538069, jobs host (cargo run -- --ci host: it carries the kernel library's five pipe:: tests, netstack's 27 and every other host suite on the one workspace), toolchain / build, and guest / suite (it carries netstack_socket_churn with its three arms, and every guest test the kernel and toyos::poller change reaches). Each green at 62ac7c817, recorded in the body with the run's URL; then the merge queue's same three on the merged tree. A red in any of them is SEND BACK with that job's log, with no further round needed to say so.
  • PR body — "The T14 has no result yet", "staged, being read" and "the T14, once it has answered" are no longer true of the record: the reading is the comment of 11:30 UTC, and it belongs in the body, where Evidence reads it. The body's last section also still lists the T14 under "no result".

Rulings the brief asked for

  1. No MACHINE_TESTS registration: the implementer's choice stands, and the other choice would have been the BLOCKER. A discovered binary on the shared boot is the tier round 3 named. A QEMU registration of the same binary is a guest test of a behaviour a metal row reaches, which Guest tests refuses by name, and it would be a second holder of one behaviour. That CI does not hold the wake is true, is said in the body, and is true of every discovered binary in the tree, poll_wake_pipe and inbox_cancel_wakes among them: whether CI should boot the shared members is a question about the harness and the owner's, not one more row for this binary.
  2. w1's stall is a red arm for the wake; no line before the park is owed. The control is the whole of it: same head, same throwaway registration, exit 0 in 4 s; with three lines gone whose only effect is the registration on the end's own watch, exit 1. A missing wake has no other observable in a test that does not wait flat: the waiter is in wait_answers(1, u64::MAX, …) and the main thread in join, which is inbox_cancel_wakes's design and that test's stated red. By reading, w1 changes nothing before leave(): the non-blocking enter, the flag and the roster's BLOCKED are reached as in the control, so the stall is at the join of the first arm. r1's log shows the same four arms pass under a different kernel mutation. On the T14, where the binary lives, the kernel log prints an exit: line per waiter thread (four before the binary's first line in this round's green readback), so a stall there is attributable to its arm without a line from the binary. The QEMU run's kept serial log stops at the hand-over to the virtio console and adds nothing.
  3. The binary, as a test. woken: the leave follows BLOCKED and the flag, the flag follows a non-blocking enter that asserts no answer, the answer is asserted whole ([(TOKEN, Ok(OTHER_END_GONE))]), and a watch made afterwards must be answered by its own submit. The roster-then-flag order is inbox_cancel_wakes's; it could pass without the park only if the waiter blocked before it stored the flag, and nothing between the spawn and the store blocks in the binary's own code (std's thread start was not read). refused: OTHER_END_GONE alone is refused by arm's older "nothing its interest names" arm even under r1, so only the READABLE | OTHER_END_GONE row can fail on the new refusal, and r1's log shows it is the one that did. held: one non-blocking submit, None expected. It can fail on what it claims: syscall/dispatch.rs:688 drains the release queue before the closing or sending syscall returns, so a kernel that let the count go at the first handle, at the transfer or at the endowment has done so before held looks. The queue arm looks while the handle is in the connection's queue (sent, recv_handles_exact not yet called, both ends in this process) and again once received; the child arm looks after spawn and before drop(stdin), the child being parked in read_to_end on that pipe, and its exit is then the leaving a parked waiter is told of. No arm waits flat; at_last and await_true have no deadline and the runner's ceiling is their bound, as the sibling's.
  4. The churn arm. [no_pipe_end, from_client] is [to_client, from_client] at main.rs:450, so the acceptor is rx_write. piped_live() counts pending connects, so the count is above live from the request's arrival and the loop cannot leave early; the connect's failure panics. The server sends nothing and the client keeps kept, so nothing but the refusal ends it: r2's 20 s say so. A count under a hang ceiling, no duration. The body's reason for QEMU is accepted: pipe_answered ignores an answer for a pipe it does not hold, and a held Pipe is a kernel handle.
  5. The six round-3 controls stand: their files' blobs are unchanged, and the churn test's new arm runs before the two they redden and depends on the refusal, not the answer.
  6. The merge. git merge-tree --write-tree origin/main 62ac7c817 is clean (tree a7c089a58), and so is the same against A device call acts on its claim's binding or is refused: a claim lends a &Binding or &isa::Row under the lock its release takes #763's queue branch (tree a971372c4). Since the base, main changed none of this branch's production files: A claim keeps where its BARs are and no object over them: a BAR asked for again after its handle closed no longer panics the kernel, and two answers held at once map apart #761 and The panel test boots an image without sshserver, and a CI step's cargo finds fresh what the job's cargo built #756 add a doc line to kernel/src/object/mod.rs, a MACHINE_TESTS row and its function to tests/toyos.rs, and src/ci.rs. A device call acts on its claim's binding or is refused: a claim lends a &Binding or &isa::Row under the lock its release takes #763 adds WatchRef::Claim in kernel/src/object/ops.rs; this branch's own is an Option<WatchRef> it only calls add_poll on, and a pipe end's watch stays Shared, so nothing here matches on the new variant. No issue either deletes is cited by this branch. The merge owes the queue's host and guest / suite on the merged tree and nothing local. Leave the merge to the queue: a merge of main into the branch makes a head the T14 reading and the five arms of this round were not taken at.

LAND AFTER NAMED CHANGES

@Japabu
Japabu added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit b26542c Oct 8, 2026
6 checks passed
@Japabu
Japabu deleted the wt/toyos-netstackclose branch October 8, 2026 12:20
Japabu added a commit that referenced this pull request Oct 8, 2026
The track issue conflicted in the meter paragraph, which #766 rewrote with
the T14's reading and this branch had re-owned. It takes main's text whole,
its two new bullets included, with the one owner line this branch changed:
"the power-off stage" is this slice, whose stage paragraph the branch
deletes, so the meter's owner reads "this stage".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant