Repository navigation
Conversation
Review r1 of #1150 at
|
… 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>
`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>
… 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>
Design note: user copies through the user mapping, with a fault fixup (option c)The copy. One primitive per direction:
The fault. A Ring 0 or EL1 fault whose PC is in the table and whose address is in the user half goes to SMAP and PAN elsewhere. x86: Locks. A copy may be called holding no kernel lock: Behaviour that changes.
Deleted.
Measured after it lands: KVM first, then the staged metal A/B (benchcase without the DATA files group in both arms), against the hot-path bar. |
…lt in it is served or ends it as BadAddress A copy reached each user page through the direct map: under the space's lock it reached the page, populating it, and pinned the frame, then copied with the lock let go. Two threads of one process copying at once met on that lock and on the space's refcount, which every copy cloned; with the kernel no longer serializing them under the process lock, that was most of #1150's remaining small-pipe-write cost (about 90 ns a write under KVM). Now a copy is one string instruction or one loop through the user's own virtual address (`arch::uaccess`): - x86-64: `stac; rep movsb; clac`, or `rep stosb` to clear. SMAP is required by the CR4 declaration, and every entry closes `AC` beside its `cld` (`arch::entry`, the NMI and unclaimed-vector stubs), which the `iretq` restores for an interrupted copy. - AArch64: unprivileged `LDTR`/`STTR`, 32 bytes a step with the kernel side paired, checked at EL0's permissions. PAN joins the declaration where the CPU has it: the entry clears `SCTLR_EL1.SPAN`, `check` sets `PSTATE.PAN` and asserts both, so any other EL1 touch of a user address faults. - Each routine replaces a user address at or past the half's top with 0 without a branch (`cmovae`; `csel` and `csdb`), so a mispredicted range check loads no kernel byte. A Ring 0 or EL1 fault at one of those accesses on a user address goes to `process::handle_page_fault`, at the syscall's depth, where a fault 7b parks may park; served, the access resumes where it stopped (the string instruction's registers, or the loop's count lowered only after each step). Not served, or a protection fault, it resumes at the routine's fixup, which answers the bytes not copied. A copy fault seals no blackbox. A window is checked against the user half's top, overflow refused, and nothing else: whether its pages are there is found by the copy. Copies now answer `Result<(), Faulted>`, `BadAddress` at the syscall, and every caller says what a fault leaves behind: - the device grants write their output window before anything is opened, so a window the caller cannot write leaves no grant it was not told of; - a receive that faults after taking its message closes the handles it installed at numbers the reader was never told, as the inbox setup does; - a pipe or bounced receive that faults copying out loses the bytes it took; - a pager supply gives back the frames the owner's buffer could not fill, and a pager write releases the pins of pages its bytes did not reach without growing the size (`Pages::write_abandon`, host-tested). Deleted: `View::pieces`'s chunked reach, pin and unpin under the space's lock with its zero page and per-copy clone, `read_pieces`/`write_pieces`, and `Space::allows`. `frame_bytes`'s claimed copies go straight between a frame's direct-map address and the user's, adjacent frames merged. `user_copy_faults` faults a copy mid-way in both directions, inside and past a bounce, and is refused for a released, an unreadable, a kernel and a past-the-top address; `a_user_copy_faults_in_mid_copy_or_ends_as_bad_address` boots it under QEMU's x86-64 machine, and the shared boots run it on metal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1150 at
|
…ee loud failures - The device-memory contract says only what holds on each architecture: a copy makes the accesses the process could make through that mapping itself, with the same instruction (x86) or unprivileged (AArch64). - The resume after a fault cites Vol. 2B's REP-prefix entry in its words. - A receive that faults closes the numbers it installed and says the race it accepts: a sibling that closed or reused one meanwhile is the misbehaving process's own. - `is_user_object` no longer speaks of per-page pieces, and the descriptor-lock record no longer recalls another kernel's internals. - A copy fault with `RFLAGS.AC` clear asserts rather than answering `BadAddress`; `user_ptr::gate` refuses a copy with interrupts masked, which its fault could not serve (`cpu::interrupts_enabled` is no longer an instrument's alone); the black box skips only a copy's fault on a user address, so one on its kernel side seals. - The device grants' comments say what the zero-fill first ensures: a window unwritable as the call begins leaves no grant, and one a sibling unmaps meanwhile can, until the claim's release. - The copy-fault handlers no longer claim a 7b fault may park there: the per-CPU fault state stays set during the service, which 7b must address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ench The owner's ruling sends every guest test to the guest runner and no guest run is a verdict on throughput, so #1150's review finding 2 is recorded in the AArch64 track rather than measured under HVF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…obe and the osbench capability groups (#1150 r9 KVM A/B, ab5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1150 at e43c7cf, after the rebase onto e11b6a7Scope: what the rebase changed. I compared each file's diff against a0e8f13 (old base) to 070c17e with its diff against e11b6a7 to HEAD, read the new diff against main in full where it changed, and audited every user-copy site on main. The approved design and earlier settled findings are not reopened. ArchitectureThe rebase fits main's structure. SMAP, PAN and the entry Implementer's claims, checked against the code:
FindingsBLOCKER
SUGGESTION For the orchestrator (PR body): "Objects: cloned out of the process lock for read/write/fstat/fsync" names SEND BACK |
Review of 199228a (fix for the r-previous blocker)Scope: only the fix Assessment. The fix moves the early What I checked:
Findings No blockers. SUGGESTION SUGGESTION LAND |
Review of
|
|
Orchestrator verification of the r12 fix (378cea3..015a99b), which changes no code:
|
Focused review:
|
|
Orchestrator verification of the changes after the review of bdec5ab (#1150 (comment)). Neither commit changes code:
#1150 is still not landable. Under rule 1, AMD |
…ock (S0-a) Every user copy goes through `arch::uaccess`: on x86-64 `stac; rep movsb; clac` (or `rep stosb`), on AArch64 unprivileged LDTR/STTR. A Ring 0 or EL1 fault at one of those accesses on a user address is served as the process's own fault would be and the copy resumes, or the copy resumes at its fixup and answers the bytes it did not copy, which `user_ptr` reports as `Faulted` (`BadAddress`). Each routine clamps an address at or past the user half's top to 0 without a branch (`cmovae`; `csel` plus `csdb`), so a mispredicted range check loads no kernel byte. No user copy runs under a spin lock: `user_ptr::gate` panics above the syscall's preemption depth or with interrupts masked. The kernel has no sleeping lock since #1152, so that is every kernel lock. Callers clone their object out of the process's lock (`syscall::handles::object`) and copy after; a device claim is described under it and copied out after (`DeviceRead`). Pipes and messages share one claim protocol whose waiter records itself each time it finds the claim held (`kernel::pipe::Bytes::turn`), so no wake is lost, and an abandoned write announces its room. A pipe transfer of at most 4 KiB and every message bounce through the caller's stack in the hold that looks the handle up; a larger pipe transfer claims its bytes and copies straight between frames and the user. The SDK's `lock_word` spins briefly before it sleeps. SMAP and PAN being required, and the entry `clac`, are main's (#1154). The kernel-file and console parts of the earlier rounds went with #1152. Closes issues/a-user-copy-demand-pages-under-whatever-its-caller-holds.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k's checks A copy-out fault in `PagerObject::read` answered `done` before the chunk's gone and written checks, so a holder could take bytes from a chunk its pager had ended or rewritten meanwhile as valid. The fault is now answered only after those checks pass, as every copied chunk's bytes are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`PagerObject::read` copies a chunk of frames one frame at a time, each copy a separate pass through the user mapping. Into fresh memory, such as the shared memory a spawn reads its image into, every page then took its own fault exception: on KVM the large spawn's 32 MiB read took 8193 copy faults and 8.2 ms of copying against main's 4.7 ms, whose copy populated its pages directly. A chunk of more than one frame now populates its destination pages in one hold of the space's lock first (`UserBytesMut::fault_in`), no lock of the object held; a page it cannot populate is left for the copy to fault on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment claimed one frame's copy faults at most once; a piece can span two destination pages. The reason is the hold a small read would pay on every call. `fault_in`'s doc is cut to its contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the paged read faults its chunk in, the large spawn still costs about 85 ns a page more than main on KVM and 9% more on the T14. The per-page translation hypothesis is labelled as one, with a counter measurement as its exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 18-21% loss on the T14 followed std's allocator code moving 32 bytes modulo 64; 32 inert bytes that move it back restore base's values with the grown lock kept (metal round 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A leaf without the accessed and dirty flags has the page walker write them on its first access or write, so a kernel copy into fresh user memory pays that for every page. On the AMD bench, the large spawn's paged read copied a page through a just-populated user leaf in 1.87 us, against main's 0.69 us through the direct map, with the same frames and instruction otherwise (metal round 7). The flag update is the one difference the measurement leaves; that it is the cost is to be confirmed on that bench. Software may set both flags (SDM Vol. 3A 4.8; AMD APM Vol. 2 5.4.2). Nothing in the kernel reads them, and AArch64's leaves already carry AF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Metal round 8: on the T14 the large spawn is 4% better than main, so its record closes; on the AMD bench it is 13% worse, recorded with its per-page copy and populate figures and the translation hypothesis labelled as such. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Orchestrator verification of the rebase onto main (cf3e5bb → 61e0bf0), which adds no logic:
Landing. |
Memory track S0-a, its no-copy-under-a-lock half, built as the orchestrator ruled (2026-11-04, option c): every user copy goes through the process's own mapping, and a fault in it is served as the process's own fault would be, or ends it as
BadAddress. No user copy runs under a kernel lock, and one that would is refused by name. Small pipe and message transfers bounce through the caller's stack in one hold. The rest of S0-a (chunk fault-in before any lock, the lost-page outcomes, its H model) is built as part of 7b, per the same ruling, recorded in both tracks. Rebased onto main after #1152: the kernel has no sleeping lock left, so the spin-lock gate covers every kernel lock, and 7b's two prerequisites are both met.Design (approved; note: #1150 (comment))
The copy (
arch::uaccess,user_ptr):stac; rep movsb; clac(rep stosbto clear). A string instruction that faults leavesRCX,RSIandRDIat its progress (SDM Vol. 2B, MOVS).LDTR/STTR, checked at EL0's permissions, so no PAN toggle is needed. They move 32 bytes a step, four 8-byte user accesses beside paired kernel-sideLDP/STP, then 8, then single bytes. The count left is lowered only after each step's accesses.start + lenwith overflow refused, and nothing else. Copies answerResult<(), Faulted>, which becomesBadAddress.The fault. A Ring 0 or EL1 fault at one of those accesses, on a user address, goes to
process::handle_page_fault, at the syscall's depth.RFLAGS,ACincluded, into the thread's own trap frame, and itsiretqrestores it; theclacevery entry runs (main's, kernel entry: no kernel register reaches Ring 3, AC and PAN bind at every entry #1154) means the handler runs withACclear. No switch can happen mid-copy today: the depth is at least 1, the Ring 0 timer only flags, and the fault state blocks preemption during the service. The served-fault test is what provesACsurvives the trap and itsclac: withoutiretqrestoring it, the resumedrep movsbwould fault on a present page and the test would fail. A fault whose savedACis clear is a kernel bug and panics. AArch64's state is the frame'sSPSR/ELRand the copy's registers.SMAP and PAN are main's (#1154), taken as they are: SMAP and PAN required by the control-register declarations,
clacat every entry, andvirt_pan_refuses_el1_a_user_page. This PR adds only the routines that open SMAP's door (stactoclac, around one string instruction) and the AArch64 copies, which use unprivileged accesses and so need no PAN toggle.ACis set only between a routine'sstacandclac.Spectre v1 (point 1). Each routine replaces a user address at or past the top with 0 without a branch (
cmovae;cselpluscsdb), so a mispredicted range check loads no kernel byte.issues/user-pointer-checks-have-no-spectre-v1-fence.mdrecords what is built and what is left: the gadget measurement on each proving machine.Device memory (point 3). A copy through a user mapping of device memory makes only the accesses the process could make through that same mapping with the same instruction (x86: one
rep movsb/stosb, which the SDM lets resume after a fault with its registers preserved; AArch64: unprivileged loads and stores at EL0's permissions, where an unaligned one to Device memory faults and ends the copy). That is nothing the holder could not do itself, so it is allowed and stated at the routines.Locks (point 4). A copy may be called holding no kernel lock. The gate stays: a copy above its syscall's preemption depth panics, naming the depth. The callers this PR restructured to copy with every lock let go:
syscall::handles::object). A device claim is described under it and copied out after (DeviceRead).query_modules,sysinfo, and the counters. Main's later copy sites (namespace names, the device set, last words) answer a fault asBadAddress; a device-set read that faults is not marked seen.Paged reads. A holder's paged read copies a chunk of frames one frame at a time. A chunk of more than one frame first faults its destination pages in, in one hold of the space's lock (
UserBytesMut::fault_in), so fresh memory takes no exception per page. A part-way copy fault is answered only after the chunk's gone and written checks.User leaves (x86-64). User page-table leaves are made accessed, and dirty where writable, from creation (
Prot::leaf_bits; SDM Vol. 3A §4.8, AMD APM Vol. 2 §5.4.2). Otherwise the walker writes those flags on a fresh page's first access, which a kernel copy into new memory paid for every page. Nothing reads either flag, and AArch64's leaves already carryAF.Speed (point 5). Measured against main on KVM and on both metal benches (Checks).
Pipes and messages
kernel::pipe::Bytes::turn,frame_bytes::turn/wake). A copy records itself as waiting every time it finds the claim held, so no wake is lost. An abandoned write announces its room.PipeRef::put_now/take_now). A larger one claims its bytes and copies them straight between the frames and the user.MessageBounce, at mostMAX_MESSAGE_LEN). A send is queued whole as its handles move. A receive pops the message and installs its handles in one hold. Only a send whose bytes want new frames claims its room.toyos::sync::lock_word) spins briefly before it sleeps. The descriptor table's process-wide lock is recorded:issues/every-descriptor-call-takes-one-process-wide-lock-twice.md.Semantics a reviewer should know
BadAddressat the copy, with partial progress. Before, it was refused only when its window was made, and a page taken away mid-call read as zeros.Records
issues/a-user-copy-demand-pages-under-whatever-its-caller-holds.md.issues/a-pipes-lock-spans-a-user-copy-and-its-first-allocation.mdkeeps what is left: the hold time unread on a bench, and the queue's lists growing under its lock.issues/every-descriptor-call-takes-one-process-wide-lock-twice.md.issues/a-large-spawn-on-the-amd-bench-is-a-seventh-slower-than-mains.md.issues/osbench-s-malloc-churn-on-the-t14-moves-a-fifth-with-the-allocator-s-code-placement.md. The SDK lock's growth moved std's allocator 32 bytes modulo 64; an arm that only moves it back reads main's figures.issues/user-pointer-checks-have-no-spectre-v1-fence.mdrecords the clamp as built and the gadget measurement as left.issues/toyos-runs-on-arm64.mdrecords AArch64 copy throughput as unmeasured, until an AArch64 bench measures it.Checks
The kernel code was measured at
bdec5ab36. Commits after it change comments and records only. The branch is rebased onto main after #1182-#1187: the only conflict wassys_getcwd, which #1182 deleted along with my rewrite of it, plus mechanical context. Host CI was re-run on the rebased head. Evidence is onToyOSOrg/evidencebranchpr-1150, rounds r0-r19. The hot-row verdict is r19; both benches' last full round is r15.Host:
cargo run -- --ci hostatbdec5ab36: rc=0, 108 green.an_abandoned_write_pin_grows_nothing_and_holds_nothing.Guest rows, on the guest runner:
bdec5ab36, 11 x86 rows that exercise user paging, 11 of 11 (run):a_user_copy_faults_in_mid_copy_or_ends_as_bad_address,a_spawn_from_an_image_frees_it_when_the_child_ends,nvme_disk_boots,console_image_boots,std_threads_count_the_cpus_and_give_back_ended_threads,stream_holders,session_lifecycle,a_launch_gives_each_unsent_stdio_slot_a_null,a_link_ends_under_a_held_stream_and_comes_back,a_client_killed_mid_connect_gives_its_place_back,the_uart_carries_the_log_again_once_the_console_carrier_goes.virt_user_copy_faults,virt_readonly_copyout,virt_unmap_touch,virt_jobs_at_el2,virt_pan_refuses_el1_a_user_page): green at5c600a4aeand, for the first two, at378cea3a7. No AArch64 code changed after that.Metal round 8,
bdec5ab36against main with the per-syscall probe; rules set before any boot, AMD judged by pair:ipc.reqrep.8-15%,ipc.reqrep.4096-13%,ipc.reqrep_handle.8-20%.pipe.throughput.4096better (+24%),pipe.throughput.64better (+0.8%, +4.1%),pipe.throughput.1024unchanged.pipe.throughput.256was worse in both pairs (-1.0%, -0.8%), a REGRESSION by that round's rule. Round 10 settled it under a stricter rule set first: passed, with a mean of +1.18% and 2 of 6 pairs worse.proc.spawn_wait.large: T14 8.02/8.10 ms against main's 8.34/8.42 (better). AMD 12.79/12.75 against 11.00/11.53 (+13%), recorded.mem.anon.touch: T14 -38%; KVM -40%.time.sleep_late.1ms,mem.realloc_grow.1m_256m,mem.memcpy.262144,proc.posix_spawn_wait.std,wake.*, and the T14'sthread.spawn_join, which follows its clock (3791 against 4189 MHz).Metal round 10, AMD only: the hot-row verdict (r19). It ran six alternating base/head pairs of main's whole benchcase, under a rule set before any boot (r18). A hot row regresses if head is worse than its paired base in at least 5 of 6 pairs (one-sided p ≈ 0.11), or if the mean pair change is below -1%. Every hot row passes:
pipe.throughput.64pipe.throughput.256pipe.throughput.1024pipe.throughput.4096ipc.reqrep.8ipc.reqrep.4096ipc.reqrep_handle.8The IPC outliers are AMD's slow mode landing on one boot of a pair, and they count as measured. Rows past -5% that are not hot:
mem.anon.mapandpingpong.futex.cross_coreare bimodal in both arms in every round;proc.spawn_wait.largeis -9.4%, recorded.Metal round 9, AMD only, diagnostic (r17):
pipe.throughput.256(-1.0%, -0.8%); round 9's plain pairs read +0.4% and +3.5%.Earlier rounds:
Risks
🤖 Generated with Claude Code