Skip to content

stream region: a connect commits 132 KiB, and each ring doubles under load - #1077

Draft
Japabu wants to merge 14 commits into
mainfrom
tcp-region
Draft

Japabu wants to merge 14 commits into
mainfrom
tcp-region

Conversation

@Japabu

@Japabu Japabu commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Part 2 of the approved "remove the memory a TCP connect wastes" work (#1074, part 1, landed). The design is option B, which the orchestrator approved under the owner's "properly" direction. The growth mechanism is the implementers' design, not the owner's. Rebased onto main 058bea2d3; SYS_SHM_COMMIT is 164, after main's 160 to 163.

Problem

A connect made its whole 8 MiB stream region memory, and every one of its 2048 frames was zeroed before the SYN left. On the T14, a 2 MiB region moved the connect p50 from about 3.0 to about 2.05 ms (#1069's evidence, issues/a-tcp-connect-zeroes-twelve-mebibytes.md).

What changes

Kernel: a memory object is committed only by an explicit call, never by a fault.

  • SYS_SHM_CREATE with SHM_PART commits only the first committed bytes (toyos_abi::syscall::shm_reserve).
  • SYS_SHM_COMMIT(handle, offset, len) (164) commits more. Any holder with MAP may call it. It promises and zeroes the pages at the call, charged to the object's own job (mm: every charge is its job's, the controls keep a floor, and a restart refused for memory waits for it #1057's accounting), and is refused whole where they cannot all be promised. It is lock-free: a racing commit gives back its frame and its unused promises.
  • A fault on a page that is not committed is refused like an access outside every range. A syscall's window over one is BadAddress: Space::allows asks an object entry for its pages in the one inventory descent it already makes. The kernel's own read of one, such as a program loader's, answers zeros. A kernel write to one panics: every kernel writer (inbox, clock page, TLS) commits its object whole.
  • The contract is stated at kernel/src/object/shm.rs.

The region. A 4 KiB header plus a 4 MiB receive ring and a 4 MiB send ring (REGION_BYTES is 8 MiB + 4 KiB). A connect commits the header and 64 KiB of each ring: 132 KiB, where it used to commit 8 MiB.

Each ring doubles under its producer, without ever needing to be empty (toyos::transport::stream).

  • Cursors are stream positions, and capacities are powers of two.
  • The size word names the new capacity and a switch position: one old ring past the head the producer last read, which is past every byte it may have placed. That matters because [tcp] places out-of-order bytes.
  • Bytes before the switch keep the old mask, and bytes after it use the new one, so no byte moves.
  • The room stays counted in the old capacity until the consumer passes the switch. A new byte could only land on an unread one a whole old ring behind it, and the old room forbids that.
  • The consumer reads the size after the tail and judges it before believing it: a capacity past the place, or a switch further than the old ring, is Violation::Size.

Who grows which ring, and who commits.

  • The client grows the send ring when a write finds less room than it brings, or fills the ring. netstack grows the receive ring when [tcp] wants a larger receive buffer than the ring holds. Each grows only after committing the memory, so a refused commit leaves the ring as it is.
  • netstack commits the header and both rings' first sizes where it judges a region (Region::judged), charged to the client, and refuses the region there if that fails.
  • Each consumer commits what the peer's size claims before touching it, so a lying peer spends its own region's memory and cannot fault the reader; a commit refused there resets that stream alone. A holder that joined commits the size it resumed before it copies by it, and one it cannot is Violation::Size. A holder with only MAP may commit, up to the size the owner made the region.

[tcp] addresses a lent ring by stream position, through Lent::most and Lent::want; the node lays each ring out by the size its pass judged last.

Records. issues/a-stream-commits-eight-mebibytes-and-a-process-holds-128.md is split: its region half is this change, and what is left is a-process-holds-at-most-128-streams.md. A ring never shrinks: a-stream-ring-never-shrinks.md records it, with an owner and an exit. a-tcp-connect-zeroes-twelve-mebibytes.md now says the region commits 132 KiB, its exit still owed the T14 reading.

Since review r1 (all blockers answered at 4849b7b88, comment above)

  • Rebased onto main. paged objects: a file's pages are the kernel's, read by its holders without a request (memory stage 7a) #1123's paged objects and clock: the TSC keeps CPUID 15H's rate or one the HPET refines; a step the RTC confirms is slewed, a second demotes it #1104's clock page take the commit's types (pmm::free_drawn over an iterator, page() answering Option). Region::judged takes IPC phase 2: a connection carries whole messages #1121's OwnedHandle, and the client's commit names the region through IPC phase 2: a connection carries whole messages #1121's Mapped end. The stale Stat size assertion left from the removed fstat.committed is gone.
  • Receive-ring growth is now driven end to end on the host (the gap the earlier body named). The node harness configured [tcp] with 64 KiB buffers rather than netstack's 4 MiB, and its peer offered no window scale. It now configures netstack's buffers, and its peer scales windows (RFC 7323 §2.2). a_download_a_reader_keeps_up_with_grows_the_receive_ring_and_keeps_every_byte: with a millisecond round trip, [tcp] wants more buffer, netstack commits and doubles the receive ring twice, and every byte reads back in place.
  • Each producer's commit is shown to hold alone. Both ends commit what a grown ring reaches, so in an honest exchange one end's commit hides the other's absence; on the guest, removing the client's commits left everything green. The node harness's region now says whose commits make memory: the upload test counts only the client's commits and the download test only netstack's.
  • stream_grows (guest): 4 MiB each way between the guest and the harness, in writes from a byte to more than a ring, every byte checked in place. ToyOS has no loopback, so the harness is the peer through the boot's forwarded port, as for socket_export. It runs in netstack_holders_exported_to_children's existing netcase boot, x86-64 q35 under QEMU, on virtio-net and e1000e, and adds about 3 s to each. Its claim is what only a guest has: netstack's own region adapter read past the ring's first size, over pages the kernel committed after both ends mapped them. QEMU's user network does not drive [tcp] to want a larger receive buffer, so that growth is the host test's.

Since review r2 (at 10c835a42, evidence r4)

  • B1: a_send_ring_a_client_grew_without_committing_is_read_through_netstacks_own_commit. The client claims a 4 MiB send ring without committing a page and publishes 100,000 bytes. netstack's reads stay inside its own commits, and the peer hears a fresh commit's zeros. With netstack's send-ring commit removed, the test reds: a take of 1460 bytes at 4262640 past what was committed.
  • S1: a joined writer commits a resumed capacity it has not reached before it copies, as a read already did. Before, a co-holder's bogus size ended the process. Test a_joined_writer_commits_the_size_it_resumed_before_it_copies; with the check removed it reds.
  • S2: a_receive_ring_grown_without_netstacks_commit_is_read_through_the_clients_own (netstack's commits switched off). With the read's commit removed it reds.
  • S3: a_send_ring_size_netstack_cannot_commit_resets_that_stream (the harness refuses netstack's commits): Violation::Size, that stream alone. The B1 mutation reds it too.
  • S4: Shape::holds says what each ring uses it as.
  • Host suite exit 0, 110 steps; QEMU stream_holders and netstack_holders_exported_to_children exit 0.
  • The metal images at 342ecc142 stand. S1 adds one comparison to a write. A connecting or accepting holder's reached always covers its capacity, so no measured stream makes a new syscall. The netstack binary changes only by a comment.

Metal A/B at 342ecc142 (evidence r5)

  • Not decided: the lan rows. All 8 T14 netbenchcase boots refused the lan group with no netstack link holds 10.0.2.15. std's TcpStream::local_addr answers QEMU's literal address (issues/getsockname-answers-an-address-nobody-asked-netstack-for.md, fixed by net: a stream's local address is netstack's, not QEMU's 10.0.2.15 #1147). No connect, upload or download row was measured, so the rule decides nothing for TCP.
  • Failed: the syscall paths. The benchcase A/B resolved the syscall-heavy rows worse at head on both benches. That was 15 fs.* rows and pipe.throughput.{64,256} on the T14, and 18 fs.* rows and 3 pipe rows on the AMD bench. Examples: fs.mem.fstat +7.0% on the T14 and +15.4% on the AMD bench, fs.disk.pread_4k +6.1% on the T14.
    • The cause I derived, not measured: Space::allows, which every user window asks first, had gained a second inventory walk (meeting, two tree descents) even for private memory.
    • 549f1c44d judges commitment inside the existing covered_where descent, which now passes each entry's start.
    • Re-measurement is requested: benchcase A/B of base 058bea2d3 against head 549f1c44d on both benches. A fs, pipe, ipc or mem row resolving more than 2% worse blocks.
  • Passed: pmm_accounting on both benches.

Checks

At 342ecc142 unless noted (round r4's are above). Logs: evidence r3.

Check Command Result
Host suite cargo run -- --ci host exit 0, 110 steps green
x86-64 image cargo run -- --build-only exit 0
QEMU cargo test --test toyos-build -- netstack_holders_exported_to_children shm_commit_alone exit 0, 2 of 2
QEMU … stream_holders (at d6e62d053) exit 0
QEMU … udp_receive_times_out kernel_objects_refused_alone memory_floor_holds_the_controls session_lifecycle libc_children (at 929e73d6c) each passed. Later commits change only test files these do not run, so the results stand

The host suite includes the ring's oracle across the u32 wrap with random growth, hostile size words, and the loom model a_ring_grown_while_it_holds_bytes_reads_each_as_written with its two red controls in build/ci.rs (transport-grow-room-now, transport-size-before-tail).

shm_commit_alone (QEMU q35, a boot of its own because a child faults) shows the kernel contract:

  • a touch past the commit ends the process;
  • a syscall's output into such a page is BadAddress, and nothing is lost;
  • a spawn from a region nothing committed is refused;
  • a commit is zeroed memory an earlier mapping sees, counted to the job once and given back with the region.

stream_region_refused sends a region with nothing committed to the real netstack, which goes on serving.

Negative controls (patch applied, built, run, reverted; patches and logs in the evidence):

  • netstack grows the receive ring without committing: the download host test reds, a put of 1444 bytes at 69472 past what was committed.
  • The client grows its send ring without committing: the upload host test reds, a write … past what was committed.
  • Neither end commits past the first ring: stream_grows reds, its process ending mid-transfer.
  • Either end's commits removed alone: stream_grows stays green, which is why the host tests carry each end's.
  • Earlier, at r1: with the BadAddress check removed, shm_commit_alone reds at its assertion (evidence r2).

Metal, the first A/B (run and read above). Base main 190f3b164 against head 342ecc142, four boots each, interleaved. The decision rule was stated in the request before any run:

  • T14 osbench_lan: lan.wired.tcp.connect counts as improved only if it resolves lower. Upload, download and rtt.64 count as regressed if they resolve worse by more than 5%, which blocks the PR until explained.
  • T14 and AMD osbench: ToyOS has no loopback, so its net.* rows measure nothing here. ipc.*, procs.* and mem.* rows that resolve more than 5% worse get four more boots each.
  • T14 and AMD pmm_accounting: the testcases boot, head only.
  • The AMD bench has no card ToyOS drives, so it measures no TCP row.

Gaps and risks

  • No TCP metal reading yet. The connect p50 gain and the absence of a throughput regression are predictions until a lan A/B reads after net: a stream's local address is netstack's, not QEMU's 10.0.2.15 #1147. The syscall-path fix awaits its benchcase re-run.
  • Throughput ramp: both rings now start at 64 KiB and double, and each doubling waits for the consumer to pass one old ring. A short transfer on a high bandwidth-delay path could ramp slower than main's whole 4 MiB ring did. The T14's upload and download rows and their peer_received_by_*ms ramp fields are where this shows.
  • A ring never shrinks (issues/a-stream-ring-never-shrinks.md). netstack still prices a place at the worst case, both rings grown whole.
  • The harness observation, unrelated: in one QEMU run, a guest binary's build failure in one test was followed by another test's build waiting on its own process's sysroot lock until the run was stopped (evidence r3 logs/qemu1.log). It did not recur, and its cause is not established.

🤖 Generated with Claude Code

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1077 at dc0ecda05

I read the six commits against 1f828f331 (#1074's head), each changed file, and their callers and boundaries: the fault path, user copies, SharedImage and CopySource, inbox, pmm's promise and free paths, netstack's region judging and pass, the client's open, read and write, [tcp]'s Ring and Rx, the loom model and the CI controls. I checked the evidence at 87062348a/r1 and looked at #1076's diff for overlap. I ran nothing.

Architecture

The design matches the approved outcome:

  • An object is made with a committed extent.
  • SYS_SHM_COMMIT promises and zeroes pages at the call, charged to the object's job through the existing pmm::promise. Floors are honoured because promise refuses what is held back.
  • A fault on an uncommitted page is refused (space.rs:825's ?), so a fault never allocates.
  • fstat reports the committed extent.
  • Growth does not wait for the ring to empty.

The kernel addition is something userland cannot do, and SHM_PART | SHM_DEVICE is refused, so device lending (Run) is untouched.

The ring protocol is sound by my derivation:

  • Switch. It is placed at head_read + old. Every placed byte lies below head_read + old because room was counted from that head.
  • No overwrite while pending. Room stays in the old capacity until the head passes the switch. A new byte p >= S and an unread byte q then satisfy 0 < p - q < old. p mod 2·old == q mod old would imply p ≡ q (mod old), which is impossible, so no write lands on an unread byte.
  • Ordering. The consumer loads the tail with Acquire before the size. That makes the size word at least as new as the bytes it maps. A newer size only adds or clears a switch above or below released, and neither remaps a byte in [released, tail). A second doubling cannot be pending until the head passes the first switch.
  • 24-bit switch. The sign-extended reconstruction in Size::of is correct for any switch within 2^23 of near. Every honest switch is within 2^21.
  • [tcp] receive side. tcp's oldest byte is never past the producer's last-read head. Its capacity is min(cap, holds), with holds = cap_at(head). So out-of-order bytes it places between passes stay below the next switch.

Loom. The model is the right one: unread bytes at the moment of growth. Its byte cells are loom's, so transport-grow-room-now reds as a loom causality violation, which matches the log. transport-size-before-tail reds as TailPastDepth. Both controls expose the defect they name.

Size. About +990/−390 production lines, including the in-file stream tests (roughly 250 of those), and +300/−80 in test files and records. The deleted free_all and Ring adapter are superseded, not lost.

Neither the PR body nor the brief makes the boundary findings below visible. Three are new ways for one process to crash the kernel, crash netstack, or lose its own data silently.

Blockers

  1. kernel/src/object/shm.rs:331 (each_piece), reached through kernel/src/file_backing.rs:74 and kernel/src/mm/space.rs:57: a userland process can panic the kernel.

    • SharedImage::over (syscall/vm.rs:118) accepts any SharedMemObject handle with MAP as a program image for spawn and dlopen (dispatch.rs:172, :322).
    • The loader then reads it with SharedMemObject::read, both in read_page and through CopySource::read for Kind::Copy pages. That goes through each_piece, which now panics on an uncommitted page.
    • shm_reserve(2 * PAGE_SIZE, 0) followed by a spawn from that handle stops the machine. This breaks "a userland bug never crashes the kernel".
    • Fix: the module header already promises "a copy reads it as zeros". Make read zero-fill uncommitted pages, as file_cache::Snapshot::read does, and keep the panic only in write. Every write caller (inbox, clock, TLS) commits its object whole.
    • Test: a guest case that spawns from a reserve-only region, or a host unit test if read can be reached there.
  2. system/netstack/src/lent.rs:104, sdk/toyos/src/shm.rs:185 and system/netstack/node/src/streams.rs:398 / :423: a client can kill netstack, and with it every stream on the machine.

    • judge checks the type and size, never committed.
    • netstack touches the header (NODELAY on connect and accept) before lent()'s ring commits, and it never commits the header at all.
    • A client that sends shm_reserve(REGION_BYTES, 0) makes netstack fault on its first header word. An exported holder adopting such a region faults the same way.
    • The PR's "a lying peer … cannot fault the reader" is false for the header.
    • Fix: commit [0, HEADER) together with both initial rings at Region::judged, charged to the client as every other commit is, and refuse the region there if that fails. See S2.
    • Test: the node's host harness already models commits (tests/common/client.rs). A host case with a header-uncommitted region would cover this.
  3. kernel/src/user_ptr.rs:174 with kernel/src/mm/space.rs:854: a syscall's output into an uncommitted object page is dropped and reported as success.

    • allows judges the whole range, so a buffer in an uncommitted part of a mapped region passes.
    • reach answers None, and the (None, Access::Write) => {} arm discards the bytes. For example, read(fd, buf_in_uncommitted_page) returns n with nothing written.
    • That arm existed for a page a sibling removes concurrently. This PR makes it a deterministic, permanent state for any program.
    • The fault path refuses the same access, so the two paths now disagree. The shm.rs:18 header documents only the read side.
    • Fix: refuse such a window as BadAddress, either by making allows check object pages or by an equivalent check at the window. Update the header to say so.
  4. system/netstack/node/src/streams.rs:99–100: LentRing::spans silently turns an unreachable layout failure into a no-op put or take.

    • By the derivation above, Place::spans cannot answer None for any size the pass has judged, and [tcp] never hands a length past u32.
    • The previous Ring::at asserted the same contract. Returning empty spans would corrupt a stream silently if the invariant ever broke.
    • Internal invariant violations fail loudly: expect or unreachable! with the arguments.
  5. Records.

    • issues/a-process-holds-at-most-128-streams.md:9–11 says "so a stream commits what it holds". A ring never shrinks, so a stream commits the most it has ever held, up to 8 MiB + 4 KiB.
    • That paragraph is also history, which belongs in the commit message.
    • The deleted issue's exit condition was "a stream commits what it holds". The rewrite drops the unmet half silently.
    • Either keep the no-shrink gap with its exit in a record, or state why it is not debt. One reason could be that [tcp]'s own rings also never shrank. That is the orchestrator's call; it must not disappear silently.
    • After this rebases onto main (which has netstack: what holds a TCP sender back, and slow start growing per ACK, for the T14 upload gap; connect's cause recorded #1069), issues/a-tcp-connect-zeroes-twelve-mebibytes.md's first bullet ("8 MiB … each of its 2048 frames") becomes false. Update it in this change to the 132 KiB commit, with its exit still pending the T14 reading.
  6. Evidence, tests/guest/rust-tests/src/bin/shm_commit.rs: nothing has run the kernel semantics.

    • The test was never run at any revision. The two QEMU runs exercised streams that commit everything they touch, so a regression to "fault allocates" or "commit not visible to an earlier mapping" would have passed all of them.
    • This is the only test of the kernel claims: a touch past the commit ends the process, and a later commit reaches an existing mapping.
    • Agents may run their own guest tests, and QEMU establishes order and contents. The cheapest sufficient check is a filtered QEMU run of shm_commit at the fixed head, recorded with its revision. It should also cover blockers 1 and 3.
    • The metal shared boot then confirms it on hardware.

Suggestions

  • S1. Wraparound test. agrees starts every cursor at position 0. So growth, before() and Size::of's 24-bit reconstruction are never run across the u32 wrap. a_cursor_is_believed… crosses the wrap without growth. I find the code correct by inspection, but a sign slip here would break every stream after 4 GiB. Running agrees with both cursors resumed from near u32::MAX is a few lines.
  • S2. Remove the broken state. Doing B2's commits at Region::judged refuses an uncommittable region at the boundary, before a stream exists. That removes Stream::broken, the (0, 0) holds, and the deferred Violation::Size in moved.
  • S3. Production use of fstat.committed. After S2, netstack still does not need it. Its only reader is the guest test. The ruling includes it, but the proposal's reason ("netstack judges what it may touch from the kernel") is not how the built design works, because readers commit instead. The orchestrator may want to confirm it stays.
  • S4. Send-growth trigger. sdk/toyos/src/transport/session.rs:918 grows only when n < len. A sender that writes in pieces no larger than the room left, such as 1-byte or small writes that block on a full ring, never grows. Its [tcp] send buffer then stays at 64 KiB on a high bandwidth-delay path. Growing also when a write finds the ring full fits the "less room than it brings" rule. The T14 upload row is where the current trigger would show.
  • S5. Accounting check. Nothing observes commit's promise accounting: wanted, unpromise(wanted - drawn), and the frame freed on a lost CAS. A few lines in shm_commit comparing AccountInfo.committed before and after a commit, and after the close, would catch a leak.
  • S6. shm: a region can be shared read-only, and a surface owns or borrows what it draws into #1076 overlap. Once both land, a read-only (MAP-only) holder can commit the owner's pages. That is intended, since readers commit before touching. The shm_commit ABI doc should then say so. Whichever lands second also needs HELD_REGION to carry WRITE for the writable mapping.
  • S7. Build record. The PR's --build-only row has no revision; the evidence shows 0b9f96a9c. Later edits were rebuilt by the QEMU run at 0583bd46c, so this is only a labelling gap.

Verdict

SEND BACK. Blockers 1–3 are boundary defects with a concrete trigger. Blocker 4 is a loud-failure regression. Blocker 5 is record accuracy, and blocker 6 is missing evidence for the kernel claims. The ring protocol and its loom model need no change. Corrections to the PR body belong to the orchestrator: the header and fault claim, and "a kernel copy reads such a page as zeros".

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 answered at 4849b7b, now rebased onto #1074's fixed head 46848c4

Blockers

  • B1, fixed. SharedMemObject::read answers zeros for a page that is not committed. A kernel write there still panics, because every writer commits its object whole. Tested: a spawn from a region with nothing committed is refused (InvalidArgument) and the machine goes on.
  • B2, fixed through S2.
    • Region::judged commits the header and both rings' first sizes, charged to the client, and refuses the region there if that fails. Stream::broken is gone.
    • Tested by stream_region_refused: a connect with a region that has nothing committed, sent to real netstack in netstack_holders_exported_to_children's netcase boot. netstack serves on.
  • B3, fixed. Space::allows asks each object whether the window's pages are committed, so a syscall's window over one that is not answers BadAddress. The header says so. Tested: a pipe read into such a page is BadAddress and takes nothing off the pipe.
    • Negative control: with the check removed, shm_commit_alone reds at that assertion, Ok(4) where BadAddress was expected. The patch and log are in evidence.
  • B4, fixed. A span the judged size cannot lay out is unreachable! with its arguments.
  • B5:
  • B6, fixed. shm_commit runs on QEMU as shm_commit_alone, on the shared boot's q35 guest in a boot of its own. It is off the shared boot and covers B1, B3 and S5.

Suggestions

  • S1, taken: the ring's oracle runs across the u32 wrap with growth.
  • S3, taken per the ruling: fstat.committed is gone.
  • S4, taken: a write that fills the send ring grows it too.
  • S5, taken: the job's account is read tightly around each commit and the close.
  • S6, taken: shm_commit's doc says a MAP-only holder may commit up to the owner's size. HELD_REGION's WRITE waits on whichever of this PR and shm: a region can be shared read-only, and a surface owns or borrows what it draws into #1076 lands second.
  • S7: the build row is labelled with its revision.

Checks at 4849b7b

Check Command Result
Host suite cargo run -- --ci host exit 0, 106 steps green
Image cargo run -- --build-only exit 0
QEMU cargo test --test toyos-build -- shm_commit_alone netstack_holders_exported_to_children stream_holders session_lifecycle libc_children kernel_objects_refused_alone memory_floor_holds_the_controls udp_receive_times_out exit 0, 8 of 8

T14 images are staged at 4849b7b.

@Japabu

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1077 at 342ecc142

I read the 13 commits against the merge base 190f3b164, the full diff (+1750/−491 across 39 files), and each changed file's callers and boundaries. On the kernel side that was SharedMemObject commit, page, read, write and drop, the fault path, Space::allows, and pmm/frames.rs promise, draw, free and unpromise. In userland it was transport::stream/session, the client's net::stream, netstack's lent.rs/serve.rs and the node's moved pass, plus [tcp]'s Ring and Rx. I also read the node harness, the stream_grows and shm_commit guests, their registration, the issue records and the r3 evidence (logs and mutation patches). I ran nothing.

Architecture

The work matches the approved option B and stays inside it. The kernel addition (SHM_PART and SYS_SHM_COMMIT) is something userland cannot do. The kernel enforces only object authority (MAP) and charges the object's job. The growth policy lives in the transport and the node. This round's rebase fixes are mechanical and correct:

  • free_drawn now takes an iterator. Its callers in paged.rs and space.rs are unchanged in meaning.
  • The clock and inbox page() calls expect on objects that create committed whole.
  • The Stat assertion in ops.rs is net-unchanged against main.

Kernel commit and accounting (checked by inspection).

  • promise(wanted) adds wanted to the job's committed count.
  • Each alloc_promised consumes one promise.
  • A frame that loses the race goes back through free_drawn, which subtracts it.
  • unpromise(wanted − drawn) returns the rest.
  • drawn ≤ wanted because a word only goes 0 → frame. The net charge is exactly the pages stored.
  • The Release CAS pairs with the Acquire in page(), so a fault sees the zeroing.
  • Drop frees only non-zero words, and each piece's heap box drops after the allocator lock is released.
  • Validation covers zero length, overflow, a range past the object, apertures and the MAP right. Sub forwards with its bounds rechecked by the whole object. Run and Boot objects are always committed.
  • allows and the fault path now agree. Pages are never decommitted, so the check-then-copy race is no worse than the existing concurrent-unmap arm.

Ring growth under concurrency (checked by inspection). I re-derived the receive side against this round's pass order:

  • held() loads the head twice: room() reads h1, then held reads h2 ≥ h1. The switch is h1 + old.
  • [tcp]'s base moves to h2, and it places up to h2 + old.
  • A placed byte p ≥ S and an unread byte q ≥ h2 satisfy 0 < p − q < old, so the masks never collide.
  • publish's room() re-reads the head, so publishing what [tcp] placed always fits.
  • A room() later in the pass can clear the switch while rx_shape still carries it. Both layouts agree for every position at or past the head, so this is harmless.

netstack against a hostile client (checked by inspection).

  • netstack lays out the send ring only by its own tx_shape, set after the judged size's commit. [tcp]'s reads lie in [released, released + cap) with the switch at most cap/2 ahead. That is at most four spans, all below committed memory.
  • A client that later rewrites the size word affects nothing until the next judgment.
  • A shrink below what is queued is TailPastDepth.
  • The client's writes to the receive ring's size word are never read back by netstack's producer.
  • lendable(64 KiB) cannot fail before a lend. [tcp]'s receive storage holds at most RECEIVE_BUFFER_INITIAL (65,535) until a reader reads, so that unreachable! stays unreachable from the wire.

Harness and stream_grows.

  • Window scaling follows RFC 7323 §2.2: a SYN's window is not scaled, and the shift applies only when both ends offer one.
  • The configured buffers now match netstack's main.rs.
  • Suppressing one end's commits correctly makes each producer's commit carry its test alone.
  • The both mutation reds stream_grows, which establishes that the guest test really reaches past the first ring.

Evidence reuse is valid.

  • After d6e62d053 only node tests, stream_grows.rs and 4 lines of tests/toyos.rs changed. After 929e73d6c only test files changed.
  • qemu3 re-ran the harness-affected test at head.

"Ring never shrinks" residual. The record is accurate (PLACE_BYTES at system/netstack/src/main.rs:114 prices the worst case). It has an owner and a concrete exit, and nothing else references the deleted slug.

Blockers

  1. system/netstack/node/src/streams.rs:321: netstack's commit of the send ring, the only defence against a client faulting netstack, is pinned by no test.
    • Scenario. A client, buggy or hostile, writes the send ring's size word to claim 4 MiB without calling SYS_SHM_COMMIT, then publishes a tail into the grown ring. netstack judges the size, and [tcp] reads the ring by it. If this line is deleted or reordered after tx_shape.set, netstack touches an uncommitted page. The kernel ends netstack, and every stream on the machine dies. That is the r1 B2 threat class.
    • Why nothing reds.
      • The upload host test switches netstack's commits off (netstack_commits_nothing), so it shows only the client's commit.
      • The download test exercises the receive ring.
      • On the guest the honest client always commits first, so stream_grows is green without this line. Your own control shows this: mutation-netstack-commit.patch alone is green, though that patch disables netstack's receive-side commit too.
    • Overclaim. The PR states "a lying peer spends its own region's memory and cannot fault the reader" as a delivered property. The controls listed establish each producer's commit, not either consumer's.
    • Cheapest check. Add one node host test in the "a hostile client" section (system/netstack/node/tests/streams.rs:973):
      • Call client_commits_nothing().
      • Store a grown size word through client.word(..): line 16, log₂ capacity, no pending bit.
      • Write bytes past 64 KiB straight into region.bytes and store the tail.
      • Bridge, then assert the peer receives those bytes. The harness's take assertion (common/client.rs) already reds if netstack reads outside its own commits.
    • Negative control. The same test with line 321's commit removed must red at the take assertion.

Suggestions

  • S1. sdk/toyos/src/transport/session.rs:907 with :563: a joined send holder does not commit the capacity it resumed.
    • A joiner starts with reached: 0, and write copies by the resumed size without calling reach. read does call it (:856).
    • Scenario. A co-holder (an exported child) stores a size larger than anyone committed. This holder's next write faults, and its process ends instead of seeing Violation::Size.
    • resume already treats a co-holder's stored words as input to judge (OwnCursor).
    • Fix. Mirror read: before copying, if capacity() > reached, either reach(capacity) succeeds or the call answers Violation::Size. On honest streams this is one idempotent syscall per holder per growth.
  • S2. The client's own consumer commit (session.rs:856) is also unpinned. netstack is a trusted system service, so this one is optional. The B1 test pattern mirrored onto the receive ring would cover it: netstack_commits_nothing(), plus a size word netstack never committed for.
  • S3. The refusal path is untested. commit failing → Violation::Size resets that stream alone, but the harness Memory::commit (common/client.rs:102) always answers true. A refusal toggle beside the B1 test would cover the ResourceExhausted path.
  • S4. streams.rs:78: the comment "What it holds from the oldest byte unread" is false for the send ring. There, holds is the high-water mark of committed capacity (:324, cap.max(tx.holds)), which can exceed the current judged capacity. Either name the field for what both rings use it as, [tcp]'s storage length, or say so in one clause.

Verdict

The kernel contract, the pmm accounting, the growth protocol under producer/consumer races and netstack's containment of a hostile client all hold by derivation, and the new harness and stream_grows show what they claim. The one gap is evidence: the boundary commit that keeps a client from killing netstack can be deleted with every test green.

SEND BACK

@Japabu

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 answered at 10c835a

  • B1, fixed. New test a_send_ring_a_client_grew_without_committing_is_read_through_netstacks_own_commit:
    • The client's commits are switched off. It stores a 4 MiB send-ring size and publishes 100,000 bytes.
    • netstack's reads stay inside its own commits, and the peer hears a fresh commit's zeros.
    • Negative control: with the commit at streams.rs:321 removed, the test reds with a take of 1460 bytes at 4262640 past what was committed.
  • S1, taken. session::write now commits a resumed capacity it has not reached before it copies. A capacity it cannot commit is Violation::Size, as read already answered.
    • Test: a_joined_writer_commits_the_size_it_resumed_before_it_copies.
    • Negative control: with the check removed, the write copies and answers Ok(1).
  • S2, taken. New test a_receive_ring_grown_without_netstacks_commit_is_read_through_the_clients_own, with netstack's commits switched off.
    • Negative control: with the read's commit removed, it reds with a read … past what was committed.
  • S3, taken. The harness can now refuse netstack's commits.
    • Test: a_send_ring_size_netstack_cannot_commit_resets_that_stream. The refusal gives Violation::Size and resets that stream alone.
    • The B1 mutation reds this test too.
  • S4, fixed. Shape::holds is documented as [tcp]'s storage length for each ring.
  • Checks at 10c835a:
  • The queued metal images at 342ecc142 stand.
    • S1 adds one comparison per write. A connecting or accepting holder's reached always covers its capacity (set at creation and at each growth), so no measured stream makes an extra syscall.
    • netstack's binary changes only by a comment.

Japabu and others added 14 commits November 1, 2026 23:49
… load

A connect made its whole 8 MiB region memory, every frame zeroed before
the SYN left. It now commits the header and 64 KiB of each ring, and a
ring's producer doubles it, up to its 4 MiB place, as it needs more.

Kernel: a memory object's pages are committed at an explicit call, never
at a fault. SYS_SHM_CREATE with SHM_PART commits only a first part;
SYS_SHM_COMMIT (any holder with MAP) promises and zeroes the rest at the
call, charged to the object's own job as its creation is. A fault on a
page not committed is refused like an access outside every range, and a
copy reads it as zeros. fstat's new `committed` reports what is.

Transport: cursors are stream positions and a ring's capacity a power of
two, so a byte's offset is its position below the capacity on every lap.
A producer grows its ring without waiting for it to empty: the size word
names the new capacity and a switch one old ring past the head it last
read, past every byte it may have placed; bytes before the switch keep the
old mask, bytes after use the new, and the room stays counted in the old
capacity until the consumer passes the switch, so a new byte can only land
on an unread one a whole old ring behind it, which the old room forbids. A
consumer reads the size after the tail and judges it before believing it.

The client grows its send ring when a write finds less room than it
brings; netstack grows the receive ring when [tcp] asks for more buffer
than it holds, each after committing the memory. Each consumer commits
what a peer's size claims before it touches it, so a lying peer spends its
own region's memory and cannot fault the reader. [tcp] addresses a lent
ring by stream position and the node lays it out by the judged size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ss reads cleanly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng's first size

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s window or netstack

B1: SharedMemObject::read answers zeros for a page not committed, as the
module promised; a program image spawned or opened from a region with
pages not committed is refused by its loader rather than stopping the
machine. A kernel write still panics there: every writer commits its
object whole.

B3: a syscall's window over an object's page not committed is BadAddress
(Space::allows asks the object), where the copy used to drop the bytes
and answer success; the fault path already refused the same access.

B2, S2: netstack commits the header and both rings' first sizes where it
judges a region, charged to the client, and refuses it there if it
cannot; the node's deferred `broken` state is gone.

B4: a span the pass's own size cannot lay out is unreachable, said with
its arguments, not a silent no-op.

S1: the ring's oracle runs across the u32 wrap with growth. S3: fstat's
`committed` is gone: readers commit rather than ask. S4: a write that
fills the send ring grows it too. S5, B6: shm_commit reads the job's
account around each commit and the close, covers B1 and B3, and runs on
QEMU as shm_commit_alone. A client's region with nothing committed is
driven at real netstack in netcase's boot (stream_region_refused). B5: a
ring that never shrinks is recorded with its owner and exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rebase onto main brings #1123's paged objects, which free a frame
with `pmm::free_drawn` as the shared regions now do, and #1104's clock
page, which reads its object's one page: both take the commit's types.
SYS_SHM_COMMIT is 160, past #1121's SYS_MESSAGE_RECV.

The guest tests map with a protection, as main's `shm_map` asks, and a
connect's answer carries main's three bells. The kernel's `Stat` size
assertion, left from the removed `committed` field, goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the guest

The ring's growth was tested on the host, through models of the region's
memory, and on no guest: nothing ran the client's commit before it reads
or writes past a ring's first size, or netstack's before it grows the
receive ring, against the kernel that refuses a touch past a commit.
`stream_grows` sends 4 MiB each way over loopback, in writes of a byte to
more than a ring, and checks every byte's place. It runs in the netcase
boot that already runs `stream_region_refused`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… loopback

netstack refuses 127/8 as a destination, so `stream_grows` listens on the
port its boot forwards and the harness connects through it, as for
`socket_export`: both ends send 4 MiB and check every byte's place.
`shm_commit` names main's `SpawnArgs.pager` and closes in `unsafe`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…host

No test drove the receive ring's growth end to end: the node harness
configured [tcp] with 64 KiB buffers, not netstack's 4 MiB, and its peer
offered no window scale, so [tcp] never wanted more than the first ring.
The harness now configures netstack's buffers, and its peer can scale
windows (RFC 7323 §2.2) and read the node's scaled windows.

The new test downloads with a reader that keeps up and a millisecond
round trip: [tcp] wants more buffer, and netstack commits and doubles the
receive ring twice, every byte read in place. Its client commits nothing,
so every page either end touches is one netstack committed: with
netstack's commit before the growth removed, the node's put past what was
committed reds it.

`stream_grows` on the guest claims the send ring's growth alone: QEMU's
user network's round trips seldom make [tcp] want more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…host

Both ends commit what a grown ring reaches, so in an honest exchange
either end's commit hides the other's absence: the guest showed it, with
the client's commits removed and `stream_grows` still green. The node
harness's region now says whose commits make memory. The upload test
counts the client's alone and the download test netstack's alone: with
the client's commit before its growth removed, the node's read past what
was committed reds the first, and with netstack's, its put reds the
second.

`stream_grows` claims what only the guest has: netstack's own mapping
read past the ring's first size, over pages the kernel committed after
both ends mapped it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd a joined writer commits what it resumed

B1: no test failed with netstack's commit of a client-grown send ring
removed, which is all that keeps a client from faulting netstack. A
client that claims its send ring grown to 4 MiB without committing a page,
and publishes 100,000 bytes, is now read only through netstack's own
commit, and the peer hears a fresh commit's zeros. With that commit
removed, the harness's take past what was committed reds it.

S1: a holder that joined copied by the size the last holder stored without
committing it, so a co-holder's bogus size ended the process. It now
commits a resumed capacity it has not reached before it copies, and one it
cannot commit is Violation::Size, as a read's already was.

S2: the client's own commit before it reads by a size netstack stored is
pinned the same way, netstack's commits switched off.

S3: a size netstack's commit is refused for resets that stream alone,
before [tcp] reads by it.

S4: a shape's `holds` says what each ring uses it as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… judges it covered

The A/B at 342ecc1 read every bench's syscall-heavy rows slower at the
head, resolved on both: fs.mem.fstat +7% on the T14 and +15% on the AMD
bench, pipe.throughput.64 -7%, fs.disk.pread_4k +4 to +6%. `allows`, which
every user window asks first, had gained a second walk of the inventory,
`meeting`'s two descents, to judge an object's pages committed, even for
a window in private memory. `covered_where` now hands its predicate each
entry's start, and `allows` asks an object entry for its pages in the
descent it already made; a private entry costs nothing more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

The A/B at 342ecc1, read against the rule set before it ran; the fix at 549f1c4

  • The lan rows were not measured. All 8 T14 netbenchcase boots refused the group with no netstack link holds 10.0.2.15, which is net: a stream's local address is netstack's, not QEMU's 10.0.2.15 #1147's defect. The rule decides nothing for connect, upload or download. The lan A/B is restaged once net: a stream's local address is netstack's, not QEMU's 10.0.2.15 #1147 lands.
  • The syscall-heavy rows regressed on both benches. The fs rows resolved worse at head, 15 on the T14 and 18 on the AMD bench, and so did pipe.throughput. For example, fs.mem.fstat was +7% on the T14 and +15% on the AMD bench. Those rows are outside the groups the rule named, but resolved on both benches they are a regression, not chance.
    • Derived cause: Space::allows had gained a second inventory walk for every user window.
    • 549f1c44d judges commitment inside the single covered_where descent.
    • The branch is rebased onto main 058bea2d3, with SYS_SHM_COMMIT renumbered to 164.
    • Host suite exit 0 (111 steps). QEMU shm_commit_alone, stream_holders, kernel_objects_refused_alone and netstack_holders_exported_to_children all exit 0.
  • pmm_accounting passed on both benches.
  • A re-run is requested: benchcase A/B of 058bea2d3 against 549f1c44d on both benches. Any fs, pipe, ipc or mem row resolving more than 2% worse blocks.

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

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