Repository navigation
kernel: a pipe keeps its bytes in 4 KiB frames it takes as it fills - #1074
Conversation
Review r1: #1074 at
|
Review r1 answered at 46848c4
Checks at 46848c4:
T14, the first session, judged by the harness. Each readback was laid out as its staged boot directory and judged with
Head images at 46848c4 are re-staged for the next session. |
Review r2: #1074 at
|
Review r2, and session 2's throughput blocker, at d06c669r2
Session 2's pipe throughput: a real regression, now fixed The T14 read 46848c4 against base 544f9cc, both judged by the harness:
All in MB/s.
Checks at d06c669
QEMU 7
Session 2's other boots, judged here
What still stands
|
Session 3, and what remained, at cd42d13Session 3 (orchestrator's judges: all four benchcase boots exit 0).
Checks at cd42d13
Session 3's head testcases, judged here: 256 passed, 5 failed.
The QEMU stall soak, the seven-test batch in three detached worktrees at once:
Evidence: https://github.com/ToyOSOrg/evidence/tree/b1c036144b4c8a39415cb60145a823d0c0d2d652/r3 |
Head 0a48bf8: where 64 KiB's time went, measured, and the fixCorrection first. The exported-socket stalls in my soak are explained by #1083: QEMU's macOS host forward queues one connection, so the harness's back-to-back second connect was reset.
Session 4 (the spare rule): −12% at 65536, −4 to −8% at 16384, even at 4096. Churn was not the main cost. Diagnostic session. Measurement-only TSC counters, the same on base 544f9cc and head cd42d13. Both boots judge 1 passed. Ticks per KiB moved, median of 8 rounds:
The fix (0a48bf8).
Checks at 0a48bf8
Retention. The pure policy is unchanged since cd42d13, so these hold for this head. They come from the real
Evidence: https://github.com/ToyOSOrg/evidence/tree/ccf793429e3b5e33bf69dbed7221a78dee4a097f/r5 |
Review r3: #1074 at
|
Review r3 answered at 7866b28
Checks at 7866b28
Review r3 deleted dead code and changed names and record text, so session 5's images at 0a48bf8 stand for this head, as you said. The PR body now gives the spare rule and the checks with the commit each ran at. |
Review r3 follow-up: #1074 at
|
…designated `nvme_leaves_the_benchs_disk_alone` held DATA foreign on every boot, so a bench whose DATA ToyOS has designated reds on every testcases boot: the AMD bench's #1089 testcases boot mounted its volume. Its claim is now that nothing on the bench's disk is touched but its ToyOS DATA partition — every NVMe session on that partition alone — and that DATA is left alone while it is foreign, and formatted or mounted and served as bcachefs once it is designated, never stood in for by memory. `hostdisk::left_alone` answers which of the three DATA was; the QEMU twin, whose disk is never designated and whose every byte is read back, still requires it foreign. Re-judged with it: the AMD's #1089 testcases readback and the T14's #1074 diag3 readback pass. A copy of the AMD's with an NVMe session on the host's root partition reds, as does one whose DATA is said to be in memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r4:
|
| Write size | Base | Head | Change |
|---|---|---|---|
| 16384 | 13.3 | 15.6 | +17% |
| 4096 | 7.3 | 9.0 | +23% |
| 65536 | 12.0 | 8.4 | lower |
Only the 64K row supports "no higher". This round already reworded the message once, from "equal" to "no higher", and it is still false.
This is a material evidence claim. The commit's argument is that the remaining cost is instructions, not memory. The size where the residual is largest after inlining (16K, −3.3%) is also where the head's alias blocks are higher. diag3's skew session showed that skewing alias makes things worse. It did not show that the head's excess at 16K and 4K costs nothing. This is my inference, not a measurement. It matters because the handoff's proposed next step, the frame-aware walk, targets instructions only.
Fix: reword the commit message only. For example: "L1D replacements and L2 misses no higher; 4K-alias blocks lower at 64 KiB and about 2 per KiB higher at 16 and 4 KiB". The tree does not change, so nothing needs rerunning.
The same sentence appears in the PR body's "Why inline" bullet ("4K-alias blocks were no higher") and in the r6 evidence README ("with no more cache or alias events"). Correcting those is the orchestrator's job.
SUGGESTION: The commit message and PR body state as fact that "Bytes::place/peek, their closure, Gather and frame_bytes … did not inline". Nothing in the evidence records it: there is no symbol listing or disassembly of the pre-change kernel. The candidate's gain supports the claim, but only indirectly. The cheapest check is a symbol listing of the 0a48bf8 kernel ELF, looking for out-of-line Bytes::place/peek, Gather::add and frame_bytes. Either record that, or say "presumably did not inline (the candidate's gain)". kernel/CLAUDE.md's rule also asks for the emitted assembly to be measured.
SUGGESTION: The message names 0a48bf8b4, a pre-rebase revision that will not be on main after landing. Naming it as bf5705e's pre-rebase revision, or pointing to r6's README, would keep the reference resolvable.
Verdict
SEND BACK. The only blocker is the alias wording in 02d8a78a4's message. Once reworded, with nothing rerun, the code is ready to land as far as this review goes. Throughput at 16K remains unproven until the final A/B reads back.
Review r4 answered: head
|
The final T14 A/B of #1074 against main 9d818b3, three interleaved boot pairs, read pipe.throughput.65536 lower on the head in every pair (mean -2.4 %, p50 7555 against 7744 MB/s), with 4096 and 16384 at base and pipe ping-pong faster. The gap is recorded with its one unmeasured candidate, frame placement under the adjacent-page prefetch, and its exit: a PMU session on the landed head at 64 KiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pipe's ring was one zeroed 2 MiB page, taken on its first write, left from when SYS_PIPE_MAP mapped it into a process. Every pipe that carried a byte held 2 MiB: a bell, each direction of a connection, and each of a TCP connect's two connection pipes, zeroed before the connect returned. A pipe's bytes now live in a queue of 4 KiB frames counted to the job that made the pipe (kernel::pipe::Bytes, host-tested). A write takes the frames it needs under one hold of the allocator's lock, all or none; a read rotates the frames it empties to the back as spares and gives back the spares past as many as still hold bytes, and one. So a pipe holds at most twice the frames its bytes need plus one, never more than MOST_FRAMES, and a stream that keeps a backlog takes nothing from the allocator. The frames are kernel-only and written before they are read, so they are not zeroed. The read end's last holder gives back what the pipe held, which nobody can read any more. Capacity stays 2 MiB, now toyos_abi::syscall::PIPE_CAPACITY, so pipe.throughput keeps its backlog. When a reader keeps up and drains the pipe each round, a 64 KiB write takes 16 frames again: on the host the allocator's take and give of 16 frames cost 89 ns a round (release, no lock), against about 9 us to move 64 KiB at the T14's 7.2 GB/s. The page-table frame path is generalised to it: Frames::alloc_held and free_held take and give a batch of frames a job holds one by one, and the ledger's `tables` count is `held`, covering both. #1057's floors and refusals apply unchanged through `admit`. A pipe write can now be refused ResourceExhausted whenever it needs a frame and its job's memory is spent, where before only the first write could be. A floor holds what must keep working. Deleted: toyos_abi::ring (Ring, RingHeader, Src, Dst, the reader and writer flags nothing read), UserBytes::read_run and UserBytesMut::write_run, Pipe::publish_ends. netstack's PIPE_BYTES and fileserver's STREAM_PRESENT read PIPE_CAPACITY. Closes issues/a-pipe-is-a-two-mebibyte-page-for-a-mapping-nothing-makes.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ake is never refused B1: pmm::free_held returns before the allocator's lock when it is handed no frame, so a pipe read that empties no frame past those kept, and a drop of a pipe that holds none, take no global lock. It takes an exact size iterator for that. S1: a pipe that has held a byte keeps what an empty one needs, so a write of at most MAX_WHOLE_PIPE_WRITE into it while empty is never refused for memory; PIPE_CAPACITY says so. toyos::wake::pair writes and takes one wake, so every bell has held a byte, and Waker::wake reads a refusal for memory as a wake already pending, which it then is. B2, S2, S4: the comments that still named a pipe's ring or its pages are corrected, and a frame's bytes are `frame_bytes`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…frames The T14 read pipe.throughput.65536 at 4451 MB/s on 46848c4 against 6930 on 544f9cc, and 16384 at 3060 against 4393: a pipe copied each 4 KiB frame run with its own UserBytes::read_at or write_at, and each of those took the address space's lock, reached and pinned the user pages the run touched and unpinned them. A run at an arbitrary user offset touches two pages, so a 64 KiB write paid that walk 16 times over about 32 page reaches, where the 2 MiB ring paid it once per 16 pages. Bytes now places a write's bytes at an offset past what it holds and counts them with commit, and peeks a read's at an offset before consume lets them go. The pipe walks the user window once, through UserBytes::read_pieces and UserBytesMut::write_pieces, which hand out the pinned pieces read_at and write_at already copied through, and copies each piece into or out of the frame runs it covers. Review r2: MAX_WHOLE_PIPE_WRITE <= FRAME is asserted where the promise to an empty pipe rests on it, the post-drain test writes a whole write, and free_held's doc says plainly that giving back none takes no lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…MiB ring The test's negative control still asserted the 2 MiB ring a pipe took on its first write. A pipe now takes a frame for its bytes; the control keeps its meaning, that a byte on a live connection buys memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session 3 read pipe.throughput at 5e0cb52 against 544f9cc, interleaved x2: 65536 5954/5975 against 6940/6844 MB/s (-13%), 16384 3683/3680 against 4456/4360 (-16%), 4096 2651/2702 against 2833/2742 (-3%). The size pattern names the spare rule: a drained pipe kept one frame, which a write of a page or less fits, while a 16 or 64 KiB write into a pipe its reader keeps up with took 4 or 16 frames from the allocator and the drain gave all but one back, each under the allocator's lock. A drained pipe now keeps as many spares as its last write spanned, beside those its bytes need, so a writer of one size reuses its frames whether its reader lags or keeps up, and one that moves to small writes keeps one again. The steady-stream test now covers the reader that drains the pipe each round, which the old rule failed (1516 frames taken in 100 rounds). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…evision the soak ran Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in its one record main already recorded this stall as two-holders-racing-on-an-exported- socket-can-both-stall.md. The soak's counts, signature and logs go there, and the second record this branch added goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The T14 diagnostic session (measurement-only counters, base 544f9cc against cd42d13, eight rounds each) put the whole of a 64 KiB pipe write's extra time in the user walk's copy phase: 153.8 against 123.5 ticks per KiB moved. Its page reach and pin, the lock's spin and hold outside the copy, and frame take and give were equal or under a tick per KiB. The head made 32 memcpys per 64 KiB write against base's 17, since every user page's piece was cut at a 4 KiB frame edge. The pipe now gathers the runs of each user piece and copies runs that continue each other in both source and destination as one. Frames a write takes come off the allocator in ascending order, so a piece over two of them is one copy when they are adjacent, as base's ring always was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… say what they are B4: Bytes::write and Bytes::read had no caller but the test helper since the kernel moved to place/commit and peek/consume; they go, and the helper writes and reads as kernel::pipe does. B5: the lock-scope record names the walk the pipe makes, write_pieces and read_pieces with peek and place inside, and when a read gives frames back. S6: the copy gatherer is Gather, not a name that shadows the Copy trait. S7: the frame-run closures' offset into the piece is `off`. S8: the spare rule is said as the largest of three. S10: the connect flood's measure is `took`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The T14's PMU session (measurement-only counters, Intel SDM Vol. 3B section 20.2 and Intel's Tiger Lake event table) put most of a pipe write's remaining cost in instructions: 67.9 against base's 24.1 retired per KiB copied at 64 KiB and 77.9 against 31.5 at 16 KiB, about 44 more per KiB or 175 per 4 KiB piece. Base, not the head, stalled on a full store buffer, and L1D replacements and L2 misses were no higher. 4K-alias blocks were lower at 64 KiB but about 2 per KiB higher at 16 and 4 KiB (+17 % and +23 %), and may account for part of what remains at 16 KiB. The kernel before this change had Bytes::place and Bytes::peek out of line, one call per user-page piece; Gather and frame_bytes were inlined into them. place and peek are inlined now, and Gather and frame_bytes are marked to stay inlined in the larger walk. frame_bytes' bounds refusal goes to a cold, never-inlined helper, as a hot accessor's refusal belongs (kernel CLAUDE.md). The same change, measured as a patch on this branch's pre-rebase head, read pipe.throughput.16384 at 4262/4260 MB/s against base's 4377/4434 and 4096 at 2662/2673 against 2661/2772, from 7 % and 4 % below base before. The readings, the counters and both kernels' symbol listings: https://github.com/ToyOSOrg/evidence/tree/f9107a6656605a9cbedeae3ad98d38f1b4d9a9d0/r6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's fileserver imports toyos_abi::syscall names individually, so the rebased stream bound names PIPE_CAPACITY there rather than through a syscall module path it no longer has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The final T14 A/B of #1074 against main 9d818b3, three interleaved boot pairs, read pipe.throughput.65536 lower on the head in every pair (mean -2.4 %, p50 7555 against 7744 MB/s), with 4096 and 16384 at base and pipe ping-pong faster. The gap is recorded with its one unmeasured candidate, frame placement under the adjacent-page prefetch, and its exit: a PMU session on the landed head at 64 KiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Orchestrator: landing at 094323f. The landing boots' reds on the T14 are all accounted for independently of this change:
|
The T14's 00:0d.0 is a spare xHCI under both boot layouts: off the stick 00:14.0 is the boot controller, and off the NVMe (#1095) no xHCI is. The AMD-Vi row amdvi_usbd_drives_its_own_entry claims it and aims its command ring past its grant, and klogd_hosted's must_be_clean refuses that staged fault. PR #1074's four stick boots and its NVMe landing boot all show it. The record that a metal row names no machine already holds this mechanism. It gains those boots and the other rows that red with it: four AMD-bench rows by the same mechanism, and i219 and hda_tone, which have records of their own. It also gains its owner. Evidence: https://github.com/ToyOSOrg/evidence/tree/b1c036144b4c8a39415cb60145a823d0c0d2d652/r3 https://github.com/ToyOSOrg/evidence/tree/3cb575aadca9e85f33b4c5690c7e1ea4ca15822f/r8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hda_tone read underruns=1 in its first stats window on PR #1074's landing boot (094323f). Judged on every distinct T14 testcases boot, it is 1 in 7 boots of #1074's branch, which mix stick and NVMe layouts, and 0 in 2 boots of main 370d7d9. That is not attributable. The record states that the cause has no candidate: the signal bell, the one pipe on the path, cannot be refused for memory after delivery in a stream this short in either tree. Its exit asks for an underrun record that names the cause, and does not close on a count of clean boots. Evidence: https://github.com/ToyOSOrg/evidence/tree/9d0ed9325f33f34ba7714fe68ca1261129c27f0a/r9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… rows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each direction of a connection is a kernel message queue: the bytes of its messages in the frames #1074 takes as a pipe fills, and beside them the messages' lengths and their handles, in the order they were sent. A send admits the whole message under the queue's lock or refuses it with nothing of it queued and every handle back at its number; a receive takes the head message whole into the caller's buffers, its handles installed under the same lock, or leaves it queued. No stream position exists any more, so nothing pins a batch of handles at one. The bounds are the approved ones, per direction: 2 MiB of bytes, taken as used, 256 messages and 64 handles. Without room the send waits, or answers WouldBlock; a receiver's buffer too small for the head message is InvalidArgument and a table without room for its handles is ResourceExhausted, both leaving it queued; a peer gone is Gone. A connection polls writable once the largest message would fit, so a refused non-blocking send retried on readiness lands. ABI: SYS_MESSAGE_RECV takes one message. SYS_HANDLE_RECV, HandlesPending and MAX_QUEUED_BATCHES are gone, and plain read and write on a connection are refused PermissionDenied: a connection is never a byte stream. SDK: a frame is a 4-byte message type and its payload, the kernel reporting length and handle count, so IpcHeader goes. FrameRx is a buffer and one receive, non-blocking (pump) or blocking (recv), and replaces recv_header/recv_payload/recv_bytes/recv_handles and the partial-frame state machine. A send frames into an uninitialised buffer instead of a zeroed 8 KiB one. Every consumer moves to it in this commit. netstack's Client::gone asks with a non-blocking receive instead of a one-byte read. The link (#1106) rang its doorbell as a byte on its connection, the last such doorbell; it rings an event each way now, as the block service's do. The hello carries the bell the driver rings, the answer the bell the stack rings, and a bell whose waker is gone is the link's end, so the connection goes once the link is open. Deleted: kernel/pure/message.rs's batches and windows, HandleQueue, AtBatch, try_read_within, the pipe's written/read counters and Landing (a pipe's write landing is its own rule now), the connection arms of the pipe read and write paths, the acceptor's close_now of each queued connection (a pending connection's receiver goes with it). Tests: the pure queue (order, wholeness, each bound, a refused receive taking nothing, a 50,000-step random run against the order sent); the SDK's framing and refusals; messages_are_whole, replacing message_carries_its_handles, for the syscalls' refusals and the bounds as a sender meets them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each direction of a connection is a kernel message queue: the bytes of its messages in the frames #1074 takes as a pipe fills, and beside them the messages' lengths and their handles, in the order they were sent. A send admits the whole message under the queue's lock or refuses it with nothing of it queued and every handle back at its number; a receive takes the head message whole into the caller's buffers, its handles installed under the same lock, or leaves it queued. No stream position exists any more, so nothing pins a batch of handles at one. The bounds are the approved ones, per direction: 2 MiB of bytes, taken as used, 256 messages and 64 handles. Without room the send waits, or answers WouldBlock; a receiver's buffer too small for the head message is InvalidArgument and a table without room for its handles is ResourceExhausted, both leaving it queued; a peer gone is Gone. A connection polls writable once the largest message would fit, so a refused non-blocking send retried on readiness lands. ABI: SYS_MESSAGE_RECV takes one message. SYS_HANDLE_RECV, HandlesPending and MAX_QUEUED_BATCHES are gone, and plain read and write on a connection are refused PermissionDenied: a connection is never a byte stream. SDK: a frame is a 4-byte message type and its payload, the kernel reporting length and handle count, so IpcHeader goes. FrameRx is a buffer and one receive, non-blocking (pump) or blocking (recv), and replaces recv_header/recv_payload/recv_bytes/recv_handles and the partial-frame state machine. A send frames into an uninitialised buffer instead of a zeroed 8 KiB one. Every consumer moves to it in this commit. netstack's Client::gone asks with a non-blocking receive instead of a one-byte read. The link (#1106) rang its doorbell as a byte on its connection, the last such doorbell; it rings an event each way now, as the block service's do. The hello carries the bell the driver rings, the answer the bell the stack rings, and a bell whose waker is gone is the link's end, so the connection goes once the link is open. Deleted: kernel/pure/message.rs's batches and windows, HandleQueue, AtBatch, try_read_within, the pipe's written/read counters and Landing (a pipe's write landing is its own rule now), the connection arms of the pipe read and write paths, the acceptor's close_now of each queued connection (a pending connection's receiver goes with it). Tests: the pure queue (order, wholeness, each bound, a refused receive taking nothing, a 50,000-step random run against the order sent); the SDK's framing and refusals; messages_are_whole, replacing message_carries_its_handles, for the syscalls' refusals and the bounds as a sender meets them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each direction of a connection is a kernel message queue: the bytes of its messages in the frames #1074 takes as a pipe fills, and beside them the messages' lengths and their handles, in the order they were sent. A send admits the whole message under the queue's lock or refuses it with nothing of it queued and every handle back at its number; a receive takes the head message whole into the caller's buffers, its handles installed under the same lock, or leaves it queued. No stream position exists any more, so nothing pins a batch of handles at one. The bounds are the approved ones, per direction: 2 MiB of bytes, taken as used, 256 messages and 64 handles. Without room the send waits, or answers WouldBlock; a receiver's buffer too small for the head message is InvalidArgument and a table without room for its handles is ResourceExhausted, both leaving it queued; a peer gone is Gone. A connection polls writable once the largest message would fit, so a refused non-blocking send retried on readiness lands. ABI: SYS_MESSAGE_RECV takes one message. SYS_HANDLE_RECV, HandlesPending and MAX_QUEUED_BATCHES are gone, and plain read and write on a connection are refused PermissionDenied: a connection is never a byte stream. SDK: a frame is a 4-byte message type and its payload, the kernel reporting length and handle count, so IpcHeader goes. FrameRx is a buffer and one receive, non-blocking (pump) or blocking (recv), and replaces recv_header/recv_payload/recv_bytes/recv_handles and the partial-frame state machine. A send frames into an uninitialised buffer instead of a zeroed 8 KiB one. Every consumer moves to it in this commit. netstack's Client::gone asks with a non-blocking receive instead of a one-byte read. The link (#1106) rang its doorbell as a byte on its connection, the last such doorbell; it rings an event each way now, as the block service's do. The hello carries the bell the driver rings, the answer the bell the stack rings, and a bell whose waker is gone is the link's end, so the connection goes once the link is open. Deleted: kernel/pure/message.rs's batches and windows, HandleQueue, AtBatch, try_read_within, the pipe's written/read counters and Landing (a pipe's write landing is its own rule now), the connection arms of the pipe read and write paths, the acceptor's close_now of each queued connection (a pending connection's receiver goes with it). Tests: the pure queue (order, wholeness, each bound, a refused receive taking nothing, a 50,000-step random run against the order sent); the SDK's framing and refusals; messages_are_whole, replacing message_carries_its_handles, for the syscalls' refusals and the bounds as a sender meets them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each direction of a connection is a kernel message queue: the bytes of its messages in the frames #1074 takes as a pipe fills, and beside them the messages' lengths and their handles, in the order they were sent. A send admits the whole message under the queue's lock or refuses it with nothing of it queued and every handle back at its number; a receive takes the head message whole into the caller's buffers, its handles installed under the same lock, or leaves it queued. No stream position exists any more, so nothing pins a batch of handles at one. The bounds are the approved ones, per direction: 2 MiB of bytes, taken as used, 256 messages and 64 handles. Without room the send waits, or answers WouldBlock; a receiver's buffer too small for the head message is InvalidArgument and a table without room for its handles is ResourceExhausted, both leaving it queued; a peer gone is Gone. A connection polls writable once the largest message would fit, so a refused non-blocking send retried on readiness lands. ABI: SYS_MESSAGE_RECV takes one message. SYS_HANDLE_RECV, HandlesPending and MAX_QUEUED_BATCHES are gone, and plain read and write on a connection are refused PermissionDenied: a connection is never a byte stream. SDK: a frame is a 4-byte message type and its payload, the kernel reporting length and handle count, so IpcHeader goes. FrameRx is a buffer and one receive, non-blocking (pump) or blocking (recv), and replaces recv_header/recv_payload/recv_bytes/recv_handles and the partial-frame state machine. A send frames into an uninitialised buffer instead of a zeroed 8 KiB one. Every consumer moves to it in this commit. netstack's Client::gone asks with a non-blocking receive instead of a one-byte read. The link (#1106) rang its doorbell as a byte on its connection, the last such doorbell; it rings an event each way now, as the block service's do. The hello carries the bell the driver rings, the answer the bell the stack rings, and a bell whose waker is gone is the link's end, so the connection goes once the link is open. Deleted: kernel/pure/message.rs's batches and windows, HandleQueue, AtBatch, try_read_within, the pipe's written/read counters and Landing (a pipe's write landing is its own rule now), the connection arms of the pipe read and write paths, the acceptor's close_now of each queued connection (a pending connection's receiver goes with it). Tests: the pure queue (order, wholeness, each bound, a refused receive taking nothing, a 50,000-step random run against the order sent); the SDK's framing and refusals; messages_are_whole, replacing message_carries_its_handles, for the syscalls' refusals and the bounds as a sender meets them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each direction of a connection is a kernel message queue: the bytes of its messages in the frames #1074 takes as a pipe fills, and beside them the messages' lengths and their handles, in the order they were sent. A send admits the whole message under the queue's lock or refuses it with nothing of it queued and every handle back at its number; a receive takes the head message whole into the caller's buffers, its handles installed under the same lock, or leaves it queued. No stream position exists any more, so nothing pins a batch of handles at one. The bounds are the approved ones, per direction: 2 MiB of bytes, taken as used, 256 messages and 64 handles. Without room the send waits, or answers WouldBlock; a receiver's buffer too small for the head message is InvalidArgument and a table without room for its handles is ResourceExhausted, both leaving it queued; a peer gone is Gone. A connection polls writable once the largest message would fit, so a refused non-blocking send retried on readiness lands. ABI: SYS_MESSAGE_RECV takes one message. SYS_HANDLE_RECV, HandlesPending and MAX_QUEUED_BATCHES are gone, and plain read and write on a connection are refused PermissionDenied: a connection is never a byte stream. SDK: a frame is a 4-byte message type and its payload, the kernel reporting length and handle count, so IpcHeader goes. FrameRx is a buffer and one receive, non-blocking (pump) or blocking (recv), and replaces recv_header/recv_payload/recv_bytes/recv_handles and the partial-frame state machine. A send frames into an uninitialised buffer instead of a zeroed 8 KiB one. Every consumer moves to it in this commit. netstack's Client::gone asks with a non-blocking receive instead of a one-byte read. The link (#1106) rang its doorbell as a byte on its connection, the last such doorbell; it rings an event each way now, as the block service's do. The hello carries the bell the driver rings, the answer the bell the stack rings, and a bell whose waker is gone is the link's end, so the connection goes once the link is open. Deleted: kernel/pure/message.rs's batches and windows, HandleQueue, AtBatch, try_read_within, the pipe's written/read counters and Landing (a pipe's write landing is its own rule now), the connection arms of the pipe read and write paths, the acceptor's close_now of each queued connection (a pending connection's receiver goes with it). Tests: the pure queue (order, wholeness, each bound, a refused receive taking nothing, a 50,000-step random run against the order sent); the SDK's framing and refusals; messages_are_whole, replacing message_carries_its_handles, for the syscalls' refusals and the bounds as a sender meets them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each direction of a connection is a kernel message queue: the bytes of its messages in the frames #1074 takes as a pipe fills, and beside them the messages' lengths and their handles, in the order they were sent. A send admits the whole message under the queue's lock or refuses it with nothing of it queued and every handle back at its number; a receive takes the head message whole into the caller's buffers, its handles installed under the same lock, or leaves it queued. No stream position exists any more, so nothing pins a batch of handles at one. The bounds are the approved ones, per direction: 2 MiB of bytes, taken as used, 256 messages and 64 handles. Without room the send waits, or answers WouldBlock; a receiver's buffer too small for the head message is InvalidArgument and a table without room for its handles is ResourceExhausted, both leaving it queued; a peer gone is Gone. A connection polls writable once the largest message would fit, so a refused non-blocking send retried on readiness lands. ABI: SYS_MESSAGE_RECV takes one message. SYS_HANDLE_RECV, HandlesPending and MAX_QUEUED_BATCHES are gone, and plain read and write on a connection are refused PermissionDenied: a connection is never a byte stream. SDK: a frame is a 4-byte message type and its payload, the kernel reporting length and handle count, so IpcHeader goes. FrameRx is a buffer and one receive, non-blocking (pump) or blocking (recv), and replaces recv_header/recv_payload/recv_bytes/recv_handles and the partial-frame state machine. A send frames into an uninitialised buffer instead of a zeroed 8 KiB one. Every consumer moves to it in this commit. netstack's Client::gone asks with a non-blocking receive instead of a one-byte read. The link (#1106) rang its doorbell as a byte on its connection, the last such doorbell; it rings an event each way now, as the block service's do. The hello carries the bell the driver rings, the answer the bell the stack rings, and a bell whose waker is gone is the link's end, so the connection goes once the link is open. Deleted: kernel/pure/message.rs's batches and windows, HandleQueue, AtBatch, try_read_within, the pipe's written/read counters and Landing (a pipe's write landing is its own rule now), the connection arms of the pipe read and write paths, the acceptor's close_now of each queued connection (a pending connection's receiver goes with it). Tests: the pure queue (order, wholeness, each bound, a refused receive taking nothing, a 50,000-step random run against the order sent); the SDK's framing and refusals; messages_are_whole, replacing message_carries_its_handles, for the syscalls' refusals and the bounds as a sender meets them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each direction of a connection is a kernel message queue: the bytes of its messages in the frames #1074 takes as a pipe fills, and beside them the messages' lengths and their handles, in the order they were sent. A send admits the whole message under the queue's lock or refuses it with nothing of it queued and every handle back at its number; a receive takes the head message whole into the caller's buffers, its handles installed under the same lock, or leaves it queued. No stream position exists any more, so nothing pins a batch of handles at one. The bounds are the approved ones, per direction: 2 MiB of bytes, taken as used, 256 messages and 64 handles. Without room the send waits, or answers WouldBlock; a receiver's buffer too small for the head message is InvalidArgument and a table without room for its handles is ResourceExhausted, both leaving it queued; a peer gone is Gone. A connection polls writable once the largest message would fit, so a refused non-blocking send retried on readiness lands. ABI: SYS_MESSAGE_RECV takes one message. SYS_HANDLE_RECV, HandlesPending and MAX_QUEUED_BATCHES are gone, and plain read and write on a connection are refused PermissionDenied: a connection is never a byte stream. SDK: a frame is a 4-byte message type and its payload, the kernel reporting length and handle count, so IpcHeader goes. FrameRx is a buffer and one receive, non-blocking (pump) or blocking (recv), and replaces recv_header/recv_payload/recv_bytes/recv_handles and the partial-frame state machine. A send frames into an uninitialised buffer instead of a zeroed 8 KiB one. Every consumer moves to it in this commit. netstack's Client::gone asks with a non-blocking receive instead of a one-byte read. The link (#1106) rang its doorbell as a byte on its connection, the last such doorbell; it rings an event each way now, as the block service's do. The hello carries the bell the driver rings, the answer the bell the stack rings, and a bell whose waker is gone is the link's end, so the connection goes once the link is open. Deleted: kernel/pure/message.rs's batches and windows, HandleQueue, AtBatch, try_read_within, the pipe's written/read counters and Landing (a pipe's write landing is its own rule now), the connection arms of the pipe read and write paths, the acceptor's close_now of each queued connection (a pending connection's receiver goes with it). Tests: the pure queue (order, wholeness, each bound, a refused receive taking nothing, a 50,000-step random run against the order sent); the SDK's framing and refusals; messages_are_whole, replacing message_carries_its_handles, for the syscalls' refusals and the bounds as a sender meets them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
A pipe's bytes lived in one zeroed 2 MiB page taken on its first write, a leftover from when
SYS_PIPE_MAPmapped it into a process. Every pipe that carried a byte held 2 MiB: a bell, each direction of a connection, and both pipes of the connection a TCP connect opens, zeroed before the connect returned. This is part 1 of the approved "remove the memory a TCP connect wastes" assignment (the owner's direction of 2026-10-27, recorded inissues/a-tcp-connect-zeroes-twelve-mebibytes.mdon #1069). Part 2, the stream region sized to use, is #1077, stacked on this PR.What changes
kernel::pipe::Bytes, pure and host-tested;kernel/src/pipe.rs).MOST_FRAMES. A stream of writes of one size takes nothing from the allocator, whether its reader keeps up or lags.toyos_abi::syscall::PIPE_CAPACITY, sopipe.throughputkeeps its backlog. netstack'sPIPE_BYTESand fileserver'sSTREAM_PRESENTread it.Frames::alloc_heldandfree_heldwork in batches. The ledger'stablescount is renamedheld. mm: every charge is its job's, the controls keep a floor, and a restart refused for memory waits for it #1057's floors and refusals apply throughadmitunchanged.toyos_abi::ring:Ring,RingHeader,Src,Dst, and the reader and writer flags nothing read.UserBytes::read_runandUserBytesMut::write_run.publish_ends, and a hand-writtenunsafe impl Send.issues/a-pipe-is-a-two-mebibyte-page-for-a-mapping-nothing-makes.md.Decisions and why
pipe.throughput.65536relies on (7.2 GB/s on the T14).symbols/) hasBytes::placeandBytes::peekout of line, one call per user-page piece, withGatherandframe_bytesalready inlined into them.frame_bytes' bounds refusal is a cold, never-inlined helper (kernel CLAUDE.md).VecDequewhose heap is charged to the job. It grows by doubling up toMOST_FRAMES, at most 4 KiB, and does not shrink.Behaviour change and risks
ResourceExhaustedwhenever it needs a new frame and its job's memory is spent. Before, only the first write could be refused. A drained pipe keeps at least one frame, and as many as its last write spanned, so a message of up to 4 KiB still crosses an exhaustion.memory_floorexercises exactly this, and it passes. What must keep working holds a floor.pipe.throughput.65536, p50, MB/s):9d818b3ed(three interleaved pairs, NVMe boot, evidence r7): 4096 3021 against 3020 and 16384 4806 against 4790, both at base. 65536 is 7555 against 7744, −2.4%, lower in every pair, with base's own boot-to-boot spread 2.5%.issues/a-64-kib-pipe-write-is-two-percent-below-the-two-mebibyte-ring.md. 16 KiB crosses as many pieces per KiB and is at base, so per-piece instructions (the frame-aware walk) are not expected to recover it. Its unmeasured candidate is frame placement.issues/a-pipes-lock-spans-a-user-copy-and-its-first-allocation.mdis updated to match.Checks
The head is
094323f29: the branch rebased onto main370d7d9f6(#1095) with no conflicts. Both builds and--ci hostexit 0 there, and the final A/B readbacks were rejudged with main's harness. Before that,b7c364a43was rebased onto main9d818b3edwith no conflicts, plus the inlining and fileserver's import ofPIPE_CAPACITY(main now imports ABI names one by one).b7c364a43only rewords7b9fe58f2's message (review r4), and their tree9e1d8397cis identical. Main changes none of the pipe, the user copy, pmm or the frame allocator, and #1074 adds no syscall.cargo run -- --ci host;--build-onlyx86_64 and aarch64094323f29cargo run -- --ci host48c92e307(+ the 64 KiB record)cargo run -- --ci host(includes the kernel library's pipe and frame tests)7b9fe58f2cargo run -- --build-onlyand--arch aarch647b9fe58f252c9e14b1(tree9e1d8397c)d72a6ffc5(the same change on maina25cd36ce)Guest on QEMU:
cargo test --test toyos-build -- kernel_objects_refused_alone memory_floor_holds_the_controls stream_holders netstack_holders_exported_to_children session_lifecycle libc_children udp_receive_times_out: exit 0, 7 of 7.Negative controls, each a checked patch that builds, applied and restored:
T14, each readback judged by the harness:
abuse_connect_floodin session 2, whose 2 MiB premise was this PR's and is fixed.pingpong.pipe.*p50 is faster on the head in every pair.094323f29):usbd_spareunder the USB-boot layout this image predates. i219 is the recorded no-frame-on-the-wire row. A fresh testcases and watchdog boot of094323f29is requested.wake.onto_busy(partner not placed), one of them on a base image. That is issues: a spawn can miss a CPU holding parked threads for seconds at boot #1087's placement record, and its counts are updated in a separate records PR.Retention, from the real
Byteson the host:Evidence:
🤖 Generated with Claude Code