Skip to content

kernel: no user copy runs under a spin lock, and one that would is refused (S0-a, its spin-lock half) - #1150

Draft
Japabu wants to merge 8 commits into
mainfrom
s0a-wide
Draft

Japabu wants to merge 8 commits into
mainfrom
s0a-wide

Conversation

@Japabu

@Japabu Japabu commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Memory track S0-a, its no-copy-under-a-spin-lock half: no user copy runs under any kernel spin lock, and one that would is refused by name. The rest of S0-a's row (a chunk faulted in before any lock and pinned after a re-check, the lost-page outcomes, its H model) is not built and stays open. This half is what memory stage 7b needs from S0-a; whether 7b also waits for the rest is not yet ruled. It matters to 7b because there, a copy's fault-in will park on a userland pager, and a copy made under a spin lock would park holding it. Folds and closes #1146, keeping its gate and dropping its per-space holder.

Problem

A user copy reaches its pages under its space's lock, populating any it has not touched (kernel/src/user_ptr.rs). Several syscalls copied while holding a spin lock:

  • read/write (blocking and not) under the process lock and then the object's lock: a pipe's, a kernel file's cache, a console's line, a device claim's description;
  • message send and receive under the process and queue locks;
  • getcwd, query_modules (process lock), sysinfo (process table) and readlink (VFS lock; the scout missed this one, a static audit found it).

Now

The gate. Each copy entry (View::window, View::pieces, translate_user) panics, naming the depth, when the preemption count is above the depth its syscall's entry runs at, whether or not a page needs populating. Every spin lock raises the count, and both architectures' trap entries raise it to scheduler::BASELINE_TRAP = 1: x86_64's syscall_entry and common_entry, AArch64's trap::syscall and trap::user_abort. The WIP's count() != 0 was wrong, since every syscall runs at 1. A sleeping lock (sleeplock::SleepLock) raises nothing, so a copy may run under one. The gate is a kernel-bug fence: no user action reaches it once the copies below moved.

The moves.

  • Objects are cloned out of the process's lock before any copy (syscall::handles::object; with_object now clones). A device claim is read under it, since describing one installs handles, into a DeviceRead that is copied out after.

  • Laid out, then copied: getcwd clones the cwd, readlink lets the VFS lock go first, and query_modules and sysinfo lay their answers out under their locks.

  • Sleeping locks where atomicity is the contract. A kernel file's position and a console's line are now SleepLocks, so a read or write stays atomic with its position update (POSIX XSH 2.9.7), and a console write's lines stay whole. A file's id and volume are fixed at its open and its mtime is atomic, so pread, pwrite, fstat and ftruncate take no lock. (Consolidate storage around native bcachefs and userland filesystems #1152 deletes the kernel file cache and lands first; these parts go at the rebase.) file_cache reads copy from the page's Arc with the cache lock let go (a concurrent write writes a copy), and writes bounce a page at a time before the cache lock. ByteSource had no other user and is deleted.

  • Pipes and connections claim their bytes (kernel::pipe::Bytes, host-modelled). One write claim and one read claim at a time; each copies with no lock held. The lock does only three things: claim, hand out a chunk's frames (≤ 17 runs; the first chunk's in the claim's own hold), and end the claim.

    • The data-race argument is the claims. A claimed write's frames count as used, so no surplus takes them. Only the read's own consume lets claimed bytes go. A read that empties the pipe beside a write claim does not reset the start under it. A last reader leaving during a claim leaves the frames for the claim's end to give back.
    • One turn protocol for pipes and connection directions (frame_bytes::turn, frame_bytes::wake). A second copy of either kind waits for the first on its end's watch, under a TURN token. The wait is cancellable by a kill and wakes no poll. Bytes::turn(end) asks to be woken every time it finds the claim held, under the lock its wait re-checks in, so a claim made between a wake and its re-check still wakes it as it ends (review r1's lost wakeup). Each claim's end answers a #[must_use] Ended.
    • An abandoned write announces its room. While a write claim is held its room is not space(), so a wait for room or a writable poll parks. A write abandoned (a pipe write or a send refused for memory, a send whose receiver left or whose handle a sibling closed) answers Ended::room, which posts the end's watch for every waiter.
    • Frames, and a longer frame list, are taken before the lock; spares are given back after it (frame_bytes::supply, give_back). So neither the frame allocator nor the heap is reached under a pipe's lock; a longer list a concurrent read made unnecessary is handed out of the hold too. A claimed transfer takes two holds or more in steady state; a bounced one takes one.
  • A transfer of at most 4 KiB bounces through its stack in one hold (frame_bytes::BOUNCE, Bounce). Round 1's metal showed the claims' extra lock holds cost small pipe writes 28–30% and request-reply IPC 12–26% on the T14. A small transfer is now copied between its caller and an uninitialized stack buffer with no lock held. Between the buffer and the frames the copy is kernel to kernel, in one hold:

    • Pipe write: Bytes::put, unless it would want frames, which it takes outside the lock as a claim does.
    • Pipe read: Bytes::take, then the copy out.
    • Send: the bytes are copied in before any lock, and the whole message is queued as its handles move, in the sender's process hold, as before the claims.
    • Receive: the front is popped and its handles installed in the hold that looks the connection up, where the table has room without growing (HandleTable::has_room). The bytes are copied out after.
    • Larger transfers, one that meets a claim in flight, and a receive whose table must grow take the claim path. A put or a take owes no wake: a copy asks to be woken only while a claim is held.
  • A large receive keeps "a refusal takes nothing". It claims the front message (queue lock), copies its bytes out (no lock), then under the process and queue locks pops it and installs its handles. The table's room is made before the queue lock. A real-time sender's lent window is taken with the message, so a receive refused for table room leaves it for the next. A claim dropped before its take leaves the message queued whole. That covers no table room now and, in 7b, a lost file page answered EIO. The one visible change: handles are installed after the bytes land. A send claims room, copies in, then moves its handles and queues the message whole in one hold; a receiver gone by then hands the batch back at its own numbers.

  • Console: the partial line is flushed in Drop, at the last reference. console: the VM's console is a userland virtio-serial driver's, and the UART goes quiet while it carries the log #1062 made the console's release deferred, so on_zero_handles runs from the release queue, where no sleeping lock may be taken. A write in flight holds a reference, so the line is free by then.

  • A bounce is filled in place, and a small call clones only what it must. A bounce returned by value had been moved, with all 4104 bytes copied, on every bounced pipe write and send. The wake after a pipe call borrows the object's pipe, and a bounced receive keeps no queue reference.

  • The SDK's lock spins briefly before it sleeps (toyos::sync::lock_word, 100 looks while held with no sleeper, as competitive spinning has it). Every descriptor call takes the process's descriptor-table lock twice. With the kernel no longer serializing a process's pipe calls under its process lock, the pipe benchmark's two threads met on it, and each meeting cost a futex sleep and wake. Recorded: issues/every-descriptor-call-takes-one-process-wide-lock-twice.md.

  • A small pipe call is made in the hold that looks its handle up (PipeRef::put_now, take_now). A write of at most 4 KiB is copied into the stack first. One hold of the process lock then finds the pipe and puts the bytes under its nested lock; a small read takes its bytes the same way and copies them out after. That is the lock shape base had, with no user copy inside it. A claim in flight, a want of frames or room, an empty pipe, or any other object goes to the general path, which copies a small non-pipe write in twice until Consolidate storage around native bcachefs and userland filesystems #1152 and kernel: no kernel thread but the idle loop; the console UART is a userland driver's (K4, K6) #1165 remove the kernel's files and console.

Semantics a reviewer should know.

  • A bounced pipe read or message receive lets its bytes go (and a receive installs its handles) before copying them out. Today a user copy cannot fail. In 7b a lost destination page could, and placing that outcome is recorded at 7b's prerequisite.
  • A non-blocking pipe read/write or message send/receive may wait for another copy's claim to end. That wait is bounded by one copy (by 7b, by its fault). It is never a wait for data or room, as with Linux's pipe mutex.
  • A blocked read keeps its cloned object across its park, as Linux's fdget does. A pipe write looks its handle up again after each wait for room, as before this PR, so a sibling's close of the writer's last handle is answered rather than the write landing past the reader's EOF. One window is left: a write already past its lookup when that close runs lands its bytes after the reader may have seen EOF. It is bounded by one claim wait and one copy. POSIX leaves a close concurrent with another thread's call on the descriptor to the application to avoid (my reading, not verified against the text); Linux would complete the write before EOF.

Records

  • Closes issues/a-user-copy-demand-pages-under-whatever-its-caller-holds.md: its order is refused by a check that fails.
  • The swap track's S0-a row and the process-memory track's 7b prerequisite say only this half of S0-a is met.
  • New: issues/a-file-write-beside-a-read-of-its-page-can-be-refused-for-memory.md. A write that finds its page shared with a reader in flight copies it under the cache lock and can fail Exhausted. Consolidate storage around native bcachefs and userland filesystems #1152 removes the file cache, and this issue with it.
  • issues/a-pipes-lock-spans-a-user-copy-and-its-first-allocation.md keeps what is left: the hold time is unread on a bench (QEMU's clock cannot read it), the connection queue's two VecDeques still grow under its lock (reserving their charged bound at creation is the fix, at that memory per direction: an owner tradeoff), and audio_tone's worst wake is unread.
  • The process-memory track records the ruling (orchestrator, 2026-11-01, on the owner's delegation) that 7b comes before stage 4. It also records that S0-a's spin-lock half is met, and that a copy under a sleeping lock parks holding it, which 7b's admission must refuse for a pager.

Checks

Round 5 (head 05f6c884f; evidence https://github.com/ToyOSOrg/evidence/tree/350a3bd22d010685d4bb15c0bbd2fb533273c778/r5):

  • Round 3's metal, read (r4/ab3-table.txt; base a0e8f13, head 5572386b8):
    • The futex contention is gone: 0–1 FUTEX_WAITs a run against base's 94–136.
    • Hot rows still regress. T14: pipe 64–1024 −10 to −15%, ipc.reqrep.4096 +16%, reqrep_handle.8 +12%. AMD: pipe 64/1024 −16/−19%, and IPC +15 to +45% (one of each AMD head arm's two boots in a slow mode).
    • The threshold is settled at 4096: b1024 moved worse on the 256 and 4096 rows of both benches.
    • The T14's std_fs failure was its DATA volume, overwritten after base boot 1 and independent of this change.
  • Host: cargo run -- --ci host at 05f6c884f: rc=0, 109 green.
  • Guest: the relevant x86 guest tests run on the remote KVM host at 05f6c884f, per the owner's rule: PENDING (queued). The --debug members run on the Mac before the rule gave 230/238 with the same 8 SYS_DEBUG members, and C 143/143.
  • Metal, round 4: staged (ab4/), with rules set before any boot. Base and head both carry a measurement-only per-syscall probe (branches s0a-probe-base, s0a-probe; never to land), which reports mean ns per call by syscall number for the IPC residual without a boot of its own. Five boots per bench; the AMD half can run first while the T14 is down.

Round 4 (head 5572386b8; evidence https://github.com/ToyOSOrg/evidence/tree/77bd65bd3e59d17b55254b0074f0ef938dd67270/r3):

  • Round 2's metal, read (ab2-table.txt). The gate holds on both benches. The hot rows still regress:
    • T14: pipe.throughput.64..4096 −8 to −15%; ipc.reqrep.8/4096/reqrep_handle.8 +8 to +16%.
    • AMD: pipe.throughput.64 −35% and 1024 −24%.
  • Two causes found.
    • The exit lines' FUTEX_WAIT counts for the pipe benchmark's process: main 125–205 a run, head 17,000–161,000 (ab2-futex.txt).
    • The disassembly's 0x1008-byte memcpy in PipeRef::write and sys_message_send (disasm-pipe-write-before.txt). The rebuilt kernel has neither.
    • The IPC rows' remaining cost is not fully explained by either. Their syscall counts are equal on both arms, and the time per call is about 3–5% higher.
  • Host: cargo run -- --ci host at 5572386b8: rc=0, 109 green. The new contenders_for_a_lock_each_hold_it_alone host test fails under control N8 (a spinner takes a held lock).
  • Members via --debug at 5572386b8: 230 of 238, the same 8 SYS_DEBUG members. C corpus 143 of 143. No gate panic.
  • Metal, round 3: staged (ab3/), with rules set before any boot. Per the owner's ruling, any hot row that moved worse blocks landing. The futex count is an oracle for the spin. Round 2's threshold comparison was confounded by the bounce's move, so round 3 measures it again.

Round 3 evidence (the bounce): https://github.com/ToyOSOrg/evidence/tree/4d8d75f1006b28a727f5d7fa608125fd5583c2a1/r2. Code at 0d0a03065; fa5c9d257 adds an issue note, and 1321274d7 a test lint.

  • Host model: whole_transfers_in_one_hold_keep_the_stream_and_the_claims covers small puts and takes beside a large write claim in flight, the stream byte-exact, frames bounded and accounted. Controls are red on exactly that test and reverted: N6 (put places though frames are wanted) and N7 (a refused put keeps its claim).
  • cargo clippy -p kernel --bin kernel -- -D warnings: rc=0 on x86_64 and AArch64.
  • Members via --debug (x86_64 QEMU) at 0d0a03065: 230 of 238, the same 8 needing SYS_DEBUG. The C corpus: 143 of 143. No gate panic.
  • QEMU threshold sweep: no order. Host load was 13–62, and base's two rounds differed tenfold (qbench-table.txt). One red, head's osbench stalled after ipc.reqrep.4096 at load 62, which the harness classed as its liveness ceiling. It did not reproduce: 3 of 3 head runs at load 21–25 completed. The phase that stalled runs the unchanged claim path.
  • Host CI: cargo run -- --ci host at 1321274d7 gave rc=0, 109 steps green. At 0d0a03065 its one red was clippy's len_zero in the new test, which 1321274d7 fixes; the change is test-only and leaves the staged images unchanged.
  • Metal, round 2: staged (ab2/), rules set before any boot. On both benches: base, b4096, b1024, base, b4096, b1024 benchcase, then b4096 testcases. b1024 is the same head at BOUNCE = 1024, staged because QEMU could not choose the threshold. 4096 stands unless b1024 moves better on the 4096-byte rows.

Round 1 evidence (head 42e2716c3, base 058bea2d3): https://github.com/ToyOSOrg/evidence/tree/18c2541c946ee50f72a9f8bda0c47633142525dc/r0
Round 2 evidence (head 19c814f63, rebased on main a0e8f1333): https://github.com/ToyOSOrg/evidence/tree/98ad7c5154481d8af4a08eaba1e3bfb297d08f77/r1

Round 2 (review r1's fixes):

  • Host: cargo run -- --ci host at 19c814f63: rc=0, 109 steps green. cargo clippy -p kernel --bin kernel -- -D warnings: rc=0 on x86_64 and AArch64.
  • Host model of the turn: a_claim_made_before_a_woken_waiter_looks_again_still_wakes_it (A ends, C claims, B re-checks, for both ends), an_abandoned_write_gives_its_room_back, a_second_claim_waits_until_the_first_ends, an_unwaited_claim_wakes_nobody.
    • Controls, each red on exactly its test and reverted: N4, the old protocol (a copy asks to be woken on its first look only); N5, an abandoned write's room returned silently.
  • Members via --debug (x86_64 QEMU) at 19c814f63: 230 of 238 exit 0. The 8 others are the members that need SYS_DEBUG, as in round 1. The C corpus: 143 of 143 match. No gate panic, no kernel panic.

Round 1 (still standing for what round 2 did not change): host model controls N1-N3; guest control G1 (the gate fires at depth 2 through sys_getcwd); the whole QEMU suite at 0d7a6ecfc, 83 of 90, with every red investigated (the recorded virtsmpcase counters-read silence, session_lifecycle under load, two load-only reds green alone); the Bytes::surplus underflow found there and fixed with a red-then-green host test.

Metal A/B, round 1 (T14 and AMD; base, head, base, head benchcase, then head testcases and head shared-debug; all boots passed; judged by the rules set before the boots, ab1-table.txt):

  • The gate on hardware: in the testcases boot, 14 of 14 members exit 0 on both benches, with no gate panic and no kernel panic. handle_lifetime is green in shared-debug on both.
  • Regressions past 5%, for the owner:
    • T14: pipe.throughput.64/256/1024 −28 to −30%, 4096 −5%; ipc.reqrep.8/4096 +25 to +26%, 65536 +12%, reqrep_handle.8 +19%.
    • Both: fs.mem.fstat +10% (T14) and +17% (AMD); fs.mem.seek +17 to +19%; fs.mem.pread_8 +7 to +8%; fs.mem.seq_write.4096 −12 to −14%; seq_write.1048576 −6 to −8%.
    • AMD: fs.mem.read_4k_at +8%, write_4k_at +20%.
  • Gains: pipe.throughput.16384/65536 +73 to +83% on the T14; 16384 +71% on AMD. AMD's small-pipe rows did not move: their bases straddle the heads.
  • Unchanged-path controls (fs.disk.*, persist.*) also flag "regressions" on the T14 (fs.disk.direct.*, stat, fstat +14 to +20%), but its two base boots differ from each other by about as much. The T14's durable-path variance is a recorded bench issue. Some of these rows (fstat, pread, write_4k_at) run this change's file paths, so I do not read them as clean controls either way.
  • Round 2 removes the locks behind fstat, pread and pwrite. Its expected effect is unmeasured, and Consolidate storage around native bcachefs and userland filesystems #1152 deletes those paths anyway.
  • Round 2's metal is not staged yet. Consolidate storage around native bcachefs and userland filesystems #1152 lands first and removes the kernel-file paths, so images built now would not be the landing code. Its rules are set (ab2-rules.md): a new base at the rebased parent, benchcase ×4 and testcases on both benches.

Risks

  • Small-transfer cost. Metal round 1 measured the claim path's extra holds on the T14: small pipes −28 to −30%, IPC +12 to +26%. AMD's small-pipe rows did not move. Transfers of at most 4 KiB now bounce, in one hold; round 2's metal says whether that recovers them. Any regression left is the owner's call. Kernel-file writes still bounce a page through the stack, and their rows wait for Consolidate storage around native bcachefs and userland filesystems #1152.
  • A copy under a sleeping lock parks holding it in 7b. A pager that needs the same open file's position or console line would deadlock against its own fault. Recorded at the 7b prerequisite.
  • Concurrent reads of a cached kernel page force a writer to copy that page (Page::own), which can fail with Exhausted. This is recorded in its issue and goes with Consolidate storage around native bcachefs and userland filesystems #1152.
  • The console's partial line is flushed at the last reference, after its holder gives the log back to the UART, and so later than before. This is intended: the release queue may run on_zero_handles where no sleeping lock may be taken, and a dying carrier drains nothing either way.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1150 at 42e2716c3 (base 058bea2d3)

Architecture

The design fits the stage. A user copy now runs outside every spin lock, and one gate in user_ptr refuses any copy made above trap depth. A pipe or connection direction claims its bytes under its own lock, so a copy needs no new lock and no kernel thread. A receive claims, copies, then takes, so a refusal still takes nothing. I checked the pure Bytes claim model against concurrent reads, rotation, an unsupplied write, relisting and the surplus fix, and it holds. The defects below are in the kernel-side wait protocol around the claims, which has no host model.

Size: the kernel grows by +1355/−524 lines. About 215 of the added lines are pure tests, so production grows by about 615 net, mostly in frame_bytes (+211) and message (+187). The TURN protocol is the same code twice, in pipe.rs and message.rs, and both copies carry the same bug (finding 1).

Gate reachability: verified by inspection. SyscallContext is built only in dispatch. I checked every caller of copy_in, copy_out, user_vec, user_str, read_at/write_at, *_pieces, *_raw, fill_zero and translate_user, changed or not:

  • random, counters, log, paged, loader, proc, vm, device, machine, fs and ipc;
  • console lines, file ops, futex.

None runs under a sync::Lock, and sync::Lock always raises the preemption count. FileObject::with and ConsoleObject::write call Parkable::at_entry, which asserts the baseline depth, so a mis-nested sleeping lock also fails loudly. G1 shows that the gate fires.

Required

  1. Lost wakeup in the TURN wait. Sites: kernel/src/pipe.rs:189,218,256-261 and kernel/src/message.rs:301,330,353-358. The waiting flag is set only once, before await_turn. wait_until then loops on its predicate internally without setting the flag again. Interleaving:

    1. Thread A holds a claim. B sets waiting and parks.
    2. A ends its claim. It takes waiting (now false) and posts TURN.
    3. Before B re-checks, A (looping) or a third thread C claims again.
    4. B sees the claim held and parks again. waiting is still false.
    5. The second claim ends and posts nothing.

    B now sleeps until some unrelated post() on that watch. That means the next write for a pipe reader, the next read for a pipe writer, the next send for a receiver, or the next receive for a sender. If none comes, B hangs. Examples:

    • a worker pool receiving on one connection;
    • two threads writing to one stdout pipe while the reader waits for the parked thread's output;
    • a non-blocking op, which the PR says is "bounded by one copy" but is then unbounded.

    Fix: set waiting inside the predicate, under the lock, whenever it finds the claim held. Do it once, in one shared helper for pipes and queues. Inspection then suffices as evidence.

  2. An abandoned write claim gives its room back without waking anyone. Sites: kernel/src/pipe.rs:243 (NoMemory) and kernel/src/message.rs:198. The message path is reached by a commit refused because a sibling closed a handle being transferred, or by NoMemory. While a claim is held, space() excludes the claimed room. So a room-waiter (has_space, fits) or a writable poll that checks during the claim finds no room and stays parked. When the claim is abandoned, nobody posts write_watch. Example:

    1. The queue is full and sender S2 is parked waiting for room.
    2. The receiver takes a message and posts, but S1 claims the freed bytes first.
    3. S2 re-checks, finds no room, and parks again.
    4. A sibling of S1 closes one of the handles S1 is sending, so S1's commit check fails.
    5. The room comes back with no post.

    S2 stays parked until another receive, and the receiver may be waiting for S2. Before this PR no claim could hold room, so this state is new. Fix: post write_watch when a write claim with room is abandoned.

  3. The records overclaim S0-a. issues/process-memory-in-4-kib-pages-still-copies-each-programs-text.md:115 says "the swap track's S0-a, met", and the PR body says the same. The S0-a row in anonymous-memory-swaps-to-an-encrypted-partition.md:322 still lists unbuilt work:

    • chunk fault-in before any lock, with pins after a re-check;
    • a lost anonymous page ends the caller, and a lost file-mapping page answers EIO;
    • the H model.

    Its exit issue (the pipes one) also stays open. Say instead that S0-a's no-copy-under-a-spin-lock half is met, and that 7b needs only that half, if that is the ruling.

  4. Misplaced test comment. At kernel/pure/message.rs:196, the new test was inserted between a_message_without_handles_asks_no_room and its doc comment. the_front_is_answered_and_left_queued now opens with "The table's room is asked only for a message that carries handles", which is false for it, and the old test has lost its doc. Move the line back.

  5. The header's invariant is false on one path. The header at kernel/src/frame_bytes.rs:7-8 says the lock is never held across the frame allocator. But at kernel/src/frame_bytes.rs:174, when a concurrent read made the longer list unnecessary, the closure drops the new list's Charge and VecDeque under the object's lock. Charge::drop calls pmm::uncharge, which takes the allocator's lock, and the PR body claims the same invariant. Fix: hand the unused pair out of the hold as old is handed out.

Optional

  • At kernel/src/message.rs:343, rt_boost_pending is taken at claim time. If the take is refused NoRoom, the loan is lost. The old code took it only on success.
  • pread/pwrite (kernel/src/object/ops.rs:462,470) hold the position SleepLock across the copy, but neither uses the position (pwrite sets only mtime). Copying file_id out first serialises less, and it shrinks the 7b deadlock this PR records.
  • kernel/src/object/device.rs:201-213: a console's partial line is now flushed after released_by gives the log back to the UART, and later than before. Confirm that this order is intended.
  • kernel/src/syscall/dispatch.rs:123-125: the fsync comment says "fsync parks between flush attempts". ops::fsync never parks, and object() now documents the rule, so delete the comment.
  • A read or write keeps its cloned object across parks, but a pipe end's holder is released at zero handles. So a writer blocked after its last handle closed can land bytes after the reader has already seen EOF. Mention this in the PR's semantics list.
  • When a reader shares a page, the writer must copy it (Page::own), and that copy can fail with Exhausted. This is a new way a write can fail. Either record it in issues/ or copy under the lock into a bounce buffer, as the write path already does.
  • sysinfo_laid repeats HEADER_SIZE/ENTRY_SIZE, and the header-only path now allocates.

Evidence

  • Host --ci host at the head: rc=0.
  • Guest evidence was reused from 0d7a6ecfc. The only differences from the head are the pure surplus fix, its test and two issue sightings, so the reuse stands.
  • N1–N3 are meaningful controls for the Bytes model.
  • There is no test or model of the TURN/waiting protocol, which is where findings 1 and 2 are. A host model of claim end against a waiter is not required if the fix makes the invariant evident by inspection.
  • Metal is staged, not run, which is acceptable for a draft. The orchestrator owns that run.
  • Performance: the extra lock round trips follow from the claim design. I found none avoidable.

SEND BACK

Japabu and others added 2 commits November 2, 2026 07:28
…fused (S0-a)

A user copy reaches its pages under its space's lock, populating any not
yet touched, and in memory stage 7b that fault-in will park on a userland
pager. A copy made under any spin lock would then park holding it. Several
syscalls copied that way: reads and writes under the process lock and the
object's (a pipe's, a kernel file's cache, a console's line, a device
claim's description), message send and receive under the process and queue
locks, and getcwd, query_modules, sysinfo and readlink under the process,
process-table or VFS lock.

The gate. Every copy entry (View::window, View::pieces, translate_user)
refuses a copy above the preemption depth its syscall's entry runs at,
whether or not a page needs populating: every spin lock raises the count,
and both architectures' trap entries raise it to BASELINE_TRAP (x86_64's
syscall_entry and common_entry, AArch64's trap::syscall and
trap::user_abort). It is a kernel-bug fence; no user action reaches it.
This replaces #1146's per-space holder, which caught only a copy under its
own space's lock.

The moves.
- Syscalls work on their object cloned out of the process's lock
  (syscall::handles::object, and with_object now clones). A device claim is
  read under it, since describing one installs handles, into a DeviceRead
  copied out after.
- getcwd clones the cwd, readlink lets the VFS lock go first, and
  query_modules and sysinfo lay their answer out under their lock and copy
  it out after.
- A kernel file's position and a console's line are sleeping locks
  (SleepLock), which raise nothing, so a read or a write stays atomic with
  its position update (XSH 2.9.7) and a console write keeps its lines whole.
  file_cache reads copy from the page's Arc with the cache's lock let go (a
  write meanwhile writes a copy), and a write's bytes are copied in a page at
  a time before the cache's lock. ByteSource had no other user and goes.
- Pipes and connections claim their bytes (kernel::pipe::Bytes): one write
  claim and one read claim at a time, each copying with no lock held. The
  lock claims, hands out a chunk's frames and ends the claim. A claimed
  write's frames count as used, so no surplus takes them, and only the read's
  own consume lets claimed bytes go; a last reader leaving under a claim
  leaves the frames to the claim's end. A second copy of either kind waits
  for the first on its end's watch under a TURN token, cancellable by a kill
  and waking no poll. Frames and a longer frame list are taken before the
  lock and spares given back after it (frame_bytes::supply, give_back), so
  neither the allocator nor the heap is reached under it.
- A receive claims the front message, copies its bytes out, then under the
  process and queue locks pops it and installs its handles, with the table's
  room made first. A claim dropped first leaves the message queued whole, so
  a refusal still takes nothing. A send claims its room, copies in, then
  moves its handles and queues the message whole in one hold; a receiver
  gone by then hands the batch back at its own numbers.

The console's partial line is now flushed in Drop, at the last reference:
the deferred release queue runs on_zero_handles where no sleeping lock is
taken, and a write in flight holds a reference.

Records: the finding a-user-copy-demand-pages-under-whatever-its-caller-holds
is closed (its order is refused by a check that fails). The pipe issue keeps
what is left: the hold time unread on a bench, the queue's two lists grown
under its lock, and audio_tone's worst wake. The process-memory track records
the orchestrator's ruling of 2026-11-01 (on the owner's delegation) that 7b
comes before stage 4, its S0-a prerequisite met, and that a copy under a
sleeping lock parks holding it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… abandoned write's room announced

A copy that found its end's claim held asked to be woken once, before its
wait; the wait's re-checks did not ask again. A claim made between a wake
and the re-check then ended without waking it. The turn now lives in
`kernel::pipe::Bytes`: `turn(end)` asks to be woken every time it finds the
claim held, and each claim's end answers `Ended`. `frame_bytes::turn` is the
one wait, shared by pipes and connection directions, and `frame_bytes::wake`
the one post. A claim is made only in its turn, so `claim_read` and
`claim_write` no longer answer a refusal.

An abandoned write gives back room that a wait for room or a writable poll
found taken: `Ended::room` posts the end's watch for every waiter (a pipe
write refused for memory; a send refused for memory, for a receiver gone or
for a handle a sibling closed).

The rest of the review:
- `frame_bytes::supply` hands an unused longer list out of the hold, so no
  `pmm::uncharge` runs under the object's lock.
- A real-time sender's lent window is taken with the message, not at the
  claim, so a receive refused for table room leaves it for the next.
- A file's id and volume are fixed at its open and its mtime is atomic, so
  `pread`, `pwrite`, `fstat` and `ftruncate` take no lock; the sleeping lock
  holds the position alone.
- A pipe write looks its handle up again after each wait for room, as it
  did before the claims, so a sibling's close is answered rather than a write
  landing past its reader's EOF.
- The S0-a records say only its no-copy-under-a-spin-lock half is met.
- The fsync comment that said it parks goes; the file row's comment says
  where a file's read or write parks; a test's doc comment is back on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu Japabu changed the title kernel: no user copy runs under a spin lock, and one that would is refused (S0-a) kernel: no user copy runs under a spin lock, and one that would is refused (S0-a, its spin-lock half) Nov 3, 2026
Japabu and others added 3 commits November 3, 2026 20:59
…in one hold

A claim costs a small transfer at least two holds of the object's lock where
the copy under the lock took one, and on the T14 that cost small pipe
writes 28-30% and request-reply IPC 12-26%. A transfer of at most `BOUNCE`
(4096) bytes is now copied between its caller and a `Bounce` on the stack with
no lock held. Between the bounce and the frames the copy is kernel to kernel,
in one hold:
- a pipe write puts its bytes whole (`Bytes::put`), unless it would want
  frames, which it takes with the lock let go as a claim does;
- a pipe read takes its bytes whole (`Bytes::take`) and copies them out after;
- a send's bytes are copied in before any lock, and the message is queued
  whole as its handles move, in the sender's process hold, as before the
  claims;
- a receive pops the front message and installs its handles in the hold that
  looks the connection up, where the table has room without growing
  (`HandleTable::has_room`), and copies its bytes out after.
Larger transfers, a transfer that meets a claim in flight, and a receive whose
table must grow keep the claim path, which kept their gain (large pipe writes
+71-83% on the T14). The bounce is uninitialized and filled through
`read_raw`, so a 64-byte write zeroes no 4 KiB.

A put or a take owes no wake: a copy asks to be woken only while a claim is
held, and none is held across either.

The track records the orchestrator's 2026-11-03 ruling: 7b needs the met
half of S0-a and, after #1152, the gate extended to sleeping locks; the rest
of S0-a is built as part of 7b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pipe read or message receive of at most `BOUNCE` bytes lets its bytes go,
and a receive installs its handles, before copying out. A lost page met in
that copy, which 7b answers, must not lose them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…othing it can borrow

`Bounce::read` answered the bounce by value, and the compiler moved it into
the caller's place: every bounced pipe write and send copied all 4104 bytes
of it, whatever it held (a `memcpy` of 0x1008 bytes in `PipeRef::write` and
`sys_message_send`). The bounce is now filled where it lies.

The wake after a pipe read or write borrows the pipe from the object the
call already holds, rather than cloning it, and a bounced receive keeps no
reference to its queue: the spares it took free themselves, and any past
their batch stay listed for the next take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`lock_word` slept in `FUTEX_WAIT` at the first collision. The descriptor
table's lock, taken twice by every descriptor call, is held for a few hundred
nanoseconds; a sleep and its wake cost a kernel round trip each way. In #1150's
metal A/B the pipe benchmark's two threads, no longer serialized by the
kernel's process lock, met on it 128,000-153,000 times a run where main met
it about 150 times, and that was much of the small-pipe-write regression.

A contender now looks again up to 100 times while the word says held with no
sleeper, as competitive spinning has it, and sleeps behind any sleeper. The
lock's sleep and wake go through the module's `wait`/`wake_one`, so a host
test contends for it. The process-wide lock itself is recorded in
`issues/every-descriptor-call-takes-one-process-wide-lock-twice.md`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 4, 2026
… comment names whose lock

The eleven sightings all fall between 2026-10-04 and 2026-10-10; the
2026-11-02 reading of #1150's scratch kernel was not one of them. The copy's
comment names the readers that want the lock: the caller's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A write of at most `BOUNCE` bytes is copied into the stack before any lock;
then one hold of the process's lock looks the handle up and, for a pipe's
write end, puts the bytes in under the pipe's lock (`PipeRef::put_now`). A
read takes at most a bounce's worth in the same way (`PipeRef::take_now`)
and copies it out after. That is the lock shape a pipe call had before any
copy left the locks, with no user copy inside it: a process lock and the
pipe's, nested, once each.

Anything that hold cannot answer at once falls to the general path: a claim
in flight, a write that wants frames or room, an empty pipe or more than a
bounce to read, and any other object. A small write to another object is
copied in twice, which ends with #1152 and #1165 for the kernel's files and
console. `Bytes::claim_held` asks after a claim without asking to be woken,
since these never wait.

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