Skip to content

A switch loads only what the incoming thread does not already have - #1251

Merged
Japabu merged 8 commits into
mainfrom
switch-drops-unneeded-work
Nov 12, 2026
Merged

Japabu merged 8 commits into
mainfrom
switch-drops-unneeded-work

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

On the T14, the switch inside a same-CPU futex round trip cost 403 of 3,998 ns (wakeperf's segment probes on main 422915e; Linux's whole round trip is 2,298 ns): a CR3 write of ~120 ns although both threads share one address space, an FPU restore (~100 ns with the save), an IA32_FS_BASE read and write (91 ns) and an SS reload (44 ns). A park with nothing runnable switched to the idle context reloading CR3, and back.

What the switch does now

x86-64

  • Root. Cr3::activate records the value it loads in a per-CPU word; the switch loads a thread's root only when another is loaded. The idle context keeps whatever root it finds: idle code reaches only the kernel half, which every root shares.
    • A CPU idle on a user root stays in that space's shootdown targeting with an idle mark (tlb_tags). A change that frees no table passes it by, and the CPU flushes when a thread of the space resumes there (Tags::resume, paired with Tags::changed by the same SeqCst-fence pattern as enter; only a CPU back from idle pays it). A change that frees a table reaches it, since its walker could reach the freed table, and a CPU answering any shootdown in its idle context loads the kernel's root.
    • Letting a change's frames go past an idle holder is the "delayed invalidation" of SDM Vol. 3A §5.10.4.4: the holder may still read them speculatively. user_change states what makes that sound: SMAP faults every other Ring 0 access to a user page, a freed user frame is only ever mapped write-back again (§14.12.4), and a user mapping that is not write-back is a device window, UC (never read speculatively, §14.3) or the WC scanout. A non-write-back mapping of pool frames would break a stated condition.
    • A space is never freed while a CPU holds its root. A thread leaving its process (process::leave_as) gives its own context the kernel's root before loading it (sched::driver::leave_address_space), so a dying thread switched out and back in before its exit pass never loads the space again. Once every thread has left, the last one out retires the space (Space::retire → tlb::retire): every CPU still idle on its root is reached and loads the kernel's before the teardown goes on, so the space's drop waits on no CPU. tlb::retire asserts its post-condition and UserTables' drop asserts that no CPU holds the tag. activate runs masked, so no answer finds a CPU between two roots.
  • FPU. Every switch away from a user thread still saves its state; nothing is deferred to a #NM trap, so Ring 3 only ever holds its own state (LazyFP, CVE-2018-3665). The restore is skipped when this CPU's registers still hold the thread's state: the per-CPU holder names the thread's context and the thread's own last switch-out was on this CPU. This keeps arch::fpu's XSAVEOPT/XSAVES invariant: a resume without a restore follows a save on the same CPU, so no save elsewhere wrote the area in between (SDM Vol. 1 §13.6). Closes issues/a-thread-resuming-on-the-cpu-that-holds-its-state-restores-it-again.md.
  • FS base. Still read back at every switch-out of a user thread, since Ring 3 moves it with a segment load (SDM Vol. 2B, MOV) and SYS_SET_THREAD_POINTER writes it; written at a switch-in only when the CPU's cached value differs. The cache starts at 0, the base load_gdt's mov fs, KERNEL_DS leaves (SDM Vol. 3A §3.4.4), and is exact at every switch-in. CR4.FSGSBASE stays forbidden.
  • SS. Reloaded only on a part whose SYSRET keeps SS's hidden descriptor, which is every part but Intel's (SDM Vol. 2B SYSRET writes it whole; AMD APM Vol. 3 SYSRET sets the selector alone), and only when SS is not already the kernel's data selector (an IDT entry from Ring 3 leaves it null, SDM Vol. 3A §7.14.4). The sysret_ss_reload metal row reads which part the boot ran on from the PCI host bridge's vendor, independently of the kernel's CPUID reading. The AMD APM itself was not fetched: amd.com ends the stream for the build's user agent (r2/apm-fetch.log); the reviewer's reading of APM Vol. 3 (24594), SYSRET, agrees, and the fact only decides that non-Intel parts keep the reload.

AArch64 (shared code only, no arm-only work): the switch skips a TTBR0_EL1 write identical to what it holds; its idle context still loads the kernel's empty root, since nothing there takes a dropping space's root off an idle CPU. leave_address_space applies there too.

Test. tlb_shootdown_waits' third part claimed that a change in a space no other CPU runs reaches none. A change that frees a table now rightly reaches a CPU idle on the space's root, which the main thread's parks in its joins can leave behind, so the part releases the mapping's middle page, whose table the rest of the mapping keeps.

Decisions and their alternatives

  • The idle context keeps a user root rather than loading the kernel's: it costs a full-reach shootdown only where tables are freed or the space ends, and every other shootdown (kernel half, PCID reclaim, Every kernel stack sits above unmapped memory, and an overflow names its stack #1211's Stack and Guard) makes an idle CPU drop its root as it answers.
  • Eager FPU save stays; only the restore is skipped. A deferred save would let a thread run with another's state in its registers.
  • No manual change: the manual states no switch mechanism, and none of its requirements moves.

Found on the way, filed, not fixed

issues/ring-3-replaces-the-kernels-gs-base-with-a-segment-load.md: Ring 3 can load GS with a DPL-3 selector, replacing the per-CPU GS base the no-swapgs entry addresses first.

Checks and evidence

Head 421f84d: 9b7d6d9's code, then review r5's comment fixes and two records-only commits. 9b7d6d9 is rebased onto main 36ed75b (past #1266, #1259, #1243, #1239, #1263 and #1267). Round 5 there: the 10 kernel clippy shapes and the 2 loom-crate shapes exit 0, cargo test -p kernel --lib 485 passed, cargo check --test toyos-build exit 0, loom tlb_tags and its controls as below, and cargo run -- --ci host models exit 0 (89 steps, all green, #1266's share tests and #1267's shootdown controls among them): https://github.com/ToyOSOrg/evidence/tree/358f169f8661121911a2e20aed96a31f7c7e7901/r5.

Round 5's conflicts: kernel/pure/sched/task.rs (#1266's outrun_request beside ctx_mut; cpu.rs auto-merged with running_ctx), kernel/src/arch/x86_64/tlb.rs (#1267's asks beside the idle holder: user_change keeps its tables argument and asks each reached CPU, so a table-freeing change asks an idle holder for the whole user range, which its answer flushes whole; answer_ask leaves an idle CPU's space as answer does) and the TLB issue record (both accounts kept).

Round 4 (fa9f2e5, onto 8188095) resolved kernel/src/arch/x86_64/boot.rs (#1244's copies_go_down arm kept beside the vendor read once), kernel/src/invalidation.rs (#1241 removed Stop; Retire stays beside #1211's Guard and Stack) and kernel/src/sched/driver.rs (#1246's current_handles beside leave_address_space). One break had no textual conflict: #1246's teardown_resources no longer holds the process data's guard where the retire used it, so the retire now runs on a clone of the space's Arc and holds no process-data lock across its shootdown, only the space's own, as an unmap does (folded into the first commit).

Round 3 (review r2's FS-base fix, a99cd8c): x86 kernel clippy shapes, https://github.com/ToyOSOrg/evidence/tree/3224156f92a772d52fc9559a5002d3bead4800d9/r3.

Round 2, at f06faad on main 6d4b7e4 (rebased past #1211, #1238, #1131). Commands, exit codes and logs: https://github.com/ToyOSOrg/evidence/tree/1a08e6bb6978c796427b929bb6c9bb764db0a851/r2 (round 1: r1/ there).

Host (targeted, per the owner's testing policy):

  • cargo clippy -p kernel for x86_64 (default; boot-actuators,test-actuators; boot-actuators; the instrument set) and AArch64 (the same four), the kernel library (sched-check; with the kernel's controls) and kernel-loom (default; both tlb_tags controls), all with the adopted lints and -D warnings: exit 0 each.
  • cargo test -p kernel --lib sched::: 197 passed, exit 0.
  • cargo test --manifest-path kernel/loom/Cargo.toml --test tlb_tags: exit 0, both models. Negative controls: --features tlb-tags-resume-relaxed exit 101 with only the idle holder's model red; --features tlb-tags-enter-relaxed exit 101 with only the entering model red.

Guest at 9b7d6d9 (the head since adds comments and records only): run 43125192691. Seven rows green: the x86 rows vector_state_survives_a_switch (16 away-and-back trips, the FPU skip's guest check), std_threads_count_the_cpus_and_give_back_ended_threads and libc_children (thread and process ends, so leave_address_space and the retire), affinity_pins_hold_on_every_cpu (migration), and the AArch64 rows virt_fp_isolation virt_smp virt_jobs_at_el2. virt_mask_windows red with the signature of issues/virt-mask-windows-finds-a-mask-with-no-hook-in-a-lower-el-data-abort.md, red on main before this branch; recorded there as another sighting.

Metal: both benches are down, so this lands on review, host checks and guest rows. The speed-up is not yet measured on metal. Owed on metal, recorded in issues/toyos-uses-what-modern-hardware-offers-for-speed.md and issues/a-tlb-shootdown-reaches-only-the-cpus-that-ran-the-space.md:

  • On the T14 and the AMD bench, main's testcases boot green, vector_state reporting its 16 trips; main's shared-debug boot with sysret_ss_reload judging "not needed" on the T14 (host bridge 8086) and "reloaded" on the AMD bench (1022).
  • On the T14, tlb_shootdown_waits in shared-debug green, its third part counting zero CPUs reached. On both benches, no testcases, shared-debug or testcases-watchdog boot ending on either retire assert.
  • On the T14, the restore skip's two negative controls (patches), each red through vector_state for its own reason: ignoring where the thread last left its state, "held the file it had left there"; ignoring the CPU's holder (the deleted issue's own mutant), "changed while it was switched out".
  • osbench of main before this PR against main after it, four boots a side in a counterbalanced order, on both benches. The switch-bound rows (pingpong.futex.same_cpu, pingpong.pipe.same_cpu, ipc.reqrep.*, wake.onto_busy, time.yield) measure the four skips together and stand for the deleted issue's "cost of a switch with and without the skip". No row may be slower beyond its spread.

The images staged for these at f06faad are deleted; the request that listed them is in r2/stage/ on the evidence branch.

Not tested by a row: the dying thread switched back in after its process's teardown. No test forces that interleaving; after leave_address_space the thread's context names the kernel's root, and the switch loads nothing but incoming.root.

Risks and coordination

🤖 Generated with Claude Code

Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 11, 2026
…134d3e9199de6: x86 guest rows, reused checks, metal staging; masked
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1251, round 1, at e63e985 (base b566931)

What I read: the three commits, the whole diff, and every changed file. I also read the callers and boundaries the change relies on:

  • process::leave_as and teardown_resources, and proclife::leave
  • SchedPass::switch_to_current and switch_to_idle, and the Box-backed contexts
  • every shoot_down caller in mm::space, and Unlinked
  • the AP bring-up order
  • sys_set_thread_pointer
  • Cr3::activate, load_flush and flush_tlb_user
  • the tlb_shootdown_waits guest test

I also read the evidence at bcf4626a3 r1 and the brief, #1211 as merged, and #1241 at 954b57d. I checked the SDM text in the implementer's own docs-dl/sdm3.pdf.

Architecture

Each piece has the right owner:

  • tlb_tags holds the targeting state: the idle mark and the resume pairing.
  • arch::x86_64::tlb holds the shootdown, and the idle holder giving up the root.
  • x86 per-CPU data holds the CR3, FS and FPU caches.
  • Generic code gains only leave_address_space, Space::retire and a per-arch fpu::Left.

The idle context keeping the root follows the established lazy-TLB design. A change to leaves only passes an idle holder by. A change that frees a table reaches it. A retire reaches every holder before the space goes.

The FPU skip is the standard test: a per-CPU holder plus the CPU the thread last left from. Saves stay eager, so nothing reintroduces LazyFP.

Size: production +377/−60, tests +223/−12, issues +45/−41. Most of the growth is the idle-holder targeting, the fpu::leave/resume pair and the per-CPU caches.

No manual change is needed: docs/ describes no switch, TLB or FPU mechanism (I searched).

Required

  1. kernel/src/arch/x86_64/hw.rs:366-367: wrong SDM section.

    • The comment cites "SDM Vol. 3A §7.14.2, 'Stack Switching in IA-32e Mode'". So do commit 9b9fdb7 and the PR body.
    • In the SDM copy under docs-dl, §7.14.2 is "64-Bit Mode Stack Frame". "Stack Switching in IA-32e Mode" is §7.14.4 (§6.14.4 in editions before the renumbering). That section, with §7.14.5 for IST, is where SS is forced to NULL.
    • Cite §7.14.4. Correcting the PR body is the orchestrator's job.
  2. kernel/src/arch/x86_64/percpu.rs:121-122: the FS-base cache's contract is false.

    • The comment says the word is "what it holds while no Ring 3 code has run since". But sys_set_thread_pointer (syscall/proc.rs:265) writes IA32_FS_BASE in Ring 0 without touching this word. The word is stale from that syscall until the thread's switch-out.
    • What is true, and what the skip relies on: the word is exact at every switch-in, because every switch away from a user thread reads the MSR back.
    • Say that. Otherwise a reader outside the switch could trust a stale value.
  3. kernel/src/tlb_tags.rs:8-13 and kernel/src/arch/x86_64/tlb.rs:105-109: the hardware condition for passing an idle holder by is unstated.

    • A change that frees no table now returns, and its frames are reallocated, while an idle holder still has translations to them under its current PCID.
    • SDM Vol. 3A "Delayed Invalidation" (§5.10.4.4 in the copy under docs-dl) says such frames "should not be reallocated for another purpose until the appropriate invalidations have been performed". It also says speculative accesses can occur through stale translations, and that invalidation should not be delayed for a range with read side effects.
    • The design holds only for these reasons:
      • SMAP faults any Ring 0 access to a user page outside a user copy, and the idle context makes no user copy.
      • A frame that a user change frees is RAM, and it is only ever mapped write-back again. A speculative read through the stale translation therefore creates no memory-type alias ("Programming the PAT", §14.12.4).
      • A user mapping that is not write-back is a device window. Today that is either UC, which nothing reads speculatively (§14.3), or the WC scanout, which has no read side effects.
    • The phrase "uses no user translation" covers only architectural use.
    • Put these conditions, with the citation, in two or three lines at Tags::changed or user_change. Then the first WC/UC mapping of pool frames will visibly break a stated condition instead of breaking it silently.
  4. Rebase, and take the queued evidence on the rebased head.

Suggestions

  • tlb_shootdown_waits part 3 may now fail.
    • Its claim, "a space no other CPU runs waits for none", now has an exception: a munmap that frees a table reaches a CPU idling on the root.
    • After spinners.stop(), every spinner's CPU is on the kernel root, because each spinner's exit ran leave_address_space.
    • The main thread, though, parks in each join, and its CPU idles on the root. If main is woken onto another CPU, part 3's 2 MiB release would increment SPACE_REACHED. That release unlinks its table(s) unless neighbouring mappings keep them.
    • Watch the shared-debug readback. If it fails, fix the test rather than the design: either the test's claim names the exception, or part 3 releases a range that frees no table.
  • tlb.rs:14-19 restates tlb_tags.rs:8-13. Keep in tlb.rs only what it owns: an idle holder's answer loads the kernel's root, and retire. Leave the targeting rule to tlb_tags.
  • The AMD APM was not actually read.
    • docs-dl/apm3.pdf is a 2,613-byte HTML page, not the APM.
    • My reading of APM Vol. 3 (24594), SYSRET, agrees with the claim: SS's selector is loaded, and its base, limit and attributes are unchanged.
    • The fact only decides keeping a conservative reload on parts that are not Intel's. Still, record the APM revision and page in the evidence. I could not download it from here either.
  • fpu.rs:15-17 describes kernel threads that do not exist. It says "A kernel thread never enters Ring 3 ... its switches neither save nor restore", but Runs has only Idle and User. The sentence sits in the paragraph this PR rewrote; deleting it costs one line.
  • The FS-base sentinel and the swapgs work.
    • percpu.rs:392 justifies u64::MAX as a sentinel by "zero or a user-half address". That holds only while sys_set_thread_pointer is the only writer.
    • Once the swapgs entry enables FSGSBASE, WRFSBASE can set any canonical value, including u64::MAX.
    • Seeding the word from the MSR at CPU bring-up removes the sentinel. Not needed now.
  • Optional extra check of the resume flush on metal. Only inspection shows that the switch calls tlb::resume. A T14 row could leave a translation on an idle holder, remap the page from another CPU, then resume. Run with the call removed, it would show whether the T14 keeps translations across its idle states. If deep C-states drop them, such a row cannot be built, and that is worth recording.

The brief's questions

CR3: correct.

  • percpu::root has one writer: Cr3::activate, which runs masked, inside switching. load_flush runs only at boot, before a CPU's first activate, while the word is still u64::MAX, which matches no real root. flush_tlb_user reloads only the value CR3 already holds.
  • A change that passes an idle holder by is caught by the fence in Tags::resume pairing with the one in changed. This is the same store-buffer argument as enter.
  • A change that frees a table reaches every holder.
  • Every machine-wide shootdown (kernel half, PCID reissue, and after the rebase Stack and Guard) unloads the root as it is answered. So a reissued tag never meets a holder.
  • retire reaches the holders before teardown and asserts that none remains.
  • The switch sets current_tid before calling is_loaded, so a shootdown answered after that cannot pull the root from under a resuming thread. An answer that arrives before it only costs one more CR3 write.
  • Masking activate keeps an answer from finding a CPU in both spaces' bitmaps, which would trip retire's assert.
  • When the CR3 write is skipped, the switch still flushes when the CPU comes back from idle, and only then. A CPU that ran a thread of the space was reached by every change.
  • AP bring-up is safe: an answer before enter_idle_loop reloads the kernel's root after CR4.PCIDE is set, so the NOFLUSH bit cannot fault.

FPU: correct across migration, thread death and slot reuse.

  • A save still happens at every switch-out, dying threads included: their final switch saves through their own Box-backed context.
  • The holder and left are written together in leave, and the skip needs both.
    • left equal to this CPU means the thread's own last leave was here, and that leave set the holder.
    • Any later leave here would have overwritten the holder.
    • Every state loaded onto a CPU belongs to a thread that leaves before another thread resumes there.
  • A recycled context address starts with left = NOWHERE and enters through load_initial.
  • save and restore are now private to leave and resume, which the XSAVEOPT/XSAVES invariant needs.

FS base: correct.

  • The MSR is read back at every switch-out of a user thread, which covers both segment loads and sys_set_thread_pointer.
  • It is written at switch-in only where the cache differs.
  • Nothing else writes it, the trampoline included.

SS: correct.

  • On Intel, SYSRET loads SS's selector and fixed descriptor fields (SDM Vol. 2B, SYSRET). On AMD, SYSRET loads only the selector (APM Vol. 3; see the suggestion above).
  • SS can equal KERNEL_DS only after a load that also wrote SS's hidden part: mov ss or IRET from the GDT, or SYSCALL's fixed values. Skipping the reload in that case is sound.
  • Vendors other than Intel and AMD keep the reload.
  • The metal judge reads the vendor from the PCI host bridge (1022 or 8086). That is independent of the kernel's CPUID reading. The row is metal-only, so q35's 8086 host bridge cannot mislead it.

The dying-thread fix: complete on both architectures.

  • Leave::Last requires every other thread to be marked zombie. Only proclife::leave marks that, and it runs after leave_as has run leave_address_space.
  • So every thread's context names the kernel's root before retire, and no other path loads a user root.
  • On AArch64 the idle context loads the kernel's empty root and retire is a no-op, so the fix is harmless there.
  • Inspection plus the two asserts, which fail loudly if this is ever wrong, is the right evidence. No test can force the interleaving.

Loom: the model and its controls are sound.

  • The idle-holder model is the right shape: the pairing between the idle mark and the stamp, with an assert that names the hazard.
  • tlb-tags-resume-relaxed fails only that model (host/loom-tlb-tags-resume-relaxed.log, exit 101). tlb-tags-enter-relaxed fails only the first model.
  • The model does not cover the idle mark's Release/Acquire, which orders what the CPU did in the space before idling against the change's frees. No control fails without it. x86's total store ordering gives that ordering anyway, so inspection suffices.

Overlaps: none that make the coming work harder.

Evidence checked.

  • The targeted host checks fit the change: 12 clippy shapes, sched:: tests, and loom with both controls.
  • The reused harness check is valid: tests/ and build/ are identical between 3785475 and e63e985.
  • The x86 guest log shows run 43052915315 checked out e63e985 and passed 4 of 4, including "16 trips from CPU 1 to 0 and back".
  • The per-row gain from the A/B is labelled as an inference until it is measured.

What the readbacks must show (after items 1–4)

  1. AArch64 guest run. Run 43052933164 (still queued while I reviewed) must be green, because this PR changes AArch64 code.
  2. Verdict boots. On the T14 and the AMD bench, from the rebased head, testcases, shared-debug and testcases-watchdog must be green, with:
    • vector_state_survives_a_switch passing its 16 trips
    • sysret_ss_reload judging "not needed" on the T14 and "reloaded" on the AMD bench
    • tlb_shootdown_waits green on the T14
    • no tlb: a CPU still holds the root or paging: a space's tables are freed while a CPU still holds its root panic in any boot
  3. FPU negative control. The T14 mutant must fail vector_state with "held the file it had left there".
    • The deleted issue's exit names the other half of the skip: "owner never cleared", which here means dropping the holder check.
    • Reading the code, the workers part of vector_state would catch that mutant. The PR should either say so or run that mutant too.
  4. A/B. The osbench A/B should run in a counterbalanced order, as the brief asks. stage/request.txt and the PR body say strict alternation, base first.
    • Report the switch-bound rows on both benches.
    • These rows measure all four skips together. They stand for the deleted issue's "cost of a switch with and without the skip" only if the PR says so.
    • Investigate any regression.

SEND BACK. Items 1–4 are small: three comment fixes and a rebase. Once they are done, this can land pending the readbacks above.

Japabu added a commit that referenced this pull request Nov 11, 2026
…#MC and #DF install it whatever GS holds

The kernel ran no `swapgs`: GS held this CPU's `PerCpu` from boot on, and
every entry's first stores went through `gs:`. Ring 3 replaces the GS
base with a segment load (`mov gs, 0x1b`: the user data descriptor, base
0), so its next syscall stored near address 0 at CPL 0 on its own stack.
`SYSCALL` switches no stack and `#PF` has no IST, so the fault's frame was
pushed to the stack pointer the program chose, a kernel address if it
chose one, and every handler after it addressed the same base. Found by
reading in #1251; the previous commit's two rows red on that kernel.

Now `IA32_GS_BASE` is Ring 3's own while it runs and the kernel's while
the kernel runs, with `IA32_KERNEL_GS_BASE` the other:

- `SYSCALL` comes only from Ring 3 and swaps first and on its way out.
  The image gate on the shipping `syscall_entry` now requires it to open
  `swapgs`, `cld`, the save and the switch.
- Every other gate swaps when its frame's `CS` is Ring 3's, and back
  before an `iretq` to Ring 3. That decision is sound because the
  kernel's CPL-0 spans with a user's base live (before `SYSCALL`'s
  `swapgs`, and from an exit's `swapgs` to its `SYSRET` or `IRETQ`) run
  with `IF` clear and no instruction that can fault: `SYSRET`'s `rcx` is
  canonical by the user half's placement, and every `iretq` frame is the
  CPU's or a validated entry's. No `#DB` lands there: nothing arms a
  debug register, `IA32_FMASK` clears `TF`, and a single-step trap after
  `MOV SS` is suppressed, not deferred (SDM Vol. 3A §7.8.3).
- NMI, `#MC` and `#DF` can land in those spans and their frame does not
  say so. They take the kernel's base from `GDTR`, whose base is the
  block's own `gdt`, and write it to `IA32_GS_BASE` whatever it held;
  the NMI puts back the base it found before its `iretq`. They decide
  nothing: a test of the found base's sign would hold only while no
  thread can write its own GS base, and `WRGSBASE` writes any canonical
  one, the kernel half's included (`issues/fsgsbase-is-taken.md`).
  `#DF` and `#MC` never return, so any dispatched vector on an IST stack
  goes through the new `fatal_entry`.

Speculation (CVE-2019-1125): the gate's `swapgs` is conditional, so an
`LFENCE` follows the join, before any load through GS, which is Intel's
placement for both a skipped and an extra `swapgs` ("Speculative Behavior
of SWAPGS and Segment Registers", ID 758371). The unconditional
`SYSCALL` swap needs none, the exits load nothing through GS after their
`swapgs` and end in a serializing `IRETQ` or in `SYSRET`, and the IST
entries have no branch before their serializing `WRMSR` (SDM Vol. 3A
§11.3). On AMD the fence orders dispatch only where `LFENCE` is
dispatch-serializing (AMD 70586 G-2, MSR C001_1029[1]), which #1243 sets;
AMD's own statement on SWAPGS was not reachable to read.

The switch moves no GS base: every descriptor Ring 3 may load into GS
has base 0 (asserted at compile time beside the GDT), AMD's null load
keeps the base it found, and `CR4.FSGSBASE` stays forbidden, so each CPU's
`IA32_KERNEL_GS_BASE` starts at 0 and every thread's user GS base is 0.
The switch's save and restore of it belongs with taking FSGSBASE.

AArch64 has no GS and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…ge that frees no table is what reaches no idle CPU

- The SS comment cites SDM Vol. 3A §7.14.4, "Stack Switching in IA-32e
  Mode", where an entry from Ring 3 through an IDT gate forces SS null.
- The cached FS base says what is true: exact at every switch-in, since
  every switch away from a user thread reads the register back, and stale
  while a thread runs, which moves it by a segment load or
  SYS_SET_THREAD_POINTER. It is read from the register at each CPU's
  bring-up instead of starting at a sentinel a later WRFSBASE could
  write.
- `user_change` states what letting frames go past an idle holder rests
  on, against SDM Vol. 3A §5.10.4.4 ("Delayed Invalidation"): SMAP faults
  every other Ring 0 access to a user page, a freed user frame is only
  ever mapped write-back again (§14.12.4), and a user mapping that is not
  write-back is UC (§14.3) or the WC scanout. A non-write-back mapping of
  pool frames breaks a stated condition rather than a silent one.
- `arch::tlb`'s header keeps only what it owns, an idle holder's answer
  and the retire; `tlb_tags` says which changes reach an idle holder.
- `fpu`'s header no longer describes kernel threads, which do not exist.
- `tlb_shootdown_waits`' third part claimed that a change in a space no
  other CPU runs reaches none. A change that frees a table now rightly
  reaches a CPU idle on the space's root, which the main thread's parks
  in its joins can leave behind, so the part releases the mapping's
  middle page, whose table the rest of the mapping keeps, and says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the switch-drops-unneeded-work branch from e63e985 to f06faad Compare November 11, 2026 17:36
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 11, 2026
…d70c42057c3f7: targeted host checks, loom controls, metal staging, the APM fetch; masked
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Response to review r1 (head now f06faad: the three commits rebased onto main 6d4b7e4, past #1211, #1238 and #1131, plus f06faad "Review r1 of #1251").

Required:

  1. hw.rs: cites SDM Vol. 3A §7.14.4. The first commit's message is corrected in the rebase too, and the PR body says §7.14.4.
  2. percpu.rs: the FS-base cache now says it is exact at every switch-in, because every switch away from a user thread reads the register back, and stale while a thread runs. Took the suggestion as well: the cache is read from the register at each CPU's bring-up (init_bsp, init_ap), and the u64::MAX sentinel is gone.
  3. user_change states the delayed-invalidation conditions with SDM Vol. 3A §5.10.4.4, §14.12.4 and §14.3 (checked in the SDM copy): SMAP, freed user frames only ever mapped write-back again, and non-write-back user mappings are UC device windows or the WC scanout.
  4. Rebased. fpu.rs keeps Every kernel stack sits above unmapped memory, and an overflow names its stack #1211's "a fresh kernel stack is zeroed". invalidation.rs carries both Retire and Every kernel stack sits above unmapped memory, and an overflow names its stack #1211's Guard and Stack. Clippy (12 shapes), sched:: and loom with both controls were rerun at f06faad. The x86 rows (43069791737) and AArch64 rows (43069798953) are dispatched at f06faad. The AArch64 run at e63e985 was cancelled unrun. All metal is restaged from f06faad (evidence r2/).

Suggestions taken:

  • tlb_shootdown_waits part 3 is fixed now rather than after a red. It releases the mapping's middle page, whose table the rest of the mapping keeps, and its claim names the exception. The issue record says the same.
  • tlb.rs's header keeps only what it owns: an idle holder's answer and the retire.
  • fpu.rs's kernel-thread sentence is deleted.
  • AMD APM: amd.com ends the stream for the build's user agent at both known paths (r2/apm-fetch.log). It stays unread here; the PR body says so.
  • FPU controls: both mutants are staged for the T14. One drops the holder check (the deleted issue's own mutant, caught by the workers). The other drops the left check (caught by the trips). Each kernel differs from the head's (r2/stage/slotfiles.txt).
  • The A/B order is B H H B H B B H on both benches.

Not taken: the optional metal row for the resume flush. Whether the T14 keeps translations across its idle states is worth knowing, but it needs a new actuator row; it is recorded as a follow-up in the handoff.

#1241: if it lands first, the rebase checks that a retire can't target a CPU the stop has taken out of answering.

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1251, round 2, at f06faad (base 6d4b7e4)

Scope: whether round 1's four required findings are resolved, whether the code changed for them is correct, and whether the rebase's conflict resolutions are correct. I read f06faad in full and ran git range-diff b56693152..e63e985c2 6d4b7e4ce..1a10c3438. I also read the callers each fix depends on:

I checked the SDM citations against the copy under docs-dl (bookmarks and page text).

Architecture

Nothing structural changed since round 1, and the rebase did not change any ownership.

#1211's Stack and Guard flushes go through the machine-wide shootdown. answer runs leave_idle_space for every origin, so those flushes also take idle holders off a user root.

The FPU skip still keys on the context plus the per-thread left. A reused stack slot, and therefore a reused area address, starts at Left::NOWHERE, so #1211's slot reuse cannot make a resume skip a restore.

The branch still merges cleanly with current main (6d0cb3a, #1234). The only file both sides touch is build/ci.rs, in different tables.

Round 1's required findings

  1. SDM section: resolved. hw.rs:367 cites §7.14.4 "Stack Switching in IA-32e Mode", which is that section's number in the docs-dl copy. The first commit's message carries the same citation (range-diff), and so does the PR body.
  2. FS-base contract: the comment is resolved, but the bring-up read added with it is wrong. See the blocker below.
  3. Delayed-invalidation conditions: resolved, true and complete. tlb.rs:109-115:
    • SMAP is required: control_regs refuses a CPU without it, and clac is #UD otherwise.
    • Pool frames are only ever CachePolicy::Normal: SharedMemObject::create and create_run hard-code it, and sub inherits it.
    • Every non-write-back user mapping is an aperture over a device window: the UC BAR objects (pcidev::bar_object) or the WC scanout (device.rs:218). The OpRegion aperture is firmware memory mapped Normal.
    • Uncacheable selects PAT entry 3, which is strong UC under every MTRR type.
    • In the docs-dl copy, §5.10.4.4 is "Delayed Invalidation" and §14.12.4 is "Programming the PAT". §14.3 says of UC: "No speculative memory accesses, page-table walks, or prefetches of speculated branch targets are made."
  4. Rebase: resolved.

Suggestions that were taken

  • tlb_shootdown_waits part 3 is sound.
    • unlink frees a table only where no inventory range still needed its span. The middle 4 KiB release leaves both neighbouring ranges in the inventory, so tables is false, whether or not the neighbours were ever faulted in.
    • The assertion now checks the design's own claim: a change that frees no table passes even an idle holder by.
    • A broadcasting user_change still raises SPACE_REACHED deterministically.
    • The only property dropped was "a table-freeing change reaches no idle holder". The new design makes that false, so dropping it hides no regression. No userland observer could check the efficiency property that replaces it (reaching only the holders), because holders exist only after the main thread migrates.
    • The row is metal-only (shared-debug), so the readback is its check.
  • The tlb.rs header now keeps only what it owns. The fpu.rs kernel-thread sentence is gone, and no other mention of kernel threads remains in the changed files.
  • The mutant patches (r2/patches/) each remove exactly one half of the resume condition, and both are staged.

Required

BLOCKER: kernel/src/arch/x86_64/percpu.rs:559 and :607, the FS-base cache is read before load_gdt rewrites the register.

  • What happens. Both init_bsp and init_ap read IA32_FS_BASE and then call load_gdt, which runs mov fs, KERNEL_DS (percpu.rs:185).
  • Why that changes the register. SDM Vol. 3A §3.4.4: "Normal segment loads (MOV to Sreg and POP Sreg) into FS and GS load a standard 32-bit base value in the hidden portion of the segment register. The base address bits above the standard 32 bits are cleared to 0." KERNEL_DS's descriptor (0x00CF_9200_0000_FFFF) has base 0, so after load_gdt the register holds 0 whatever was read. The comment at percpu.rs:186 relies on the same fact for GS, and so does the PR's own read-back ("Ring 3 moves it with a segment load").
  • APs are safe only by accident. The trampoline already loaded FS with 0x10 (smp.rs:371), so the stale read is 0 anyway.
  • The BSP reads whatever the firmware left. UEFI does not guarantee FS.base = 0. Every CPU's first thread is a user thread. If that thread's thread_pointer equals the firmware's leftover base, the switch-in skips the write, and the thread runs with FS.base 0.
  • Consequence. The new field comment's "exact at every switch-in" is false from bring-up to the first write. This is the exact case the sentinel used to cover. The defect is latent and needs a coincidence to show, but the invariant the skip depends on is not established.
  • Fix: either of these.
    • Read the register after load_gdt in both functions. In init_bsp, the &mut then lives until the GS wrmsr, so update that SAFETY comment.
    • Or drop both reads, and initialise fs_base: 0 in alloc_percpu because load_gdt loads FS with KERNEL_DS, whose base is 0.
  • Evidence after the fix. An x86 clippy compile is enough. The fixed kernel differs from f06faad only in the value the cache holds from bring-up to the first switch-in. On every bench whose firmware leaves FS.base at 0 the two kernels behave identically, and in no case could the f06faad boots expose the defect. So the queued guest runs and the staged metal remain evidence for everything else, and no restaging is needed.
  • PR body. Its "read from the register at each CPU's bring-up and is exact at every switch-in" should follow the fix. That correction is the orchestrator's.

Suggestions

  • tlb.rs:7 and :113 give two numbers for one section. Both say "SDM Vol. 3A", but the header cites "Programming the PAT" as §11.12.4 and the new user_change paragraph cites it as §14.12.4. The same goes for §4.10.4.1 in the header against the new §5.10.4.4. The new numbers are correct for the edition apic.rs names (325384-093US) and the docs-dl copy, so a reader with either edition will find one of the two wrong. Name the edition in the new paragraph, or move the header's two citations in this file to the same numbering.
  • tlb.rs:111-112: "SMAP faults every other Ring 0 access to a user page" is literally untrue of user copies, which stac opens. The paragraph is about the idle CPU, which makes no copy (uaccess's contract is "the running process's user half"), so the sentence is sound in context. "every other access it makes to a user page" would say exactly that.

What the pending runs must show

  1. Guest x86 run 43069791737 (queued at f06faad) must be green on all four rows, with vector_state_survives_a_switch reporting its 16 trips.

  2. Guest AArch64 run 43069798953 (queued at f06faad) must be green, because this PR changes the AArch64 switch and paging.rs.

  3. Verdict boots on the T14 and the AMD bench: testcases, shared-debug and testcases-watchdog, all green, with:

    • vector_state passing its trips
    • sysret_ss_reload judging "not needed" on the T14 (host bridge 8086) and "reloaded" on the AMD bench (1022)
    • tlb_shootdown_waits green in the T14's shared-debug, including part 3's zero count
    • neither tlb: a CPU still holds the root… nor paging: a space's tables are freed while a CPU still holds its root in any boot
  4. T14 mutants. Each must end red, through vector_state, for its own reason:

    • mutant-left: "held the file it had left there"
    • mutant-holder: "changed while it was switched out"

    A red from anything else, such as a boot or harness failure, is not a control.

  5. A/B on both benches, in the order B H H B H B B H. Report the switch-bound rows, labelled as the four skips together. Investigate any regression.

SEND BACK. One blocker remains: move or replace the FS-base bring-up read. After that, with a compile check, this can land, provided the runs above show what they must.

Japabu added a commit that referenced this pull request Nov 11, 2026
…t's segment load leaves

`init_bsp` and `init_ap` read `IA32_FS_BASE` before `load_gdt`, whose
`mov fs, KERNEL_DS` loads the descriptor's zero base and clears the upper
half (SDM Vol. 3A §3.4.4). The boot CPU's cache could then hold the
firmware's leftover base while the register held 0, and a first thread
whose thread pointer matched it would run with FS.base 0. The cache now
starts at 0 for that reason, and neither function reads the register.

`arch::tlb`'s SDM citations are numbered as one edition, order
325384-093US, and the idle holder's SMAP condition names the accesses it
makes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 11, 2026
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Response to review r2. Head is now a99cd8c, "Review r2 of #1251".

  • Blocker: I took the simpler of the two fixes. init_bsp and init_ap no longer read the register. alloc_percpu starts the cache at 0, with the reason beside it: load_gdt loads FS with KERNEL_DS, whose base is 0, and a segment load clears the base's upper half (SDM Vol. 3A §3.4.4; checked in the docs-dl copy). The field comment now says "as load_gdt left it or this CPU's last switch read or wrote it". init_bsp's &mut comment needs no change, because the added line before load_gdt is gone.
  • tlb.rs cites one edition: the header's §11.12.4 and §4.10.4.1 are now §14.12.4 and §5.10.4.1, and the header names order 325384-093US once. The SMAP condition now reads "every other access it makes to a user page".
  • Checks: the four x86 kernel clippy shapes at a99cd8c, clean tree, exit 0 each (evidence r3/). Per the review, nothing is restaged, and the queued guest runs at f06faad stand. The PR body's bring-up sentence is updated.

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1251, round 3, at a99cd8c (from f06faad)

Scope: round 2's single blocker (the FS-base cache read before load_gdt) and the tlb.rs citation and wording edits. git diff f06faad8a a99cd8cb5 touches only percpu.rs and tlb.rs, with 8 lines added and 9 removed. I read both files at the head, plus the AP trampoline and ap_entry, the x86 switch, every FS writer in the kernel, and the r3 evidence. I checked the SDM citations against the docs-dl copy, whose title page reads "Order Number: 325384-093US".

Architecture

Nothing structural changed. The fix removes the bring-up rdmsr and starts the cache at a value the architecture defines, so a CPU no longer reads state from before load_gdt. This is simpler than reading the register after load_gdt.

Round 2's blocker: resolved

The register holds 0 after load_gdt on every CPU.

  • load_gdt runs mov fs, KERNEL_DS (percpu.rs:185). KERNEL_DS is 0x10, which is GDT_ENTRIES[2] = 0x00CF_9200_0000_FFFF, base 0.
  • SDM Vol. 3A §3.4.4 "Segment Loading Instructions in IA-32e Mode", in the 093US copy, says: "Normal segment loads (MOV to Sreg and POP Sreg) into FS and GS load a standard 32-bit base value in the hidden portion of the segment register. The base address bits above the standard 32 bits are cleared to 0." The same section says "The hidden descriptor register fields for FS.base and GS.base are physically mapped to MSRs", so IA32_FS_BASE reads 0.
  • AMD is an inference I did not check against the APM text, because I could not render the local Vol. 2 copy. AMD's known difference concerns loads of a null selector, and 0x10 is not null.

BSP. init_bsp (percpu.rs:554) runs alloc_percpu (cache 0) and then load_gdt. gs: becomes valid only at the MSR_GS_BASE write after load_gdt (:567). So nothing can read the cache while it disagrees with the firmware's leftover base.

APs. The trampoline loads FS with 0x10 (smp.rs:371). ap_entry (smp.rs:255) then runs init_paging, microcode::load, pat, load_kernel_flush and init_ap, which calls load_gdt (percpu.rs:608). After that come syscall::init, apic::init_ap, clocksource::start_ap, mtrr::compare, the roster echo and ap_idle. None of these writes FS.

No FS write between load_gdt and a CPU's first switch-in.

  • cpu::write_fs_base has two callers:
    • the switch-in (hw.rs:353);
    • write_thread_pointer, which only sys_set_thread_pointer calls, from a user thread that has already been switched in.
  • The only mov fs instructions are percpu.rs:185 and smp.rs:371.
  • No other wrmsr reaches 0xC000_0100. The writers with a computed MSR number are the x2APIC registers and the PMU counters.
  • CR4.FSGSBASE is forbidden, so the kernel has no wrfsbase.
  • The kernel makes no UEFI runtime-service calls and has no S3 resume path.
  • sysret_ss_probe touches only SS.

The cache is exact at the first switch-in and afterwards.

  • percpu::fs_base() has one reader, hw.rs:352.
  • Runs is only Idle or User, so a CPU's first switch-in that compares the cache is a user thread's, and it comes after that CPU's load_gdt.
  • The idle context neither reads back nor moves FS. Every switch away from a user thread reads the register back (hw.rs:321-323).

Round 2's evidence still applies.

  • Against f06faad, the register outcome differs in one case only: the boot CPU's firmware leaves a base L ≠ 0, and the first thread's pointer equals L. There f06faad wrongly skipped the write.
  • When the first thread's pointer is 0, f06faad wrote 0 redundantly and a99cd8c skips the write, which leaves the same register.
  • The tlb.rs changes are comments only.
  • So the queued guest runs and the staged metal at f06faad stand, as round 2 said.

Comments and citations

  • percpu.rs:394-395: true and concise. It states the one fact that makes 0 correct.

  • percpu.rs:123-126: the field comment is true. "As load_gdt left it or this CPU's last switch read or wrote it" covers the bring-up value, and the argument for "exact at every switch-in" now holds from the first switch-in.

  • tlb.rs:7: the header now names 325384-093US. The 093US copy confirms every SDM section the file cites:

    • §14.12.4 "Programming the PAT"
    • §5.10.4.1 "Operations that Invalidate TLBs and Paging-Structure Caches"
    • §5.10.4.4 "Delayed Invalidation"
    • §14.3 "Methods of Caching Available", whose UC entry says "No speculative memory accesses, page-table walks, or prefetches of speculated branch targets are made"

    The file cites no other SDM section, so the header's edition statement is accurate.

Evidence

  • r3/rev.txt records a99cd8c with a clean tree. All four x86 kernel shapes from build/clippy.rs exit 0 (r3/summary.txt).
  • The default shape's log shows a fresh fingerprint and no Checking kernel line. That is valid on a clean tree, because cargo rechecks after any source change.
  • The two heap-band x86 shapes were not run, but they change no cfg in either touched file.
  • The branch still merges cleanly with current main, 45935f4 (A stalled job is named while it stalls, ends libc_children, and a suite the driver ends leaves no scratch #1229). The only file both sides touch is still build/ci.rs.
  • The PR body's FS-base paragraph and its evidence line now describe a99cd8c accurately.

Suggestions

  • percpu.rs:688: the accessor's doc, "IA32_FS_BASE as of this CPU's last switch", leaves out the bring-up value that the field comment now names, and at a CPU's first switch-in there was no last switch. Either drop the first clause, leaving the field to own the description, or match the field's wording.
  • tlb.rs:111-112: this wording was round 2's own suggestion and is imprecise. SMAP faults supervisor data accesses only. A fetch from a user page is SMEP's fault, and control_regs requires both bits. "SMAP and SMEP fault every other access it makes to a user page" would say it exactly.

Still pending, as round 2 listed

  • Guest x86 run 43069791737 and guest AArch64 run 43069798953 were both still queued at f06faad when I checked.
  • After them come the verdict boots on the T14 and the AMD bench, the two T14 mutants (each red through vector_state for its own reason), and the A/B, all with the outcomes round 2 listed.

LAND, pending the guest runs and metal runs above.

Japabu added a commit that referenced this pull request Nov 11, 2026
Both benches are down, so the switch's skips land on review, host checks
and guest rows. What only metal can show is recorded where its subject
lives: the idle holder's `tlb_shootdown_waits` and retire asserts in the
TLB record, and in the hardware-speed record `vector_state`'s trips and
`sysret_ss_reload` on both benches, the restore skip's two negative
controls on the T14, and the osbench A/B of main before and after. The
speed-up is unmeasured on metal, and the record says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…#MC and #DF install it whatever GS holds

The kernel ran no `swapgs`: GS held this CPU's `PerCpu` from boot on, and
every entry's first stores went through `gs:`. Ring 3 replaces the GS
base with a segment load (`mov gs, 0x1b`: the user data descriptor, base
0), so its next syscall stored near address 0 at CPL 0 on its own stack.
`SYSCALL` switches no stack and `#PF` has no IST, so the fault's frame was
pushed to the stack pointer the program chose, a kernel address if it
chose one, and every handler after it addressed the same base. Found by
reading in #1251; the previous commit's two rows red on that kernel.

Now `IA32_GS_BASE` is Ring 3's own while it runs and the kernel's while
the kernel runs, with `IA32_KERNEL_GS_BASE` the other:

- `SYSCALL` comes only from Ring 3 and swaps first and on its way out.
  The image gate on the shipping `syscall_entry` now requires it to open
  `swapgs`, `cld`, the save and the switch.
- Every other gate swaps when its frame's `CS` is Ring 3's, and back
  before an `iretq` to Ring 3. That decision is sound because the
  kernel's CPL-0 spans with a user's base live (before `SYSCALL`'s
  `swapgs`, and from an exit's `swapgs` to its `SYSRET` or `IRETQ`) run
  with `IF` clear and no instruction that can fault: `SYSRET`'s `rcx` is
  canonical by the user half's placement, and every `iretq` frame is the
  CPU's or a validated entry's. No `#DB` lands there: nothing arms a
  debug register, `IA32_FMASK` clears `TF`, and a single-step trap after
  `MOV SS` is suppressed, not deferred (SDM Vol. 3A §7.8.3).
- NMI, `#MC` and `#DF` can land in those spans and their frame does not
  say so. They take the kernel's base from `GDTR`, whose base is the
  block's own `gdt`, and write it to `IA32_GS_BASE` whatever it held;
  the NMI puts back the base it found before its `iretq`. They decide
  nothing: a test of the found base's sign would hold only while no
  thread can write its own GS base, and `WRGSBASE` writes any canonical
  one, the kernel half's included (`issues/fsgsbase-is-taken.md`).
  `#DF` and `#MC` never return, so any dispatched vector on an IST stack
  goes through the new `fatal_entry`.

Speculation (CVE-2019-1125): the gate's `swapgs` is conditional, so an
`LFENCE` follows the join, before any load through GS, which is Intel's
placement for both a skipped and an extra `swapgs` ("Speculative Behavior
of SWAPGS and Segment Registers", ID 758371). The unconditional
`SYSCALL` swap needs none, the exits load nothing through GS after their
`swapgs` and end in a serializing `IRETQ` or in `SYSRET`, and the IST
entries have no branch before their serializing `WRMSR` (SDM Vol. 3A
§11.3). On AMD the fence orders dispatch only where `LFENCE` is
dispatch-serializing (AMD 70586 G-2, MSR C001_1029[1]), which #1243 sets;
AMD's own statement on SWAPGS was not reachable to read.

The switch moves no GS base: every descriptor Ring 3 may load into GS
has base 0 (asserted at compile time beside the GDT), AMD's null load
keeps the base it found, and `CR4.FSGSBASE` stays forbidden, so each CPU's
`IA32_KERNEL_GS_BASE` starts at 0 and every thread's user GS base is 0.
The switch's save and restore of it belongs with taking FSGSBASE.

AArch64 has no GS and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…ge that frees no table is what reaches no idle CPU

- The SS comment cites SDM Vol. 3A §7.14.4, "Stack Switching in IA-32e
  Mode", where an entry from Ring 3 through an IDT gate forces SS null.
- The cached FS base says what is true: exact at every switch-in, since
  every switch away from a user thread reads the register back, and stale
  while a thread runs, which moves it by a segment load or
  SYS_SET_THREAD_POINTER. It is read from the register at each CPU's
  bring-up instead of starting at a sentinel a later WRFSBASE could
  write.
- `user_change` states what letting frames go past an idle holder rests
  on, against SDM Vol. 3A §5.10.4.4 ("Delayed Invalidation"): SMAP faults
  every other Ring 0 access to a user page, a freed user frame is only
  ever mapped write-back again (§14.12.4), and a user mapping that is not
  write-back is UC (§14.3) or the WC scanout. A non-write-back mapping of
  pool frames breaks a stated condition rather than a silent one.
- `arch::tlb`'s header keeps only what it owns, an idle holder's answer
  and the retire; `tlb_tags` says which changes reach an idle holder.
- `fpu`'s header no longer describes kernel threads, which do not exist.
- `tlb_shootdown_waits`' third part claimed that a change in a space no
  other CPU runs reaches none. A change that frees a table now rightly
  reaches a CPU idle on the space's root, which the main thread's parks
  in its joins can leave behind, so the part releases the mapping's
  middle page, whose table the rest of the mapping keeps, and says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…t's segment load leaves

`init_bsp` and `init_ap` read `IA32_FS_BASE` before `load_gdt`, whose
`mov fs, KERNEL_DS` loads the descriptor's zero base and clears the upper
half (SDM Vol. 3A §3.4.4). The boot CPU's cache could then hold the
firmware's leftover base while the register held 0, and a first thread
whose thread pointer matched it would run with FS.base 0. The cache now
starts at 0 for that reason, and neither function reads the register.

`arch::tlb`'s SDM citations are numbered as one edition, order
325384-093US, and the idle holder's SMAP condition names the accesses it
makes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
Both benches are down, so the switch's skips land on review, host checks
and guest rows. What only metal can show is recorded where its subject
lives: the idle holder's `tlb_shootdown_waits` and retire asserts in the
TLB record, and in the hardware-speed record `vector_state`'s trips and
`sysret_ss_reload` on both benches, the restore skip's two negative
controls on the T14, and the osbench A/B of main before and after. The
speed-up is unmeasured on metal, and the record says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the switch-drops-unneeded-work branch from 15a11eb to fa9f2e5 Compare November 11, 2026 22:33
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 11, 2026
…0e820558248d5: rebase checks, models; masked
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main 8188095. The head is now fa9f2e5; its six commits are the old ones carried over.

Conflicts:

One break produced no textual conflict. #1246's teardown_resources no longer holds the process data's guard at the point where the retire called data.space.retire(), so the code stopped compiling. The retire now runs on a clone of the space's Arc and holds no lock across its shootdown. I folded this into the first commit, so every commit builds.

#1241 landed first. A retire cannot wait on a silenced CPU: under the stop every CPU keeps taking interrupts and scheduler passes (quiesce: "tasks stop; CPUs do not"), and nothing clears smp::answering once the APs are released.

Checks at fa9f2e5 (evidence r4/):

  • 12 kernel clippy shapes: exit 0 each.
  • cargo test -p kernel --lib: 460 passed.
  • cargo check --test toyos-build: exit 0.
  • loom tlb_tags: exit 0. Each control exits 101 with only its own model failing.
  • cargo run -- --ci host models: exit 0, "84 step(s), all green".

Guest run 43112810732 at fa9f2e5 carries the four x86 rows and the four AArch64 rows; it is queued. Each name was checked against MACHINE_TESTS / SCREEN_TESTS.

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1251, round 4: the rebase onto main 8188095 (head fa9f2e5)

Scope: the rebase from a99cd8c (LAND at r3) onto 8188095, as the brief asks. I did not reopen settled points.

What I read:

  • git range-diff 6d4b7e4ce..a99cd8cb5 818809583..fa9f2e599.
  • Every rebase-touched hunk at the head: boot.rs, invalidation.rs, process.rs, sched/driver.rs, mm/space.rs.
  • The new commit fa9f2e5.
  • The code the resolutions depend on:
    • main's teardown_resources and teardown, and leave_as
    • Space::retire → UserTables::retire → tlb::retire / user_change / wait_for / answer / leave_idle_space
    • sync::Lock::acquire
    • quiesce::stop, pass_on_every_other_cpu and smp_roster's answering
    • power::{reboot, shutdown} and x86 power::settle
    • every Origin consumer, on both architectures
  • main's diff between the two bases in every file this PR touches.

Architecture

The rebase changes no ownership or mechanism.

  • Main has since landed A thread carries its process's handles, and a lookup takes no lock but the table's #1246, which gives the handles their own lock. The retire still sits in the last thread's teardown, after the handles close, and now holds one lock fewer.
  • The machine's stop keeps its gate and waits for no thread #1241 removed Origin::Stop, so the census is one entry shorter, with nothing else changed.
  • The new commit moves what the deleted a-thread-resuming-… issue owed on metal into the two records that own those subjects. That keeps the closed issue's exit from being lost.
  • Size: the five carried commits are +678 −131 against the new base, against +676 −129 before the rebase. The whole difference is in process.rs, where the PR's change grows from +2 −1 to +4 −3 with the retire on a clone. fa9f2e5 adds 28 lines of issue text.

Range-diff: nothing else changed

Resolutions

boot.rs:69-77: both sides' guarantees hold.

invalidation.rs: the list and COUNT are consistent everywhere they are used.

  • The variants are Pcid, Mmio, Unmap, Retire, Guard, Stack, Staged, Bench: 8 under repr(usize), so indices 0–7. COUNT = 8.
  • NAMES lists the same eight in the same order, and its type ties its length to COUNT.
  • The header lists them in the same order.
  • Consumers:
    • x86 and AArch64 ISSUED, both [_; COUNT] indexed by origin as usize.
    • The two census Fields, which zip NAMES with the counts.
  • No host code parses the per-origin names, and nothing in kernel/ still names Stop. kernel-loom does not compile this file.

sched/driver.rs:793-804: both functions stand side by side, independent of each other.

The retire on a clone (process.rs:706-709)

  • No use-after-free.
    • Arc::clone(&process_data_arc.lock().space) drops the process-data guard at the end of the statement, as A thread carries its process's handles, and a lookup takes no lock but the table's #1246's handles line does two lines above.
    • ProcessData.space is never reassigned. It is the same Arc the loader built and every thread's payload carries (loader/mod.rs:191, spawn_thread via current_address_space).
    • ProcessData still owns a reference across the call, so the clone cannot become the last Arc. UserTables' drop, with its held assert, runs where it ran before.
  • No missed retire.
    • Every process end goes through teardown → teardown_resources, which runs the retire unconditionally, in the same order as before: handles closed, isa::process_ends, retire.
    • Only the switch, leave_idle_space and leave_address_space load a root.
    • Inbox's Weak of the space upgrades only to release its ring mapping (inbox/mod.rs:86). A change made after the retire finds no holder.
  • The shootdown answers on every CPU it targets.
    • user_change(Retire, tag, 0, MAX, tables = true) targets every CPU with the tag's running bit set, idle or not.
    • The retiring CPU's own bit was already cleared by leave_address_space → activate_kernel → switching → left.
    • A target answers either from the IPI or from the poll in a lock spin. Its answer runs leave_idle_space, which loads the kernel's root through switching, which clears the bit before the acknowledgement.
    • wait_for panics after DEAF_CPU, so the wait cannot hang silently. The held assert then checks the post-condition.
    • Before the APs are released only the boot CPU runs, and !answering() sends nothing, so there is no holder to miss.
  • Lock order.
    • Before the rebase the retire held the process-data guard and the space's lock across the wait. Now it holds only the space's lock (Space::retire, space.rs:200).
    • That is the same hold an unmap's shoot_down makes. A CPU spinning on that lock polls shootdowns (sync.rs:143), so it cannot block the wait.
    • No lock is taken in a new order. stats_from's later process_data.lock() reads usage(), which is outside the space's lock.

The stop (#1241)

The author's claim holds.

  • quiesce::stop stores the caller and then demands a pass of every other CPU. It masks no CPU, and its module says "Tasks stop; CPUs do not".
  • smp_roster never stores false to ready, the flag answering() reads.
  • power::reboot and shutdown, and x86 settle, halt no other CPU. Only the panic path stops CPUs.
  • A retire's targets are idle holders of a dying space. The stopping CPU runs its caller's thread, so it is never one.
  • A stopped thread is held at its boundary and never leaves its process, so its process reaches no retire.
  • The worst interleaving is a retiring CPU that is asked for a pass while it holds the space's lock. It begins the pass as soon as its targets answer, which is well inside the stop's DEAF_CPU bound.

The new commit fa9f2e5

Both records are accurate and follow main's "owed on metal" pattern.

  • The panic strings the TLB record quotes are prefixes of the actual asserts (tlb.rs:80, paging.rs:1110).
  • The speed record carries the deleted issue's exit: the cost of the switch with and without the skips, and the holder mutant. That way the deletion leaves no owed measurement unrecorded.

Findings

For the orchestrator: a description correction, not a branch blocker.

  • The "Rebase conflicts" paragraph says the retire "holds no lock across its shootdown". The rebase comment and r4/README.md say the same.
  • That is false. Space::retire (kernel/src/mm/space.rs:200, self.lock().tables.retire()) holds the space's own lock across tlb::retire's wait.
  • The code is right, for the reasons above. The sentence matters because main's squash commit takes the PR body. It should read: "holds no process-data lock across its shootdown, only the space's own, as an unmap does".
  • In the same paragraph, the "12 kernel clippy shapes" are 10 of build/clippy.rs's 12 kernel shapes plus 2 kernel-loom shapes. The two x86 heap-band shapes were not run. I ran them below, and both exit 0, so this is a wording fix only.

No blockers. No suggestions.

Evidence

Mine, at fa9f2e5: a detached worktree with a clean tree and its own target directory.

  • All 12 kernel shapes of build/clippy.rs, each with the adopted lints and -D warnings, exit 0:

    • the two library shapes, the controls one with kernel/counting-allocator,kernel/iova-past-four-gib,kernel/sched-tripwire
    • six x86 shapes, including heap-band-notail,heap-lockspin and heap-band-nohead
    • four AArch64 shapes

    Every log shows Checking kernel.

  • kernel-loom clippy, default and with both tlb_tags controls: exit 0.

  • cargo test -p kernel --lib: 460 passed, exit 0.

  • Loom tlb_tags: exit 0, 2 passed.

    • tlb-tags-resume-relaxed: exit 101, only a_change_passing_an_idle_holder_by_is_flushed_when_its_thread_resumes fails, with "a thread resumed on cpu 1 unreached and used a translation the change took away".
    • tlb-tags-enter-relaxed: exit 101, only a_change_reaches_a_runner_or_the_entering_cpu_flushes fails (:63).
  • cargo run -- --ci host models: exit 0, "84 step(s), all green". Both tlb_tags controls show "1 verdict(s) reached".

The author's r4/: the same commands at the same revision with a clean tree (rev.txt). The results agree with mine.

Guest run 43112810732 at fa9f2e5 was still queued when I checked at 23:05 UTC, behind other dispatches, so I read no result. It is the only test that runs the rebased retire:

Negative control for the rebase fix: none is needed. The fix moves a call out of a lock and onto a clone of the same Arc. Inspection and a compile settle that, and no host test can reach teardown_resources.

Metal: not a landing condition while the benches are down. What it owes is now recorded in the two issue records.

LAND, once guest run 43112810732 is green and the orchestrator has corrected the description's lock sentence.

Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 11, 2026
…ross its shootdown, and the clippy count is 10 kernel shapes and 2 for kernel-loom
Japabu added a commit that referenced this pull request Nov 11, 2026
…ge that frees no table is what reaches no idle CPU

- The SS comment cites SDM Vol. 3A §7.14.4, "Stack Switching in IA-32e
  Mode", where an entry from Ring 3 through an IDT gate forces SS null.
- The cached FS base says what is true: exact at every switch-in, since
  every switch away from a user thread reads the register back, and stale
  while a thread runs, which moves it by a segment load or
  SYS_SET_THREAD_POINTER. It is read from the register at each CPU's
  bring-up instead of starting at a sentinel a later WRFSBASE could
  write.
- `user_change` states what letting frames go past an idle holder rests
  on, against SDM Vol. 3A §5.10.4.4 ("Delayed Invalidation"): SMAP faults
  every other Ring 0 access to a user page, a freed user frame is only
  ever mapped write-back again (§14.12.4), and a user mapping that is not
  write-back is UC (§14.3) or the WC scanout. A non-write-back mapping of
  pool frames breaks a stated condition rather than a silent one.
- `arch::tlb`'s header keeps only what it owns, an idle holder's answer
  and the retire; `tlb_tags` says which changes reach an idle holder.
- `fpu`'s header no longer describes kernel threads, which do not exist.
- `tlb_shootdown_waits`' third part claimed that a change in a space no
  other CPU runs reaches none. A change that frees a table now rightly
  reaches a CPU idle on the space's root, which the main thread's parks
  in its joins can leave behind, so the part releases the mapping's
  middle page, whose table the rest of the mapping keeps, and says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…t's segment load leaves

`init_bsp` and `init_ap` read `IA32_FS_BASE` before `load_gdt`, whose
`mov fs, KERNEL_DS` loads the descriptor's zero base and clears the upper
half (SDM Vol. 3A §3.4.4). The boot CPU's cache could then hold the
firmware's leftover base while the register held 0, and a first thread
whose thread pointer matched it would run with FS.base 0. The cache now
starts at 0 for that reason, and neither function reads the register.

`arch::tlb`'s SDM citations are numbered as one edition, order
325384-093US, and the idle holder's SMAP condition names the accesses it
makes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the switch-drops-unneeded-work branch from fa9f2e5 to 2184217 Compare November 11, 2026 23:45
Japabu added a commit that referenced this pull request Nov 11, 2026
Both benches are down, so the switch's skips land on review, host checks
and guest rows. What only metal can show is recorded where its subject
lives: the idle holder's `tlb_shootdown_waits` and retire asserts in the
TLB record, and in the hardware-speed record `vector_state`'s trips and
`sysret_ss_reload` on both benches, the restore skip's two negative
controls on the T14, and the osbench A/B of main before and after. The
speed-up is unmeasured on metal, and the record says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The switch in a same-CPU futex round trip cost 403 of 3,998 ns on the T14
(wakeperf's segment probes): a CR3 write though both threads share one space,
an FPU restore, an FS-base MSR read and write, and an SS reload, and a park with
nothing runnable switched to idle reloading CR3 and back.

x86-64:
- The root. `Cr3::activate` records what it loads in a per-CPU word, and the
  switch loads a thread's root only when another is loaded. The idle context
  keeps whatever root it finds: its code reaches only the kernel half, which
  every root shares (a space copies every kernel root slot in when it is made,
  and `seal_kernel_half` gives each a table first). A CPU idle on a root
  still runs that space for the shootdown targeting, with an idle mark: a
  change that frees no table passes it by and
  it flushes when a thread of the space resumes there (`Tags::resume`, paired
  with `Tags::changed` by the same fence pattern as `enter`); a change that
  frees a table reaches it, and the answer of a CPU in its idle context loads
  the kernel's root, so it is reached once. A process's last thread out
  retires its space (`Space::retire`): every CPU still idle on its root loads
  the kernel's before the teardown goes on, so the space's drop waits on no
  CPU, and `UserTables`' drop asserts none holds it. `activate` runs masked, so
  no answer finds a CPU between two roots.
- The FPU. Every switch away from a thread still saves its state (nothing is
  deferred to a #NM trap, so Ring 3 only ever holds its own state); the
  restore is skipped where this CPU's registers still hold it: the per-CPU
  holder names the thread's context and the thread last left on this CPU.
  `load_initial` clears the holder. This is the design
  issues/a-thread-resuming-on-the-cpu-that-holds-its-state-restores-it-again.md
  laid out, which this closes.
- The FS base. The switch still reads it back at a switch-out, since Ring 3
  moves it with a segment load (SDM Vol. 2B, MOV); it writes it at a switch-in
  only when the CPU's last-known value differs.
- SS. Reloaded only on a part whose SYSRET keeps SS's hidden descriptor, which
  is every part but Intel's (SDM Vol. 2B SYSRET sets it whole; AMD's APM Vol. 3
  SYSRET sets the selector alone), and only when SS is not already the kernel's
  data selector (an IDT entry from Ring 3 leaves it null, SDM Vol. 3A §7.14.4).
  The probe says so on Intel, and the metal judge reads which part a boot ran
  on from its PCI host bridge's vendor, not from the kernel's own decision.

AArch64: the switch skips a TTBR0 write identical to what TTBR0 holds; its
idle context still loads the kernel's empty root, since nothing there could
take a dropping space's root off a CPU idle on it.

Tests: kernel-loom's `tlb_tags` gains the idle holder's model and its negative
control `tlb-tags-resume-relaxed`; `vector_state` gains a thread that leaves
its registers on an idle CPU, changes them on another and comes back.

Found on the way and filed, not fixed:
issues/ring-3-replaces-the-kernels-gs-base-with-a-segment-load.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu and others added 5 commits November 12, 2026 02:08
…it held

Every state loaded onto a CPU, a restore or a new thread's initial image,
belongs to a thread that leaves through `fpu::leave` before another thread
resumes there, so the holder that leave records is the whole of the skip's
record: the writes in `resume` and `load_initial` recorded nothing a later
reader could see.

`tlb::retire` asserts its post-condition, that no CPU still holds the
space's root, in the teardown that called it rather than only at the drop,
which may run long after the process's exit is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k from idle checks its tags

A dying thread can be switched out and back in between its leave of the
process and its exit pass: a killed thread keeps running through its
quantum, and any preempt-enable on the way may reschedule it. Its context
still named the space's root, so a switch back loaded that root again
after the last thread out had retired the space; a CPU could then idle on
a root `retire` had taken off every CPU, and the space's drop would
assert. `driver::leave_address_space` gives the running context the
kernel's root before loading it, so every later switch into the thread
loads the kernel's, on both architectures.

`Tags::resume` paid its fence on every switch between two threads of one
space, and flushed every user translation wherever the CPU's own by-page
flush had left its stamp behind the space's last change. Only a CPU that
idled on the root can have been passed by; one that ran a thread of the
space until the switch was reached by every change since, so it returns
at once, as a switch between two such threads did before.

`tlb::retire` no longer leaves an idle space on its own CPU: it runs on
the last thread out, which has left the space already. A change reads the
idle mark with acquire, pairing with the release that sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge that frees no table is what reaches no idle CPU

- The SS comment cites SDM Vol. 3A §7.14.4, "Stack Switching in IA-32e
  Mode", where an entry from Ring 3 through an IDT gate forces SS null.
- The cached FS base says what is true: exact at every switch-in, since
  every switch away from a user thread reads the register back, and stale
  while a thread runs, which moves it by a segment load or
  SYS_SET_THREAD_POINTER. It is read from the register at each CPU's
  bring-up instead of starting at a sentinel a later WRFSBASE could
  write.
- `user_change` states what letting frames go past an idle holder rests
  on, against SDM Vol. 3A §5.10.4.4 ("Delayed Invalidation"): SMAP faults
  every other Ring 0 access to a user page, a freed user frame is only
  ever mapped write-back again (§14.12.4), and a user mapping that is not
  write-back is UC (§14.3) or the WC scanout. A non-write-back mapping of
  pool frames breaks a stated condition rather than a silent one.
- `arch::tlb`'s header keeps only what it owns, an idle holder's answer
  and the retire; `tlb_tags` says which changes reach an idle holder.
- `fpu`'s header no longer describes kernel threads, which do not exist.
- `tlb_shootdown_waits`' third part claimed that a change in a space no
  other CPU runs reaches none. A change that frees a table now rightly
  reaches a CPU idle on the space's root, which the main thread's parks
  in its joins can leave behind, so the part releases the mapping's
  middle page, whose table the rest of the mapping keeps, and says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t's segment load leaves

`init_bsp` and `init_ap` read `IA32_FS_BASE` before `load_gdt`, whose
`mov fs, KERNEL_DS` loads the descriptor's zero base and clears the upper
half (SDM Vol. 3A §3.4.4). The boot CPU's cache could then hold the
firmware's leftover base while the register held 0, and a first thread
whose thread pointer matched it would run with FS.base 0. The cache now
starts at 0 for that reason, and neither function reads the register.

`arch::tlb`'s SDM citations are numbered as one edition, order
325384-093US, and the idle holder's SMAP condition names the accesses it
makes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both benches are down, so the switch's skips land on review, host checks
and guest rows. What only metal can show is recorded where its subject
lives: the idle holder's `tlb_shootdown_waits` and retire asserts in the
TLB record, and in the hardware-speed record `vector_state`'s trips and
`sysret_ss_reload` on both benches, the restore skip's two negative
controls on the T14, and the osbench A/B of main before and after. The
speed-up is unmeasured on metal, and the record says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the switch-drops-unneeded-work branch from 2184217 to 9b7d6d9 Compare November 12, 2026 00:09
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 12, 2026
…32a805e0d3e35: rebase past #1266 and #1267, checks, models; masked
@Japabu

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

I rebased onto main 36ed75b. That takes in #1266, #1259, #1243, #1239, #1263 and #1267. The head is now 9b7d6d9; the six commits are the old ones carried over.

Conflicts:

I resolved #1267 first against main plus #1267's queued head (e11ea5e). Once it merged, tlb.rs and shootdown.rs on main were identical to that, and the rebase onto the real merge replayed every commit unchanged.

Checks at 9b7d6d9 (evidence r5/):

Guest run 43125192691 is at 9b7d6d9 (the run list shows that SHA). It carries the four x86 rows and virt_fp_isolation virt_smp virt_jobs_at_el2 virt_mask_windows, each name checked against MACHINE_TESTS / SCREEN_TESTS, and is queued. Runs 43112810732 and 43122213464 were cancelled before they ran.

@Japabu

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1251, round 5: the rebase onto main 36ed75b (head 9b7d6d9)

Scope: the rebase from fa9f2e5 (LAND in round 4) onto 36ed75b, which brings in #1267's per-CPU asks. I did not reopen settled points.

What I read:

  • git range-diff 818809583..fa9f2e599 36ed75b5a..9b7d6d949, and the numstat of both against their bases.
  • At the head, all of arch/x86_64/tlb.rs, tlb_tags.rs and shootdown.rs (A user change asks each CPU running the space for its range, and owes itself nothing #1267's Asks, which this PR does not touch).
  • The callers and boundaries:
    • UserTables::{shoot_down, retire, drop} and Space::retire
    • flush_user_page, flush_tlb_user and Cr3::activate
    • the switch in arch/x86_64/hw.rs
    • Lock::acquire's poll
    • space.rs's shoot_down sites
  • The rebase report, the PR description and the merged TLB record.

Architecture

The rebase changes no ownership and adds no mechanism.

The checks the brief asks for

1. The composed shootdown asks every CPU it must, and none answers before its flush.

  • user_change builds its targets from Tags::changed(tag, tables, …). That is every CPU with the tag's running bit, less those idle on it when no table went.
  • A stale translation of the space can only be held by three kinds of CPU:
    • A CPU running the space is asked, by page or whole.
    • A CPU idle on its root is asked when a table went. Otherwise it is passed by and flushes at resume (the loom model, below).
    • A CPU that switched away flushes at enter, as on main.
  • Past ASKABLE, the generation path reaches every CPU, and answer leaves the space as well.
  • Order of an answer:
    • answer_ask flushes, then leaves, then returns to Asks::serve, which publishes with Release.
    • leave_idle_space sits between the flush and the publication and reorders nothing.
    • A CPU read as running that idles before it answers still flushes the asked pages correctly: by INVPCID by tag, or by INVLPG on the root it still holds. It then leaves.
  • tlb_tags and A user change asks each CPU running the space for its range, and owes itself nothing #1267's tlb_shootdown models and all eight of their controls behave as declared (evidence below). shootdown.rs is unchanged by this PR, so A user change asks each CPU running the space for its range, and owes itself nothing #1267's models still test the code that ships.

2. leave_idle_space in answer_ask is right and complete.

  • It is needed. Since A user change asks each CPU running the space for its range, and owes itself nothing #1267, both a table-freeing Unmap and Retire reach an idle holder only by an ask. Without the leave, every retire that found an idle holder would end on its held assert.

  • It is complete. Three closures answer:

    • answer, for generations;
    • answer_ask, for asks;
    • join, for bring-up, where no CPU has loaded a user root yet.

    A CPU at or past ASKABLE has no mailbox and is reached only through answer.

  • It runs only when something was asked. Asks::serve calls the closure only after it takes a bit.

  • It is safe in the IPI handler and in the lock spin's poll. leave_idle_space takes no lock: kernel_root is an atomic, activate masks, and switching touches only atomics. If the IPI lands inside the poll's own serve:

    • the inner leave can run first;
    • the outer one then sees is_loaded(), or, already past that check, loads the kernel root again;
    • in that case Tags::switch sees from == to and does nothing, so the only cost is a redundant CR3 write.

3. The whole-range ask for a freed table is sufficient.

  • UserTables::shoot_down turns tables into (0, u64::MAX), and retire passes the same range. These are the only two callers that pass tables = true. The other space.rs sites pass Unlinked::none() and free no table.
  • flush_asked then takes its full arm, since u64::MAX / 4096 > 64. That arm is flush(Reach::Narrow):
    • with PCID, INVPCID all-but-global, which drops every non-global translation and paging-structure-cache entry of every PCID;
    • without PCID, a CR3 reload.
  • An answer to two askers is Flush::User, which is the same flush.
  • After the flush, the walker can reach only the current tables. The leave then takes the root itself off the CPU, which is what the retire needs.

4. retire and the idle path compose with the mailboxes, with no missed answer and no deadlock.

  • retire calls user_change(Retire, tag, 0, MAX, true), which targets every CPU whose running bit is set.

    • The retiring CPU's own bit was cleared by leave_address_space.
    • If the thread migrated after that, the switch into its context loaded the kernel root on the new CPU, and that CPU left the space.
  • Asks::ask's precondition holds. user_change waits for every target before it returns. Space::retire runs under a sync::Lock, which disables preemption, so no second change on this CPU can reuse me's mailbox in the middle of the wait. No answer closure initiates a change.

  • Every target answers:

    • a halted CPU, by the IPI;
    • a CPU spinning with IF clear, through Lock::acquire's poll;
    • a CPU that is itself an initiator, through wait_for's answer_owed.

    DEAF_CPU panics otherwise.

  • held checks a real post-condition. Each target's left (a Release fetch_and) comes before its answered fetch_or (Release). The retiring CPU's Acquire load of answered therefore makes the cleared bit visible to held's Acquire load.

  • A target still running a thread of the space would be a bug. It would answer without leaving, and the assert fails loudly.

5. The range-diff shows nothing else.

Findings

BLOCKER: kernel/src/tlb_tags.rs:162-165 and kernel/src/arch/x86_64/tlb.rs:73-75. The resume contract describes the behaviour from before #1267.

  • Both comments say the resume flushes "where a change passed this CPU by while it idled there". The code returns changed[tag] > flushed[cpu].
  • Since A user change asks each CPU running the space for its range, and owes itself nothing #1267, a by-page answer (flush_asked's first arm) does not advance flushed. The record says so: "Only a flush of every user translation counts".
  • A trace where the comment is false:
    1. Cpu X runs thread A of space T.
    2. A one-page munmap on another CPU stamps T and asks X, which flushes the page alone.
    3. A parks, and X idles on T's root.
    4. A resumes there, and resume returns true, though no change passed X by.
  • This is not a regression against main. Main's idle loads the kernel root, and enter flushes under the same condition. But the contract of a TLB-coherence function now states a narrower condition than its code. A reader working on the resume path or on the flush accounting would be told that a CPU reached before it idled resumes for free.
  • Fix: state it as enter does, for example: "true where tag changed since its last flush of every user translation, as one that passed it by while it idled did". Do the same in tlb::resume.
  • The fix is comment-only. It needs no rerun, and the guest run below stays valid for the fixed head.

SUGGESTION (optional): two lists in issues/a-tlb-shootdown-reaches-only-the-cpus-that-ran-the-space.md (:40 and :63).

  • The record now has two "Owed on metal" lists, and both ask for the same machine's shared-debug boot with tlb_shootdown_waits green.
  • One list would make clear that one boot discharges both.

Evidence

Mine, at 9b7d6d9: a detached worktree with a clean tree (git status --porcelain empty) and its own target directory.

  • cargo test -p kernel --lib: 485 passed, exit 0.
  • Loom tlb_tags: 2 passed, exit 0.
    • tlb-tags-enter-relaxed: exit 101, only a_change_reaches_a_runner_or_the_entering_cpu_flushes fails (:63).
    • tlb-tags-resume-relaxed: exit 101, only a_change_passing_an_idle_holder_by_is_flushed_when_its_thread_resumes fails (:106).
  • Loom tlb_shootdown: 11 passed, exit 0. Each control exits 101 with its declared verdict among the failures:
    • shootdown-take-relaxed, shootdown-answer-relaxed and shootdown-ask-range-late: an_answered_ask_flushed_its_range_after_the_write;
    • shootdown-serve-relaxed: an_acknowledged_flush_postdates_the_page_table_write and one_serve_answers_two_concurrent_shootdowns;
    • shootdown-ask-stale-answers: only a_mailbox_reused_waits_for_its_new_answer;
    • shootdown-ask-many-as-one: only two_asks_of_one_cpu_are_each_answered_for_their_own_page.
  • Clippy, each with the adopted lints and -D warnings, exit 0 each:
    • all 12 kernel shapes of build/clippy.rs: the two library shapes (the controls one with kernel/counting-allocator,kernel/iova-past-four-gib,kernel/sched-tripwire), six x86 shapes including both heap-band ones, and four AArch64 shapes;
    • kernel-loom: default, and each tlb_tags control.
  • cargo run -- --ci host models: exit 0, "89 step(s), all green". The eight TLB controls each show their declared verdicts reached.

The author's r5/: the same commands at the same clean revision. Their results agree with mine. As in round 4, their "10 kernel clippy shapes" leave out the two heap-band shapes, which I ran.

Guest run 43125192691 is at 9b7d6d9 and carries the rows the description names. It was still running at 00:31 UTC, so I read no result.

  • It is the only test that runs the rebased answer path.
  • When it finishes, read each boot's tlb: census line, which the kernel logs at its stop, for retire=N. N > 0 means a retire there asked an idle holder through answer_ask, and the held assert passed.

Metal: not a landing condition while the benches are down.

SEND BACK for the resume comments. Once they are fixed, I see nothing else between this head and LAND, provided guest run 43125192691 is green.

…e CPU's last full flush

`Tags::resume` and `tlb::resume` said a resuming CPU flushes where a
change passed it by while it idled. The code flushes where the space
changed after the CPU's last flush of every user translation, and since
#1267 a by-page answer does not count as one, so a CPU reached before it
idled can owe the flush too. Both comments now say so, as `enter`'s does.

The TLB record keeps one "Owed on metal" list: a single `shared-debug`
boot on the T14 settles both the asks' and the idle holder's
`tlb_shootdown_waits`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 12, 2026
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 12, 2026
…fdb412f88f006: comments, record, the red virt_mask_windows run; masked
@Japabu

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Review r5: the head is now f183499, "Review r5 of #1251". The commit touches comments and records only.

  • Blocker: Tags::resume and tlb::resume now say what the code does. A CPU that idled on the space flushes when the space changed after its last flush of every user translation, as one that a change passed by has. A CPU that ran a thread of the space until now owes nothing.
  • Suggestion: the TLB record now has one "Owed on metal" list, so a single T14 shared-debug boot settles both A user change asks each CPU running the space for its range, and owes itself nothing #1267's tlb_shootdown_waits and the idle holder's.

Guest run 43125192691 at 9b7d6d9: seven rows passed and virt_mask_windows failed.

  • The failure is panic at kernel/src/windows.rs:79:5: data abort from a lower EL and then DOUBLE PANIC: the cpu was already in PageFault, right after unmap_touch's line.
  • That is the signature issues/virt-mask-windows-finds-a-mask-with-no-hook-in-a-lower-el-data-abort.md records. It was red on main at 422915e, before this branch existed.
  • The run's serial artifact holds only four x86 lanes, so it has no AArch64 trace.
  • The panic site is the mask-windows witness refusing a mask that reached no hook, during a lower-EL data abort. This PR's AArch64 code masks nothing: the switch's TTBR0 comparison, leave_address_space (with_cpu and a TTBR0 load) and retire (a no-op there) add no interrupt mask.
  • Control: virt_mask_windows alone on main 36ed75b (branch switchwake-control-main, run 43129662179), and again at this head (run 43130090881). Both are queued behind about twelve other runs.

Census: no tlb: line with retire= is readable from 43125192691. The census is logged at the machine's stop, and the run reached no stop on any captured channel: the x86 UARTs end where the log moves to logkeeper, and the job log carries no kernel log of a green row.

Evidence: https://github.com/ToyOSOrg/evidence/tree/b554bd2b082f99741c6fdc06a49df74a2a65ab00/r6

Run 43125192691 at 9b7d6d9 ended `virt_mask_windows` with the record's
two panic lines after `unmap_touch`. It is the first red on code after
34cb4c9, so the record no longer says every red ran 422915e's.

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

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: landing under the benches-down rule. Rebase review r5 LAND (composition with #1266/#1267 correct); its comment blocker fixed at f183499 (comments only, verified). Guest run 43125192691 at 9b7d6d9: 7 of 8 green; virt_mask_windows red with the signature of the recorded main defect issues/virt-mask-windows-finds-a-mask-with-no-hook-in-a-lower-el-data-abort.md (red on main without any branch, 43027396712) — an independent aarch64-only sighting, now cited in that record. Owed on metal recorded.

@Japabu
Japabu marked this pull request as ready for review November 12, 2026 01:24
@Japabu
Japabu added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 26bfb14 Nov 12, 2026
10 checks passed
@Japabu
Japabu deleted the switch-drops-unneeded-work branch November 12, 2026 01:45
Japabu added a commit that referenced this pull request Nov 12, 2026
… owes on metal

#1251 brought the record to main, and #1262 then fixed the defect without
touching it, so main's record still read as an open, unfixed hole. It now
says what #1262 built, how the exit's guest test and its negative control
came out under QEMU, and what the benches still owe before it closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
… owes on metal

#1251 brought the record to main, and #1262 then fixed the defect without
touching it, so main's record still read as an open, unfixed hole. It now
says what #1262 built, how the exit's guest test and its negative control
came out under QEMU, and what the benches still owe before it closes.

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