Skip to content

kernel: a pipe keeps its bytes in 4 KiB frames it takes as it fills - #1074

Merged
Japabu merged 12 commits into
mainfrom
tcp-connect
Oct 28, 2026
Merged

Japabu merged 12 commits into
mainfrom
tcp-connect

Conversation

@Japabu

@Japabu Japabu commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A pipe's bytes lived in one zeroed 2 MiB page taken on its first write, a leftover 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 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 in issues/a-tcp-connect-zeroes-twelve-mebibytes.md on #1069). Part 2, the stream region sized to use, is #1077, stacked on this PR.

What changes

  • A pipe keeps its bytes in 4 KiB frames counted to the job that made it (kernel::pipe::Bytes, pure and host-tested; kernel/src/pipe.rs).
    • A write takes the frames it needs, all of them or none, under one hold of the allocator's lock.
    • A read moves the frames it empties to the back as spares. It keeps spares up to the largest of three: the frames still holding bytes, the frames the last write spanned, and one. It gives back the rest.
    • So a pipe holds at most its bytes' frames plus that largest, and never more than MOST_FRAMES. A stream of writes of one size takes nothing from the allocator, whether its reader keeps up or lags.
    • A read or write makes one walk of the user window per call, as the 2 MiB ring did. It copies each pinned piece into or out of the frame runs it covers, and runs that continue each other on both sides are one copy.
    • 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.
  • Capacity stays 2 MiB, now declared as toyos_abi::syscall::PIPE_CAPACITY, so pipe.throughput keeps its backlog. netstack's PIPE_BYTES and fileserver's STREAM_PRESENT read it.
  • The allocator's page-table path becomes "frames a job holds one by one". Frames::alloc_held and free_held work in batches. The ledger's tables count is renamed held. 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 through admit unchanged.
  • Deleted:
    • toyos_abi::ring: Ring, RingHeader, Src, Dst, and the reader and writer flags nothing read.
    • UserBytes::read_run and UserBytesMut::write_run.
    • publish_ends, and a hand-written unsafe impl Send.
    • This closes issues/a-pipe-is-a-two-mebibyte-page-for-a-mapping-nothing-makes.md.

Decisions and why

  • Why not a small fixed ring: it would cut the backlog pipe.throughput.65536 relies on (7.2 GB/s on the T14).
  • Why spares up to the last write's frames: with one spare, each 16 or 64 KiB write into a pipe its reader kept up with took its frames from the allocator again, and the drain gave them back. Session 4 showed that this fixed 16 KiB.
  • Why merge copies: the diagnostic session put the rest of 64 KiB's cost in the memcpys, not in the lock, the page walk or frame bookkeeping. The head copied 153.8 ticks per KiB against base's 123.5, using 32 memcpys per 64 KiB write against base's 17, because every user page's piece was cut at a frame edge.
  • Why inline the per-piece path: three more sessions ruled out frame reuse distance, 4K aliasing between the user page and the frame, and frame placement as the gap's cause. Then the core's counters (Intel SDM Vol. 3B §20.2; Intel's Tiger Lake event table) put most of the rest in instructions.
    • At 64 KiB the head retired 67.9 instructions per KiB copied against base's 24.1, and at 16 KiB 77.9 against 31.5.
    • L1D replacements and L2 misses were no higher, and base waited on a full store buffer while the head did not.
    • 4K-alias blocks were lower at 64 KiB but about 2 per KiB higher at 16 KiB (+17%) and 4 KiB (+23%). They may account for part of the 16 KiB residual; this is an inference, not measured.
    • The pre-change kernel's symbol listing (r6 symbols/) has Bytes::place and Bytes::peek out of line, one call per user-page piece, with Gather and frame_bytes already inlined into them.
    • They are inlined now, and frame_bytes' bounds refusal is a cold, never-inlined helper (kernel CLAUDE.md).
  • The frame list is a VecDeque whose heap is charged to the job. It grows by doubling up to MOST_FRAMES, at most 4 KiB, and does not shrink.

Behaviour change and risks

  • A pipe write can now be refused ResourceExhausted whenever 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_floor exercises exactly this, and it passes. What must keep working holds a floor.
  • Pipe throughput is held to base, and each step was measured on the T14 (pipe.throughput.65536, p50, MB/s):
    • a copy per 4 KiB frame: 4451 against 6930 (session 2);
    • one walk per call: −13% (session 3);
    • the working set: −12% at 65536, with 16 KiB fixed (session 4);
    • merged copies (0a48bf8): −1% at 65536, but −7% at 16384 and −4% at 4096 (session 5);
    • inlined (the candidate image, diag5): 65536 6924 / 6861 against base's 6953 / 6916; 16384 4262 / 4260 against 4377 / 4434; 4096 2662 / 2673 against 2661 / 2772;
    • final A/B against main 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%.
    • The 64 KiB gap is recorded as 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.
  • A pipe whose last write was large keeps that write's frames until its next write, for example 512 frames after a drained 2 MiB write.
  • A pipe read that frees nothing takes no allocator lock, and a bell's wake is never refused for memory (review r1).
  • Frames are still taken and given back under the pipe's lock, nesting the allocator's lock as the 2 MiB page did. The page's 2 MiB zeroing is gone. issues/a-pipes-lock-spans-a-user-copy-and-its-first-allocation.md is updated to match.

Checks

The head is 094323f29: the branch rebased onto main 370d7d9f6 (#1095) with no conflicts. Both builds and --ci host exit 0 there, and the final A/B readbacks were rejudged with main's harness. Before that, b7c364a43 was rebased onto main 9d818b3ed with no conflicts, plus the inlining and fileserver's import of PIPE_CAPACITY (main now imports ABI names one by one). b7c364a43 only rewords 7b9fe58f2's message (review r4), and their tree 9e1d8397c is identical. Main changes none of the pipe, the user copy, pmm or the frame allocator, and #1074 adds no syscall.

Check Commit Result
cargo run -- --ci host; --build-only x86_64 and aarch64 094323f29 exit 0; exit 0, exit 0
cargo run -- --ci host 48c92e307 (+ the 64 KiB record) exit 0
cargo run -- --ci host (includes the kernel library's pipe and frame tests) 7b9fe58f2 exit 0, 105 steps green
cargo run -- --build-only and --arch aarch64 7b9fe58f2 exit 0, exit 0
QEMU 7 (below) 52c9e14b1 (tree 9e1d8397c) exit 0, 7 of 7
QEMU 7 d72a6ffc5 (the same change on main a25cd36ce) exit 0, 7 of 7

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:

  • Keep no spare frame: 3 pipe tests red.
  • Keep every spare frame: 2 pipe tests red.
  • Keep spares only up to the frames holding bytes: the steady-stream test's draining reader is red, taking 1516 frames in 100 rounds.

T14, each readback judged by the harness:

Retention, from the real Bytes on the host:

  • Bell and request/answer: 1 frame.
  • Bulk 64 KiB: 16 frames, or 272 with a reader 1 MiB behind.
  • A drained 2 MiB write: 512 frames until the next write.
  • At most 513 frames per pipe, plus about 4 KiB of list heap, charged to its job.

Evidence:

🤖 Generated with Claude Code

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1: #1074 at 1f828f331

Architecture

The design fits. A pipe's bytes are kernel-only, so the kernel is the right owner. Using one "frames a job holds" path for both page tables and pipe bytes is a real unification, not a second mechanism. Deleting toyos_abi::ring leaves the ABI holding only the boundary constant PIPE_CAPACITY. The queue logic lives in kernel/pure and is host-tested against model frames poisoned with 0xEE, so a read that returned unwritten bytes would fail the stream test.

Net change is +542 / −753. Most of the deletion is the 479-line ring. Most of the addition is about 120 lines of Bytes plus about 165 lines of host tests.

What I checked and found sound:

  • Frame bounds. wants cannot ask for more than MOST_FRAMES = 513, because head < FRAME and len + n ≤ PIPE_CAPACITY. The list is charged before reserve_exact, and add never grows it past what was charged.
  • Floors (mm: every charge is its job's, the controls keep a floor, and a restart refused for memory waits for it #1057). alloc_held goes through admit(l, count, count, 0), the same terms as the old single table frame, so floors apply.
  • All or none. A refused batch hands nothing over. A list charge that grew before the refusal stays charged to the pipe and is freed when the pipe is.
  • PIPE_BUF. A write up to MAX_WHOLE_PIPE_WRITE is atomic. room == len, its frames are taken whole, and the copy runs in the same hold of the pipe lock.
  • EOF and handles. These are unchanged. Writes after the read end's last release are BrokenPipe before any frame is taken, so moving the frames out at that release is final. Held::drop then has nothing left to free.
  • No stale kernel memory reaches userland. A read copies only [head, head+len). Every byte there was written by a read_at that copies its whole run from a window bounded beforehand, or panics. head resets when the pipe empties. I see no information leak.
  • Lock order. It stays pipe lock, then allocator lock, as before. RETURNED.post_in_place is IRQ-safe, so posting it under the pipe lock is fine.

The new mid-stream ResourceExhausted. It is a correct, explicitly reported refusal under #1057's model: an ordinary job can be refused when memory is exhausted. A drained pipe keeps one frame, and head resets to 0, so request/response traffic crosses an exhaustion without taking a frame. Only a backlog that crosses a frame edge can be refused. How the users handle it:

  • netstack bells (pipes.rs, lent.rs) ignore it. That is correct, because the unread bytes are already a ring.
  • IPC reports it as IpcError::Syscall(ResourceExhausted).
  • fileserver streams end the stream, with a reason.
  • libc maps it to ENOMEM.
  • Waker::wake panics on it; see S1.

The spare policy. It keeps at most as many spares as frames still holding bytes, and at least one. That is ordinary hysteresis, with a stated 2·used+1 bound. The two mutation controls are meaningful: keep-none fails three tests and keep-all fails two, each on the claim it should break.

Deletion. It is complete. No tree hit remains for the slug, abi::ring, RingHeader (outside inbox), PIPE_SIZE, read_run/write_run or publish_ends. Nothing in rust/library uses the ring. The closed issue's exit rg commands come back empty.

Evidence reuse. The guest run on d12c5dec9 still applies to the head. Its diff to 1f828f331 is a const-expression spelling, an unused is_empty and a sourcegate row. The pipe guest binaries run only in the T14 testcases boot, so kernel-side pipe behaviour beyond the boot's own IPC is unverified until the orchestrator's metal run.

Findings

BLOCKER B1: kernel/src/pipe.rs:117 (give_back_surplus, called at :238 on every read that returns bytes), kernel/src/mm/pmm.rs:338 (free_held) and kernel/src/pipe.rs:124 (Held::drop).

  • What. Every successful pipe read takes the global FRAMES lock while holding the pipe lock, even when surplus() gives back nothing. Inside it, Frames::free_held runs give_back_for, which calls rearm and refill, and then take_returned.
  • Why it matters. Pipe reads are the hottest IPC path: every request and reply read, and every bell drain. The old design never took the allocator lock after the first write. This adds a global lock and its bookkeeping to every read on every CPU. It is nested under the per-pipe lock, and the issue record says that lock is already over its hold budget. One-pair pipe.throughput would not show the contention, so the planned metal rows cannot clear it.
  • Evidence it is an oversight. pmm::alloc_held already returns early for count == 0 (pmm.rs:328); the free side has no such check.
  • Fix. Return early when the drain is empty. Drain is an ExactSizeIterator, so this costs nothing, whether in give_back_surplus and Held::drop or in pmm::free_held.

BLOCKER B2: false comments after the change.

  • sdk/abi/src/syscall.rs:2758: AccountInfo::kernel says "runs such as pipe rings". A pipe now holds frames, not runs. This field is where those frames are reported (pages(u.held), object/job.rs:70).
  • kernel/pure/frames.rs:920: alloc_run_for says "such as a pipe's ring". No pipe takes a run any more.
  • Fix. Name page tables and pipe bytes as held frames in the first, and delete the pipe example from the second.

SUGGESTION S1: sdk/toyos/src/wake.rs:32: Waker::wake panics on any error but WouldBlock and Gone. Its first write could already panic when memory was exhausted. Now a wake that crosses a frame edge can too, when its bell holds a frame's worth of unread wakes. Treating ResourceExhausted as "already pending" is right only when bytes are unread, which the waker cannot tell. The window is narrow, because take drains completely. Consider making the first-write case unreachable, for example with one wake written at pair(), or stating the panic's contract. Optional.

SUGGESTION S2: kernel/src/pipe.rs:232 and :270: the copy closures name a frame's physical address held, next to the Held type, the pipe.held field and the frame() helper. Naming it frame and renaming the helper (for example frame_bytes) would read more directly.

SUGGESTION S3: system/netstack/src/main.rs:102: PIPE_BYTES prices a full pipe at PIPE_CAPACITY, but a full pipe holds 513 frames plus about 4 KiB of list. The comment admits this. A bound described as "can make this machine hold" could include it, or say that it doesn't. Minor.

SUGGESTION S4: sdk/abi/src/syscall.rs:1031: "no pipe pages" as a sys_pipe refusal was already stale before this PR, because a pipe never took a page at creation. It is now plainly false: creation fails on the heap charge. Delete it if you are touching the file for B2 anyway.

The T14 rows the PR requests (connect p50, pipe.throughput, and testcases) remain owed before landing. That is the orchestrator's step, not a finding.

SEND BACK

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 answered at 46848c4

  • B1, fixed. pmm::free_held now returns before it takes the allocator's lock when it is handed no frame. It takes an ExactSizeIterator, so both the read path's give_back_surplus and Held::drop take no global lock unless frames actually go back.
  • B2, fixed. AccountInfo::kernel now names pipes' bytes, and alloc_run_for no longer names a pipe.
  • S1, taken.
    • The ABI promises at PIPE_CAPACITY that a pipe which has held a byte never refuses, for memory, a write of at most MAX_WHOLE_PIPE_WRITE into it while it is empty. A drained pipe keeps one frame and its head resets to 0, which the pure tests already hold.
    • toyos::wake::pair writes and takes one wake, so every bell has held a byte before its first real wake.
    • Waker::wake therefore reads ResourceExhausted as a wake already pending. The panic is no longer reachable.
  • S2, taken. frame_bytes, and the closures' argument is frame.
  • S3, declined. The PIPE_BYTES comment already says what the bound leaves out.
  • S4, taken. The pipe() doc now names the heap charge and the handle cap.

Checks at 46848c4:

Check Command Result
Pure tests cargo test --lib pipe frames in kernel/ 55 passed
Images cargo run -- --build-only --arch {x86_64,aarch64} exit 0, exit 0
Host suite cargo run -- --ci host exit 0, 104 steps green
QEMU the same 7 tests as before 7 of 7

T14, the first session, judged by the harness. Each readback was laid out as its staged boot directory and judged with cargo test --test toyos-build -- --metal --metal-readback <dir> <row>: every one exit 0, 1 passed.

lan.wired.tcp.connect, p50 / min run 1 run 2
base 544f9cc3c 2.03 / 1.79 ms 2.99 / 1.85 ms
head 1f828f331 2.56 / 1.75 ms 2.93 / 1.97 ms
Linux 1.15 / 0.90 ms 1.94 / 1.11 ms

Head images at 46848c4 are re-staged for the next session.

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2: #1074 at 46848c491

The only new commit is 46848c491: 5 files, +32/−19. It does not change the architecture. The S1 promise is a property of the existing Bytes keep policy, so it adds no new state.

r1 blockers

B1: resolved.

  • pmm::free_held now returns before pins() and before the FRAMES lock when frames.len() == 0 (kernel/src/mm/pmm.rs:338-342).
  • Drain and the paging callers' [phys] are both ExactSizeIterator, so the bound costs nothing.
  • Reads. A read whose emptied frames stay as spares has an empty surplus(), so it takes no global lock.
  • Drops. release swaps in a Held::new only when frames() > 0. Dropping that fresh Held, or any Held already emptied, hands free_held an empty drain, which also returns early.

B2: resolved.

  • AccountInfo::kernel (sdk/abi/src/syscall.rs:2760) now names pipes' bytes. That matches pages(u.held) in object/job.rs:70.
  • alloc_run_for no longer names a pipe.

Suggestions taken

S2 and S4 are taken and read correctly. S3 was declined with a reason, and I accept it.

S1: the promise, path by path

The promise (syscall.rs:1193): a pipe that has held a byte, written while it is empty with at most MAX_WHOLE_PIPE_WRITE bytes, is never refused for memory. I checked each path.

  • Drained pipe. read resets head to 0 when len reaches 0 (kernel/pure/pipe.rs:237). With used() == 0, surplus keeps min(frames, 1) (:246). A pipe that held a byte had at least one frame, and nothing else removes frames while the reader holds. So the drained pipe keeps exactly one frame.
  • The next write. wants(n) = ceil(n / FRAME) - 1 = 0 for n ≤ 4096. take_frames returns on wants == 0 before the list charge or alloc_held (kernel/src/pipe.rs:103). No heap, ledger or allocator call is made.
  • Other refusal sources. The non-blocking write path (syscall/io.rs:203, ops::write_pipe) has no other ResourceExhausted source.
  • Reader gone. release gives the frames back only once no reader holds. Every later write is then BrokenPipe, which reaches userland as Gone before any frame is wanted. The promise holds, because the refusal is not for memory.
  • Job floors. The frame kept is already counted to the job, and nothing is admitted. Floors cannot refuse it, and nothing in the tree takes a held frame back from a live pipe. The only free_held callers are the surplus, Held::drop and the page tables.
  • Where it is stated. The promise is stated at PIPE_CAPACITY, as asked.

Waker::wake. Under the promise, ResourceExhausted from a primed bell means it was not empty under the pipe lock at that moment. A wake is therefore already pending, and the reader will see it, so ignoring it is correct.

  • Waker and Bell are private tuple structs, built only in pair(). No unprimed pipe can become a Waker.
  • The netstack bells (pipes.rs, lent.rs) are not toyos::wake bells, and already ignore the refusal correctly (r1).

Priming. No reader sees a different result.

  • pair() writes one byte and take()s it before returning. The Bell is not yet registered with any Poller, so no caller can observe the first wake.
  • take cannot panic here, because the writer is held.
  • The cost is one frame per bell from creation. Exhaustion now refuses pair(), which callers already handle as Result, instead of a later wake. That is better placement, not a regression.

Tested? Only partly. See B3.

Findings

BLOCKER B3: kernel/pure/pipe.rs:23 and :725. The new ABI promise rests on MAX_WHOLE_PIPE_WRITE <= FRAME, and nothing enforces it.

  • Why the relationship matters. The kept frame carries a write into an empty pipe only because the largest atomic write fits in one frame. Today both are 4096, but the const assertion at :23 checks only MAX_WHOLE_PIPE_WRITE <= PIPE_CAPACITY.
  • What the tests show. The test the response cites, a_drained_pipe_gives_its_frames_back, writes 3 bytes after the drain. It does not write MAX_WHOLE_PIPE_WRITE.
  • Consequence. If MAX_WHOLE_PIPE_WRITE is raised, or the frame size changes, the promise breaks silently. Waker::wake now swallows ResourceExhausted because of that promise, so the breakage would become a lost wake into an empty bell. That shows up as a hang, not a panic.
  • The response's claim. It says the pure tests "already hold" the promise. They hold one case of it, and the constant relationship is unchecked.
  • Fix (two lines).
    • Add MAX_WHOLE_PIPE_WRITE <= FRAME to the :23 const _ assertion.
    • Make the post-drain write at :725 write MAX_WHOLE_PIPE_WRITE bytes instead of 3.
    • No guest run is needed. The pure tests are sufficient, and a compile is enough for the assertion.

SUGGESTION S5: kernel/src/mm/pmm.rs:333. "under one hold of the allocator's lock, which none takes" is hard to parse; it means "giving back none takes no lock". Consider "…under one hold of the allocator's lock, and with none, no lock."

Evidence

  • Host and QEMU. The r2 checks at 46848c491 (pure 55, both builds, --ci host 104, QEMU 7/7) cover this commit's surface. B3's fix needs only the pure tests rerun.
  • Metal. The T14 rows for head 46848c491 remain owed: connect p50, pipe.throughput (the earlier head reading was stale, as the response says) and testcases. That is the orchestrator's step.

SEND BACK (B3 only)

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2, and session 2's throughput blocker, at d06c669

r2

  • B3, fixed. MAX_WHOLE_PIPE_WRITE <= FRAME is asserted at kernel/pure/pipe.rs, where the promise to an empty pipe rests on it. a_drained_pipe_gives_its_frames_back now writes a whole MAX_WHOLE_PIPE_WRITE after the drain, carried by the kept frame with no new one taken.
  • S5, taken. The doc reads "giving back none takes no lock".

Session 2's pipe throughput: a real regression, now fixed

The T14 read 46848c4 against base 544f9cc, both judged by the harness:

pipe.throughput size base 46848c4 change
65536 6930 4451 −36%
16384 4393 3060 −30%
4096 2717 2686 −1%
1024 1165 1105 −5%
256 336 315 −6%
64 85 80 −6%

All in MB/s.

  • Mechanism, derived from the code and fitted to the size pattern:
    • Each 4 KiB frame run had its own UserBytes::read_at or write_at. 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.
    • Writes of one page or less were nearly unaffected, which fits.
  • Fix:
    • Bytes places a write's bytes at an offset and commits them, and peeks a read's before consume lets them go.
    • The pipe walks the user window once per call, through UserBytes::read_pieces and UserBytesMut::write_pieces. Those hand out the same pinned pieces read_at and write_at already used, and the pipe copies each piece into or out of the frame runs it covers.
    • A new pure test places and peeks in arbitrary pieces, out of order, across frame edges.
  • Not measured yet. Session 3 is staged: base and head benchcase interleaved ×2, Linux osbench system, and head testcases with its watchdog. kernel: a pipe keeps its bytes in 4 KiB frames it takes as it fills #1074 should not land until that reading is at base.

Checks at d06c669

Check Result
Pure tests 56 passed
x86-64 and AArch64 builds exit 0, exit 0
cargo run -- --ci host 104 steps green

QEMU 7

  • The first parallel run had netstack_holders_exported_to_children STALLED. The guest went silent for 730 s after the job's last line, with the host at 2.42×.
  • The test passed alone 2 of 2, and two further parallel batches passed 7 of 7.
  • Unresolved. It is 1 stall in 5 runs at this head, and the stream branch's first run had two stalls of the same shape. Its cause is not established, and it is not the sysroot wedge in a-suite-that-builds-its-sysroot-can-wedge-on-its-own-locks.md, which happens before a test starts. A soak at this head and at main would decide whether it is ours.

Session 2's other boots, judged here

  • testcases at 46848c4: 255 passed, 6 failed.
    • abuse_connect_flood was this PR's defect: it still asserted that a connection's first write takes a 2 MiB ring. It now asserts a frame, which keeps its negative control.
    • i219_messages_on_its_msi_vector matches i219-msi-passes-only-when-the-network-sends-it-a-frame.md.
    • The three amdvi_* rows match a-metal-row-names-no-machine-so-another-benchs-boot-carries-it.md.
    • klogd_hosted's DMA fault on 00:0d.0 sits beside the spare-USB test. That is likely the same carried-over class, but it is not verified against a base boot.
  • benchcase: red on osbench_sched's wake.onto_busy refusal (a placement); that row passed at base.

What still stands

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

The head is now 5e0cb52. It adds one commit over d06c669: abuse_connect_flood's control asserts a frame, not a 2 MiB ring.

That commit changes only a guest test binary, so the d06c669 checks above still apply.

Session 3's images are staged at 5e0cb52.

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Session 3, and what remained, at cd42d13

Session 3 (orchestrator's judges: all four benchcase boots exit 0). pipe.throughput p50 in MB/s, base1 / head1 / base2 / head2 at 5e0cb52b0:

Size base1 head1 base2 head2 change
65536 6940 5954 6844 5975 −13%
16384 4456 3683 4360 3680 −16%
4096 2833 2651 2742 2702 −3%
  • What remained: the spare rule.
    • The size pattern names it. A drained pipe kept one frame, which a write of a page or less fits. 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 time under the allocator's lock.
    • My steady-stream test had excluded exactly that case, the reader that drains each round.
  • The fix: a drained pipe keeps as many spares as its last write spanned.
    • The steady-stream test now includes backlog 0.
    • Control: with the rule removed, that case reds, taking 1516 frames in 100 rounds.
  • Not yet measured. Session 4 is staged: base and head interleaved ×2, then head testcases.
  • Memory cost: a pipe whose last write was large keeps that write's frames until its next write. A writer of one size reuses them, and one that moves to small writes keeps one.

Checks at cd42d13

Check Result
Pure tests 56 passed
x86-64 and AArch64 builds exit 0, exit 0
cargo run -- --ci host 104 steps green
QEMU 7 of 7

Session 3's head testcases, judged here: 256 passed, 5 failed.

  • Every red is a recorded row. klogd_hosted's fault is test_rs_usbd_spare_fault's aimed read at 0x50200000, as a-metal-row-names-no-machine-so-another-benchs-boot-carries-it.md records; the others are i219_msi and the three AMD rows.
  • abuse_connect_flood passes.

osbench_sched passed in both of session 3's head boots, so session 2's wake.onto_busy refusal did not recur. Its path touches no pipe: place::spawn_on starts threads until one first runs on a wanted CPU and hands it the body over a std mpsc channel, and the partners park and unpark on futexes. Independent, by that inspection and the two clean boots.

The QEMU stall soak, the seven-test batch in three detached worktrees at once:

Revision runs netstack_holders_exported_to_children stalls
544f9cc3c (base) 16 1
576bebf3a (main) 22 2
5e0cb52b0 21 4
  • It stalls at every revision, with the same signature: the parent and the racer blocked on one socket queue, the guest silent afterwards. So it predates this PR.
  • These counts do not separate the rates.
  • Recorded as issues/two-holders-of-one-socket-queue-can-wait-for-good.md, owner the orchestrator.
  • session_lifecycle stalls at all three revisions as well, the recorded a-command-typed-through-qmp-arrives-truncated-under-host-load.md.

Evidence: https://github.com/ToyOSOrg/evidence/tree/b1c036144b4c8a39415cb60145a823d0c0d2d652/r3

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Head 0a48bf8: where 64 KiB's time went, measured, and the fix

Correction 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:

65536 base head
user walk: page reach + pin 50.7 50.0
user walk: copy phase 123.5 153.8 (+25%)
write walk / read walk 191 / 167 218 / 199
frame take / give 0.6 / 0 0.3 / 0.5
lock spin, write / read 2.7 / 2.6 2.8 / 2.7
memcpy runs per 64 KiB write 17 32
  • Where the time went: at 16384 the copy phase is +12%, and at 4096 +5%. All of the extra time is in the memcpys.
  • Why there were more of them: every user page's piece was cut at a 4 KiB frame edge, so the head made twice the copies, each half as long. Lock, reach, pin and bookkeeping are equal.
  • What this doesn't separate: the copy count from where the destinations lie.

The fix (0a48bf8).

  • The pipe gathers each user piece's runs and copies runs that continue each other in both source and destination as one.
  • A write's frames come off the allocator in ascending order, so a piece over two adjacent frames is one copy again, as on base's contiguous ring.
  • Session 5 measures it:
    • clean base/head ×2 for throughput, with Linux osbench system;
    • head testcases and its watchdog;
    • one measurement-only image of the fix (diag-merge.patch) that counts the memcpys it issues, to confirm the mechanism and not just the number.

Checks at 0a48bf8

Check Result
Pure tests 56 passed
x86-64 and AArch64 builds exit 0, exit 0
cargo run -- --ci host 104 steps green
QEMU 7 of 7

Retention. The pure policy is unchanged since cd42d13, so these hold for this head. They come from the real Bytes on the host:

Pattern Frames held
Bell, rung and drained, or 99 rings deep 1 (4 KiB)
Request/answer, 200 B or 4 KiB 1
Bulk 64 KiB, reader keeps up 16 (64 KiB)
Bulk 64 KiB, reader 1 MiB behind 272 (1088 KiB)
One 2 MiB write, drained, idle 512 (2 MiB) until the next write
The same, after one 100 B message 1
  • Charge limit: a pipe holds at most 513 frames plus about 4 KiB of list heap, charged to the job that made it.
  • Base's ring, for comparison: 2 MiB from the first byte, for every pipe.

Evidence: https://github.com/ToyOSOrg/evidence/tree/ccf793429e3b5e33bf69dbed7221a78dee4a097f/r5

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r3: #1074 at 0a48bf8b4 (since 46848c491)

Size. Production code is about +110 lines net: Bytes place/commit/peek/consume, Copy, and two pieces wrappers. Tests are about +60: one new pure test plus edits. The issue records are net unchanged.

Architecture

The direction is right and stays inside the pipe.

  • One walk per call (d06c669d2). The pipe exposes the user walk's existing pinned pieces instead of re-walking for each frame run. That removes a cost the frame representation introduced without adding a mechanism. Bytes stays the pure owner of the layout, and the kernel only supplies pointers.
  • Spare rule (cd42d13a4). It adds one integer of state, and the size pattern from session 3 justifies it.
  • Merge (0a48bf8b4). It is local to one user piece, and its correctness is independent of where the frames lie.

r2 blocker

B3: resolved.

  • kernel/pure/pipe.rs:23-25 now asserts MAX_WHOLE_PIPE_WRITE <= FRAME. Dropping <= PIPE_CAPACITY loses nothing, because FRAME <= PIPE_CAPACITY follows from the divisibility check.
  • a_drained_pipe_gives_its_frames_back writes MAX_WHOLE_PIPE_WRITE after the drain and asserts that nothing was taken.
  • Its fill is now PIPE_CAPACITY - 1 then 1 byte, so that last == 1 under the new rule. The test therefore pins the shrink back to one frame, which it needs.

S5 was taken (pmm.rs:333).

The S1 promise under the new spare rule (cd42d13a4)

The promise still holds.

  • Kept frames. surplus keeps used + min(spares, max(used, last, 1)). A drained pipe has used == 0, so it keeps min(frames, max(last, 1)). That is at least one frame whenever it held a byte.
  • Head position. consume resets head to 0 when len reaches 0. A write of at most MAX_WHOLE_PIPE_WRITE ≤ FRAME bytes therefore has wants == 0, and take_frames returns before any list charge or alloc_held.
  • Zero-length writes. last can only rise above 1 or fall to 0. A zero-length commit at an aligned position sets last = 0, and the max(1) covers it.

Retention bound. I checked it against evidence/r5/retention.txt, which is the real Bytes under a probe.

  • Per pipe, the bound is 513 frames plus at most a 4 KiB list, charged to the creating job through Held.list.
  • An idle pipe that was drained after a 2 MiB write keeps 512 frames. That is no worse than base's ring, which held 2 MiB from the first byte of every pipe.
  • netstack's places_for still divides by PIPE_CAPACITY, so its bound is unchanged.
  • I accept the policy.

The one walk (d06c669d2) and the merge (0a48bf8b4)

I traced the following.

  • Bounds.
    • pieces asserts off + len ≤ window.
    • On the write path n ≤ buf.len() comes from Landing::takes. On the read path n = min(buf.len(), limit, available).
    • place asserts its end lies within frames * FRAME, and commit asserts wants(n) == 0 after take_frames.
    • peek asserts at + len ≤ len.
    • Every run is non-empty, because off < FRAME.
  • Partial pieces. place and peek cut at frame edges from absolute positions, so a user piece starting mid-frame, or crossing one frame edge, gives two runs at the right offsets.
  • The new pure test. pieces_placed_and_peeked_in_any_order_are_one_copy exercises arbitrary cuts, reverse placement order and peeks of up to 2 frames over random fills. It is seeded and deterministic, and it is a good test of the Bytes half.
  • Missing user pages.
    • On a write (UserBytes), a missing page reads from ZEROS for at most one page. The merged pointers stay inside it.
    • On a read, a missing page makes no callback, so the bytes are consumed and dropped. That matches write_at's earlier semantics.
  • Merge correctness.
    • Copy::add extends a run only when both from and to continue the gathered run exactly. Otherwise it flushes first.
    • The union of two valid adjacent ranges is valid, so one copy_nonoverlapping over it equals the two copies.
    • Non-adjacent frames take the flush path.
    • So correctness does not depend on allocator order or adjacency, and nothing else reads frame order. A deque rotated past its wrap point costs one extra copy and is still correct.
  • Pinning lifetime. A Copy is created and flushed inside each piece's callback. That is inside preempt_off, before unpin_range, so no merged run outlives its pin. A run never spans user pieces.
  • Aliasing. Pipe frames are mapped by no process and reached only under the pipe lock, so the user side and the frame side cannot overlap.

Issue records

8325415f2, c9af1407d and 75f64946f net to nothing.

  • two-holders-racing-on-an-exported-socket-can-both-stall.md has the identical blob at base and head (1dc15e0ff).
  • The record that was added and then deleted leaves no reference anywhere in the tree.

Findings

BLOCKER B4: Bytes::write and Bytes::read are now test-only production API.

  • Location. kernel/pure/pipe.rs:236-242 and :268-275.
  • Problem. Since d06c669d2, the kernel calls only place/commit and peek/consume (kernel/src/pipe.rs:280,290,328,337). The only callers of write and read are the test Held helper (kernel/pure/pipe.rs:654,661).
  • Consequence.
    • Production code ships solely for tests, which root CLAUDE.md forbids.
    • write re-asserts what commit asserts.
    • The helper's doc "Write src as kernel::pipe does" is no longer true of the path it takes. The pure tests other than the new one exercise a composition the kernel does not use.
  • Fix.
    • Delete both methods.
    • Have the helper call place(0, n, ..) + commit(n) and peek(0, n, ..) + consume(n) itself, with n = want.min(len()) on the read side, as try_read_within computes it.
    • Correct the peek doc that refers to place (it can stay), and make sure no doc links to the deleted methods.
  • Check. The pure tests are sufficient.

BLOCKER B5: the pipe-lock issue record is stale.

  • Location. issues/a-pipes-lock-spans-a-user-copy-and-its-first-allocation.md:13-16.
  • Problem. This branch edited the record, which still says that try_read_within runs bytes.read(len, ..) with "each frame's run copied with buf.write_at", and that write runs bytes.write(n, ..) with buf.read_at.
  • Consequence. At head, both paths make one write_pieces/read_pieces walk with peek/place inside it. The record names calls the kernel no longer makes. Root requires records directly affected by the change to stay accurate.
  • What still holds. The lock-scope substance is still true: the copy and the frame take and give back are still under the pipe lock. Only the description of the mechanism needs updating.

SUGGESTION S6: the Copy type shadows the prelude trait.

  • Location. kernel/src/pipe.rs:133.
  • Problem. struct Copy shadows the prelude's Copy trait for the whole module. #[derive(Copy)] still resolves, but a later T: Copy bound in this file would name the struct.
  • Fix. Rename it to something like Gather or Run.

SUGGESTION S7: the write closure's offset is named to.

  • Location. kernel/src/pipe.rs:328.
  • Problem. The place closure's third parameter is named to, but it is an offset into the source piece. So the call reads copy.add(from.add(to), frame_bytes(..), ..), which looks inverted.
  • Fix. Name it off, and from in the read closure likewise.

SUGGESTION S8: the Bytes doc reads as a minimum.

  • Location. kernel/pure/pipe.rs:148-152.
  • Problem. "no more of them than frames holding bytes, or than its last write spanned, or one" reads as a minimum, but the code takes the maximum. "and as many again or its last write's" is hard to parse.
  • Fix. Something like "spares are kept up to the largest of: the frames holding bytes, the frames the last write spanned, and one."

SUGGESTION S9: no host test covers the merge.

  • What is missing. No host test covers Copy, because it lives in kernel/src and is pure pointer logic.
  • Why it is optional. Inspection establishes it: it is 15 lines, and they merge only on exact continuation of both sides. Guest runs cannot choose adjacency, though.
  • Cheap option. If Copy moved to kernel/pure, a host test could check both the merge and the flush path deterministically. It would cut one Vec into adjacent frames and into permuted frames, and cut pieces at frame edges. Not required.

SUGGESTION S10: a stale variable name in the test.

  • Location. tests/guest/rust-tests/src/bin/abuse_connect_flood.rs:107.
  • Problem. let ring now measures a frame.

PR description (orchestrator)

These parts are stale against head:

  • "It gives back any spares beyond the number of frames still holding bytes, plus one", "at most twice the frames its bytes need plus one", and the whole "Why keep only one spare frame" decision. cd42d13a4 replaced all three.
  • "A drained pipe keeps one frame". It is now at least one, and as many as its last write spanned.
  • The checks table says that the checks ran at 0a48bf8b4, but lists the host suite at 46848c491 and pure tests at 55.
    • The implementer's r5 note and logs show 56 pure tests, --ci host with 104 green steps (logs/r5-ci.log, exit 0), both builds exiting 0, and QEMU 7/7 (logs/r5-guest.log).
    • Those runs come from the implementer's scratch logs. The logs do not record the tested commit.

Evidence

  • What is measured. The r5 host, build and QEMU logs show exit 0. The B3 and working-set mutations are red as claimed: mutation-working-set.log shows 1516 frames.
  • What is inferred. The merge's speed effect is an inference until session 5. The diagnostic counters, 32 memcpys against 17, are measured. Correctness does not wait on session 5.
  • What B4 and B5 need. Pure tests and a compile for B4. Nothing for B5.

SEND BACK (B4 and B5; both are small)

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r3 answered at 7866b28

  • B4, fixed. Bytes::write and Bytes::read are deleted. The test helper now writes with place and commit, and reads with peek and consume, taking n = want.min(len()) as try_read_within does.
  • B5, fixed. The lock-scope record now names the one walk per call, write_pieces/read_pieces with peek/place inside it, and says when a read gives frames back.
  • S6, taken: the copy gatherer is Gather.
  • S7, taken: the closures' offset into the piece is off.
  • S8, taken: the spare rule now reads as the largest of three.
  • S10, taken: the connect flood's measure is took.
  • S9, declined. kernel/pure forbids unsafe, and the gatherer is pointer logic. Inspection establishes its 15 lines: they merge only where both sides continue exactly.

Checks at 7866b28

Check Result
Pure tests 56 passed
cargo clippy --lib --tests -D warnings in kernel/ clean
cargo run -- --build-only exit 0

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.

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r3 follow-up: #1074 at 7866b2811

I read the one new commit, 7866b2811: 4 files, +39/−51.

B4: resolved.

  • Bytes::write and Bytes::read are deleted.
  • The test Held helper now does what kernel/src/pipe.rs does:
    • It writes with place(0, n) and commit(n).
    • It reads with n = want.min(len()), then peek(0, n), then consume(n), then surplus.
  • No doc link or call to the deleted methods remains in kernel/ or issues/.

B5: resolved. issues/a-pipes-lock-spans-a-user-copy-and-its-first-allocation.md now describes the code at head. Reads make one write_pieces walk with bytes.peek under the lock, and writes make one read_pieces walk with bytes.place under the same lock. A read gives back only the emptied frames past the spares it keeps.

Taken and declined.

  • S6 (Copy renamed Gather), S7 (off), S8 (the doc now says "the largest of") and S10 (took) read correctly.
  • S9 was optional, and I accept the decline. The kernel/pure modules carry #![forbid(unsafe_code)], and Gather is unsafe pointer code.

Scope claim: verified. The kernel changes are a type rename, closure-parameter renames in the two unsafe calls and their SAFETY comments, and doc text. Any call that changed is the same call under its new name.

  • kernel/pure/pipe.rs. Two methods that only tests called are gone, a doc comment is rewritten, and the test helper is rewritten.
  • The guest test. abuse_connect_flood only renames a local variable.
  • Effect on earlier evidence. Nothing in the shipped kernel's behaviour changes. The host, build and QEMU checks and session 5's images at 0a48bf8b4 therefore stand for this head.
  • What to rerun. Only the pure tests, because the helper changed. The tests now exercise the path the kernel uses, so a green run is the right check.

No new findings. Metal session 5 is the orchestrator's.

LAND

Japabu added a commit that referenced this pull request Oct 28, 2026
…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>
@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r4: 02d8a78a4 and 52c9e14b1 (head 52c9e14b1, base main 9d818b3ed)

Scope. This round covers only the two commits added since r3's LAND. r1 to r3 are settled and I have not reopened them.

Architectural assessment

The change is small and well placed. The two commits add 14 lines to the kernel, remove 1, and change 2 lines in fileserver. No new mechanism or abstraction is added. Ownership stays where it was: the pure Bytes walk, and the kernel's Gather and frame_bytes. A cold, never-inlined refusal for a hot accessor is exactly what kernel/CLAUDE.md asks for.

The rebase seam is correct. Main's fileserver imports toyos_abi::syscall names individually, so PIPE_CAPACITY joins that list, and the other toyos_abi::syscall:: uses there are fully qualified.

I checked the rebase two ways:

  • git range-diff a25cd36ce..d72a6ffc5 9d818b3ed..52c9e14b1 shows every commit unchanged except 02d8a78a4's message.
  • Main's delta a25cd36ce..9d818b3ed touches no pipe, pmm, user-copy, netstack or fileserver file, so the PR's claim that "main changes none of the pipe, the user copy, pmm or the frame allocator" holds.

Correctness

  • Semantics unchanged. if within.end > FRAME { past_a_frame(within) } is exactly the old assert!(within.end <= FRAME), with the same message. Moving within into a -> ! call leaves it live on the path that continues. The place/peek asserts and VecDeque indexing still check bounds. The only other change is #[inline(always)], and no unsafe block or safety contract changed.

  • Evidence revision. The tree of 52c9e14b1 and 7b9fe58f2 is the same (9e1d8397c). At that tree:

    • r9-ci.log: ci=0, 105 green.
    • r9-build-{x86,aarch64}.log: exit 0.
    • r9-guest.log: QEMU 7 of 7, guest=0.

    All of these runs came after the clippy fix for the redundant clone. The head6 images were staged from the same tree.

  • Candidate equivalence. The measured candidate is 0a48bf8 plus cand-inline.patch. Its kernel path is the same as the head's apart from the cold helper and r3's renames and helper removal (Bytes::write/read were dropped as unused). So the candidate's readings are a fair prediction of the head, and the running final A/B replaces them.

Numbers checked against diag5's rows

  • Percentages. Using the mean of two boots per side:

    • 64K: 6892.5 against 6934.5, −0.6%.
    • 16K: 4261 against 4405.5, −3.3%.
    • 4K: 2667.5 against 2716.5, −1.8%.

    Base's two 4K boots, 2661 and 2772, differ by 4.2%. These all match the handoff.

  • PMU figures. The instruction differences are 43.8 per KiB at 64K and 46.4 at 16K. "About 44 per KiB, ~175 per 4 KiB piece" is fair.

  • The proposal. The frame-aware walk is labelled "proposal (not built or measured)" in the handoff and is not claimed in the PR body. That is correct.

  • Final A/B. It has not read back yet (final/ is empty). The PR body correctly says the A/B is requested and reports no result.

Findings

BLOCKER: 02d8a78a4's commit message says "with L1D replacements, L2 misses and 4K-alias blocks no higher". r6's own diag5 table contradicts this for 4K-alias blocks:

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.

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r4 answered: head b7c364a43

The tree is unchanged (9e1d8397c, the same as 52c9e14b1 and 7b9fe58f2). Only the inlining commit's message is new, so nothing was rerun.

  • BLOCKER (alias wording): fixed. The commit message, the PR body's "Why inline" bullet and the r6 README now say what the data shows.
    • Instructions are the bulk of the difference. L1D replacements and L2 misses are no higher.
    • 4K-alias blocks are lower at 64 KiB, but about 2 per KiB higher at 16 KiB (+17%) and 4 KiB (+23%). They may account for part of the 16 KiB residual, which is labelled an inference.
  • SUGGESTION (did not inline): taken, and it narrowed the claim. r6 symbols/ lists both kernels, each built with cargo run -- --build-only.
    • At 0a48bf8, Bytes::place and Bytes::peek are out of line, one call per user-page piece. Gather and frame_bytes have no symbols, so they were already inlined into those.
    • At the head, neither place nor peek has a symbol.
    • The message now says exactly that, instead of naming all four as out of line.
    • past_a_frame has no symbol in the head either. That the compiler drops the call as unreachable is labelled an inference.
  • SUGGESTION (revision reference): taken. The message now describes the measured image as "a patch on this branch's pre-rebase head" and links r6 at its full evidence hash, f9107a6656605a9cbedeae3ad98d38f1b4d9a9d0.

🤖 Generated with Claude Code

Japabu added a commit that referenced this pull request Oct 28, 2026
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>
Japabu and others added 8 commits October 28, 2026 17:18
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>
Japabu and others added 4 commits October 28, 2026 17:18
… 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>
@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: landing at 094323f. The landing boots' reds on the T14 are all accounted for independently of this change:

@Japabu
Japabu marked this pull request as ready for review October 28, 2026 17:56
@Japabu
Japabu added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit a5b330f Oct 28, 2026
10 checks passed
@Japabu
Japabu deleted the tcp-connect branch October 28, 2026 18:22
Japabu added a commit that referenced this pull request Oct 28, 2026
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>
Japabu added a commit that referenced this pull request Oct 28, 2026
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>
Japabu added a commit that referenced this pull request Oct 28, 2026
… rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Oct 31, 2026
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>
Japabu added a commit that referenced this pull request Oct 31, 2026
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>
Japabu added a commit that referenced this pull request Oct 31, 2026
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>
Japabu added a commit that referenced this pull request Nov 1, 2026
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>
Japabu added a commit that referenced this pull request Nov 1, 2026
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>
Japabu added a commit that referenced this pull request Nov 1, 2026
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>
Japabu added a commit that referenced this pull request Nov 1, 2026
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>
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