Skip to content

Every kernel stack sits above unmapped memory, and an overflow names its stack - #1211

Merged
Japabu merged 8 commits into
mainfrom
kmem-guards-wx
Nov 11, 2026
Merged

Japabu merged 8 commits into
mainfrom
kmem-guards-wx

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A thread's kernel stack was 128 KiB of kernel heap with live heap on both sides. Running off it wrote into a neighbour, and the only check was a canary word a scheduler pass read afterwards (issues/a-threads-kernel-stack-has-no-guard-page.md). An IST stack's guard was a filled page read after a crash, an AP ran its bring-up on a leaked heap block, and the boot stack began on the page after the image. Only the idle stacks had an unmapped page under them.

What it does now

stack before now
a thread's heap, canary its job's frames, mapped in a kernel address range of its own whose gaps are never mapped
a CPU's idle its own arena, guarded carved at bring-up above a page taken out of the direct map
IST1-3 (x86-64) heap, filled guard the same
an AP's bring-up leaked 64 KiB heap block gone: an AP runs on its idle stack from its entry
the boot stack the page after the image the loader leaves a page between them, which the kernel's tables do not map

On x86-64 a fault on a guard cannot push its frame, so it is a #DF on IST1. Its report, and a kernel #PF that still has stack, name the stack:

KERNEL STACK OVERFLOW: 0x… is in the guard 0x…..0x… under the kernel stack of pid=0 tid=0

The canary, the heap bands' read of a live stack, OwnedAlloc, sched/idle_stack.rs and smp::bringup_stack are deleted.

The design (the orchestrator's decision after review r1, recorded on this PR; not an owner ruling)

Thread stacks do not live as holes in the direct map. They are mapped in root slot 510 (mm::stack::RANGE), which the direct map never reaches, in 256 KiB slots: the stack at the top, nothing below it.

  • The guard is true by construction. The rest of a slot is never mapped in any incarnation, so no CPU can hold a translation of it and nothing is flushed to make it real. No direct-map leaf is split at run time.
  • The frames are the job's. pmm::alloc_held takes them and they go back when the stack drops; a job over its allowance is refused the spawn. The heap Charge no longer covers the stack.
  • A dropped stack's frames go back at once; its slot's address waits. A spawn that finds no clean slot and 64 retired ones (FLUSH_BATCH) issues one kernel-half shootdown and takes them; with fewer waiting it takes a slot never used. A spawn that finds a flush already under way waits at a gate for the slots it cleans and draws no new slot; the wait is that shootdown, which ends or panics on a CPU that does not answer. So while never-used slots remain a flush follows at least 64 drops, an exit pays none, and nothing is flushed from the reaper on the idle path.
  • The reuse decision is pure, fixed-size and host-tested (kernel::stackslots, beside PcidPool): three bitmaps over 262144 slots, 96 KiB of the kernel's image. Nothing allocates under the slot lock. The range holds 262144 stacks at once (64 processes at their thread limit); a spawn past that is refused.
  • What a stale translation can and cannot reach. Between a drop and the flush, a CPU may still translate the dead stack's addresses to its old frames. Nothing uses those addresses again before the flush, so only a kernel use-after-free of the dead stack reaches them, which is the exposure every frame already has through the direct map. It cannot reach a guard, which was never mapped. On AArch64 the unmap broadcasts each page's invalidation itself, so nothing waits.
  • The range's page tables are the kernel's own and are never freed. A table freed without a flush could still be walked through another CPU's paging-structure cache. The bound: one 4 KiB table per 8 slots ever used and a directory per 4096, and the slots ever used are at most the most stacks alive at once and 64 more, which the model's test asserts, so about 2 MiB for a process at its 4096-thread limit. They are charged to no job; a spawn that cannot get a frame for one is refused. The bound is recorded in issues/the-kernel-stacks-range-keeps-every-table-it-ever-made.md and the manual's memory page.
  • Per-CPU stacks stay carved under the direct map. An x86-64 AP runs on its idle stack, and loads the IDT that names its IST stacks, under the loader's boot map, which holds no other range. The range cannot serve them without bringing a bring-up stack back.

Review history

  • r1 (the pool in the direct map): resolved by the redesign above. The AArch64 stack-probe claim was wrong and is deleted; both kernel targets probe inline. The two unrelated findings are filed as records.
  • r2 (this design): a refused spawn leaked a reference to its job through a forgotten Stack, and the slot lists were heap Vecs grown under the slot lock whose doubling reached the allocator's size assert at 262144 slots. Both are fixed: the Stack is built only once mapped, and the slots are the fixed-size pure module. Also taken: accessed and dirty preset on x86-64 stack leaves, the walk's table asserts, and an unmap that looks its table up without making one.
  • r3: a take that landed inside a flush drew a never-used slot, so every raced flush grew the range's never-freed tables for good. Fixed in code by the orchestrator's decision: such a take waits for the flush instead, and the bound above is now true.
  • r4: to land once the evidence and metal are in. Taken: two comments say what the gate and the tables' bound are, and the model's test refuses an unowed flush.

What the double-fault path needs

  • A stack of its own that is never the one that overflowed. The CPU loads IST1's top on every #DF whatever it interrupted (SDM Vol. 3A §6.14.5), so an overflow of IST2, IST3 or IST1 itself still reaches the handler.
  • A handler that ends if it is entered twice. It reports once; a second #DF on the same CPU writes one raw line and halts the machine.
  • Every per-CPU stack carved by a CPU whose own gs: is valid, before the IDT is loaded.
  • Stack probes, which both kernel targets emit inline.

Memory cost per thread (observation, not measured under load)

128 KiB of stack plus the machine-state area rounded to pages, in frames: 33 frames where the area fits one page, against a 128 KiB-plus-area heap chunk before. The guard costs no memory. An eighth of a 4 KiB table per slot ever in use is the kernel's.

Files other open PRs touch

#1200, #1204 and #1212 have landed and the branch is rebased over all three; #1202 is closed. Of the PRs open now, #1223 also edits kernel/src/scheduler.rs and #1131 tests/toyos.rs. syscall/dispatch.rs and sdk/abi are untouched.

Checks

Head 384db8897: the eight commits rebased onto 34cb4c918, where GitHub showed a conflict. The rebase changed one line of the branch, the first commit's crate::process import in kernel/src/loader/start.rs, after #1204 made ENDOW_ENTRY_LEN private (r6/range-diff.txt). #1204's other edits to loader/start.rs and process.rs are the spawn endowment's wire format and rights and leave the stack's allocation alone. Main has moved since by three merges that change only issues/, none a file of this branch; GitHub reports the PR mergeable. Logs: evidence pr-1211 at cfe3fdaa.

At the head, chosen for what the rebase can change, the kernel's code compiling and the pure logic beneath it:

check result log
cargo run -- --ci host at 384db8897 the kernel's library 459 passed, the five stackslots tests among them; clippy's 26 invocations clean, the kernel for x86-64 and AArch64 in every feature set the stage builds; harness, workspace host members, toyos-crypto and licences passed; the build system's one red is the macOS-only llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key (issues/the-llvm-environment-test-breaks-git-on-macos.md). The run was ended by a SIGTERM from outside it in the models stage, which this change does not owe: no loom model includes a file the branch changes, and the scheduler simulator covers kernel/pure/sched/, which it does not change r6/ci-host.log

At 6d7e3557a, reused: the branch's code is the head's but for the import above and review r4's two comments and one test assert, after which the x86-64 kernel built to the same bytes (r5/kernel-hashes.txt); what the rebase brought in under it is #1204's, which touches no stack code. Not rerun after the rebase for that reason: the guest rows, the host controls and the disassembly.

check result log
guest rows, run 43013777372 success, 8 passed: a_kernel_stack_overflow_names_its_stack (five boots), nested_nmi_is_loud, std_threads_count_the_cpus_and_give_back_ended_threads, libc_children, kernel_wx_leaf_is_refused (x86-64 q35 under KVM); virt_smp, virt_user_mode, virt_kernel_wx_leaf_is_refused (AArch64) r4/guest-rows-43013777372.log
the slots' host tests 5 passed: no slot retired since the last flush began is handed out; a take beside a flush draws none; the slots ever used never pass the peak plus 64 r4/stackslots-tests.log
negative control: a take beside a flush draws a never-used slot builds; 3 of 5 red diff, log
negative control from round three: retired slots made clean before the flush is done 3 of 5 red on the round-three model; not rerun on this one r3/control-no-wait.log
cargo run -- --ci host, clippy included exit 1: 1 of 110 steps red, the macOS-only llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key (issues/the-llvm-environment-test-breaks-git-on-macos.md); every other failure list in the log is a mutation control's r4/ci-host.log
AArch64 stack probes, by disassembly at 130d5e74f frames over a page step sub sp, sp, #0x1, lsl #12 / str xzr, [sp] r2/aarch64-probe-excerpt.txt

The five overflow boots of guest run 43013777372:

stack-overflow-task:   … 0xffffff000001eee8 is in the guard 0xffffff0000000000..0xffffff000001f000 under the kernel stack of pid=0 tid=0
stack-overflow-idle:   … 0xffff800000c00ff8 is in the guard 0xffff800000c00000..0xffff800000c01000 under cpu0's idle stack
stack-overflow-nmi:    … 0xffff800000c26f28 is in the guard 0xffff800000c26000..0xffff800000c27000 under cpu0's IST2 stack
stack-overflow-boot:   … 0xffff80007a314fd8 is in the guard 0xffff80007a314000..0xffff80007a315000 under the boot stack
stack-overflow-report: the raw second-entry line, then the first report naming cpu0's idle stack

What is not shown.

  • No negative control for the guard in the new range. A slot's lower part is unmapped by never having been mapped; the test's assertion is positive (a #DF whose cr2 lies in the slot's computed guard). The reuse logic, whose failure would be silent, has the host controls above instead.
  • The gate itself is not exercised by any test. The model holds the decision; that two kernel takers meet at the gate needs two CPUs spawning inside one flush, which no row arranges.
  • The flush path on a machine is unread. A passing guest row keeps no serial, and a metal readback ends before the census line, so no stack= count has been seen.
  • IST3 is not exercised, and no AMD CPU has run an overflow: the guest host is Intel and no metal row can end the machine.
  • Not measured: a spawn waiting at the gate, which no staged row can contend; the reaper's per-stack work on the idle path (33 leaf clears and 33 frame frees where one heap free was); and thread creation by itself.

Metal

Four boots, run by the orchestrator: on the T14 and on the AMD bench, the base 422915e2c with spawn_cost and syscall_cost, and the image built at 6d7e3557a with kernel_wx, smp_roster_and_tsc_trail, topology_readback, pmm_accounting, spawn_cost, syscall_cost and the shared member abuse_thread_table. Each image's revision is read from its own supervisor: build record. The head image's x86-64 kernel is 22a577abe's byte for byte; it predates the rebase, which adds #1204's endowment changes and no stack code.

Judged by the head's own row judges, under a local patch that is published and not committed (r6/judge-local.diff). The bench driver that ran these boots predates #1213 and wrote no back_unix_secs, and the judge refuses such a readback before it reads a row. Only Readback::ran_within reads the field, and only wall_clock_is_firmwares calls that, which is not among these rows. The patch also points the judge at a copy of the benches' records, so judging these readbacks adds nothing to the shared ones. The row judges are the same at 6d7e3557a, at 422915e2c for the two cost rows, and at the head.

T14 base T14 head AMD base AMD head
judge, exit 0 each 2 passed 7 passed 2 passed 7 passed
revision, from the boot's supervisor: build record 422915e2c 6d7e3557a 422915e2c 6d7e3557a
syscall_cost, cycles a syscall 159 163 87 87
spawn_cost min / median, µs over 50 214 / 241 216 / 234 209 / 265 230 / 280
Boot: complete ms / back in s, against the record's 1152 / 50 (T14) and 1312 / 36 (AMD) 136 / 56 136 / 55 241 / 33 242 / 37
log judge-t14-base judge-t14-head judge-amd-base judge-amd-head
  • The timings rule holds each boot's own numbers to twice the bench's record; none is past it. spawn_cost and syscall_cost are printed for an A/B and have no record on either bench, so the comparison is the table's, one boot a side, which is not a distribution: the T14's syscall is 4 cycles dearer and its spawn 2 µs dearer at the minimum, 7 µs cheaper at the median; the AMD bench's syscall is unchanged and its spawn 21 µs dearer at the minimum (10%), 15 µs at the median (6%).
  • smp_roster_and_tsc_trail on the head images is the first metal boot, on both benches, of APs that enter on their own guarded idle stacks under x86-64's boot map holds no page writable and executable #1212's W^X boot map (8 CPUs, every AP's TSC within 181 cycles; 16 CPUs, within 288). The base boots ran that boot map with the bring-up stack this branch deletes.
  • abuse_thread_table passed on both: 4095 threads exited unjoined, the next was refused, and a join made room for one. It spawns one thread at a time and each exits as soon as it has run; a thread's kernel stack is released at the next scheduler pass after its exit, not at its join (by reading). So its stacks retired one by one, with few alive at once, and by the slots' logic a spawn flushed each time 64 had retired, about 64 flushes over the row. That is inference: the census tlb: line, whose stack= counts them, is in no readback, because the kernel writes it where the machine stops, after logkeeper has made the log file whole.
  • No line naming LOCK CONTENTION, DEADLOCK, DOUBLE FAULT or KERNEL STACK OVERFLOW, and none naming mm/stack.rs, in any of the four kernel logs.
  • The AMD head printed a 1.72 ms clock: TSC STEP; its base boot has the same, 1.69 ms. That is issues/chipset-counters-that-stall-together-read-as-a-tsc-step.md, not this change.
  • Not shown on metal: any overflow (a #DF halts every CPU before logkeeper can make the log whole, so no metal row can judge one), the flush path and the gate, and AArch64, which has no bench.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1211 at 41e545dd3 (base 9349705ce)

Read: both commits, the whole diff, every changed file, tlb.rs on both architectures, pmm.rs, kernel/pure/frames.rs (charge, uncharge, alloc_heap, free_heap), sync.rs, the #DF handler, AP bring-up on both architectures, the loader's placement, the evidence at 0ba3b121, and the overlapping hunks of #1200, #1204 and #1212. Nothing was built or run.

Net: production +392/−252 (+140), tests +51/−3, records and manual +64/−34. The additions are mm/stack.rs (106), the report's stack naming (about 45) and four actuators (29); sched/idle_stack.rs, OwnedAlloc, the canary, bringup_stack and check_live go.

Architecture

One Stack type for every stack, the bring-up stack deleted, and a report that names the stack are the right shape, and for the stacks a CPU keeps for the machine's life (idle, IST, boot) a hole in the direct map is a sound representation: carved at bring-up, never dropped, reachable through the boot map an x86-64 AP starts under.

For thread stacks it is not. A stack that drops cannot give its memory back, so the hole brings three things with it: a pool outside the accounting that only grows, a wide shootdown whose completeness is the only thing that makes a new guard real on the other CPUs, and direct-map leaves split at run time by a path that panics when it cannot get a table frame. The PR chose it so that "a thread's Charge covers its stack exactly as before" with no change to frames.rs; that is fitting the smaller assignment.

The dedicated-range question, plainly. Thread stacks mapped in a kernel virtual range of their own, with gaps never mapped, would be materially safer: the guard is true on every CPU by construction with no shootdown, no direct-map leaf is split at run time, each stack owns frames that go back when it drops, and the charge becomes frames held by the job, which is what the manual's "every allocation has one owner" asks. It deletes the pool, Origin::Guard, the forgotten HeapPage and the new debt record. It is not smaller: this kernel maps nothing outside the direct map today, so it needs a root slot, a 4 KiB kernel mapper and an unmap flush on both architectures, and an x86-64 AP cannot start on such a stack under the loader's map, so per-CPU stacks would stay as this PR has them. TLB reach is equal either way, since a carved superframe is 4 KiB-mapped for good. A new kernel virtual region is the owner's decision, not mine or the implementer's. What I can say is that the hole representation cannot meet the manual without growing more machinery (per-superframe free tracking, re-mapping guards, a shrink policy), each of which the range makes unnecessary, so the choice should be put to the owner before more is built on the pool.

BLOCKERS

  1. kernel/src/mm/stack.rs:63-68,102-105, issues/a-dropped-kernel-stack-never-goes-back-to-the-frame-allocator.md — a dropped thread stack is never returned and is charged to nobody — not acceptable as recorded.

    • It is a regression: on main a dead thread's stack went back to the heap, usable by any allocation, and a wholly free superframe went back to the frame allocator (KernelPageSource::free).
    • It is reachable from userland: MAX_THREADS is 4096, so one process can strand 4096 × 136 KiB = 544 MiB by spawning and exiting, and a job can do it across processes up to its allowance. Afterwards that memory serves thread stacks only, until reboot.
    • It contradicts two manual requirements (docs/src/architecture/memory.md): "Every allocation has one owner, the job it is charged to" and "Unused backing returns under pressure". The PR changes neither.
    • The record understates it. Reading frames.rs (inference, not run): after a mass exit unentitle lowers drawn while the pool keeps its superframes; when threads return, charge reserves whole superframes again and nothing draws them, because the stacks are reused. So the same threads can hold the pool and an equal reservation, and a spawn can be refused for memory the pool is sitting on.
    • The closed record said the design "has to meet" when a slot's memory goes back; "never" does not meet it. The new record's own exit offers "the owner accepts the bound", and no owner acceptance exists.
    • Required: fix it here (either representation), or bring the owner's explicit acceptance with a record that states the userland bound, the double hold and a severity flag.
  2. kernel/src/arch/x86_64/paging.rs:538, kernel/src/arch/aarch64/paging.rs:379 — guard_4k now runs on the thread-spawn path and its table allocation is alloc_frame().expect(...) — a spawn that needs a new superframe panics the kernel when no frame is free, although Stack::new documents None "where there is no memory for one". Before this PR guard_4k ran only at bring-up and a failed stack allocation was a refused spawn. The table frame is also charged to nobody.

  3. kernel/src/mm/stack.rs:53-75 — POOL is held, with preemption off, across try_reserve (a heap allocation), HeapPage::grow (a 2 MiB zeroing) and guard_4k (a table allocation and up to two full local TLB flushes). HeapPage::grow's own contract is that the heap calls it "with its lock released ... so no CPU waits on either through the 2 MiB write". Every other spawn, and every stack drop from the reaper on the idle path, spins behind it. Take the frame before the lock, as KernelAllocator::alloc does.

  4. issues/a-kernel-stack-overflow-on-aarch64-reports-nothing.md:19-20,25 and the PR description — "aarch64-unknown-none-softfloat also emits no stack probes" has no evidence and is contradicted by the pinned fork: compiler/rustc_target/src/spec/targets/aarch64_unknown_none_softfloat.rs at a189b3a6b sets stack_probes: StackProbeType::Inline, the same as x86_64-unknown-none. Show the claim with a disassembled kernel frame larger than a page, or delete the sentence and the exit's last clause. Cheapest check: objdump of one such function in the existing aarch64 kernel build.

  5. kernel/src/arch/x86_64/percpu.rs:430-431 — "Called once this CPU's gs: is valid" states the wrong CPU. alloc_ap calls it for an AP whose gs: is not valid; what the shootdown reads is the calling CPU's. This is the exact contract the second commit exists to fix, so the sentence has to be right.

  6. Evidence for the cost claim is missing and the staged plan cannot produce it. See "Evidence" below.

Correctness of the guard (what holds)

  • Every address space, every CPU. The kernel half shares its lower tables, so the edit is visible in every space. guard_4k flushes the local CPU (global leaf: INVPCID type 2, or INVLPG without PCID); shootdown(Guard, Kernel) is Reach::Wide, which drops global entries in every PCID, and returns only when every answering CPU has flushed. The stack is returned after that. A migration between the two steps is covered because the initiator is flushed too.
  • Before the machine answers. With smp::answering() false the shootdown is a local flush. That is sound: an AP's own stacks are carved before its SIPI, it touches nothing else until ap_idle, and tlb::join flushes wide before it enters the scheduler.
  • The split. On x86-64 the 2 MiB leaf becomes a table of identical 4 KiB leaves, then one is cleared; a CPU still holding the 2 MiB entry is correct for the other 511 pages and the guard is not relied on until the shootdown returns. On AArch64 the split is break-before-make on a superframe fresh from HeapPage::grow and still under POOL, so nothing runs from it; later carves in it find a table and only clear a page with TLBI VAAE1IS. The shared code is right for AArch64.
  • No stack missed. Thread, idle, IST1-3, boot. Syscall entry and Ring 3 interrupts use the thread's stack (kernel_rsp, rsp0); Ring 0 interrupts use the running stack; there is no separate interrupt stack; alloc_kernel_stack is the only constructor of KernelStack. An AP starts on its idle stack and enter_idle_loop resets rsp to that stack's top, which is safe since the function never returns.
  • The first commit's defect is fixed, not moved. smp::size runs before init_bsp, so the flush reads cpu_id() through gs:. The carve now follows the wrmsr and control_regs::init (which pcid_active needs) and precedes the IDT; the TSS reads ist[] at exception time, so filling it after ltr is fine. alloc_ap runs on the boot CPU, whose gs: is valid.
  • Cost and permanence. Each stack superframe is 4 KiB-mapped for good and costs one uncharged table frame; per stack, 4 KiB of RAM is the guard. One wide shootdown per carve is conservative but I would not drop it: a later guard page in a split superframe is unowned memory another CPU could have filled a translation for speculatively.

The #DF path

  • The claim is right against SDM Vol. 3A §6.14.5 and APM Vol. 2 §8.9 (long-mode stack switching, the interrupt-stack table): a non-zero IST index loads RSP unconditionally, so a #PF that cannot push on IST2, IST3 or IST1 escalates to a #DF that starts at IST1's top. A triple fault remains only where #DF delivery itself faults (IST1's top pages, the IDT, the TSS, the handler's text), none of which this PR changes.
  • An NMI during the report runs on IST2 and returns. Overflow inside an NMI is reported with NMIs still blocked, which the halt tolerates.
  • stack_guard_at reads gs: words, the TSS and one atomic: no lock, no allocation. Safe in that context.
  • The second-entry arm is twelve lines and reads correctly. If halt_all_cpus itself faults every time the CPU loops on the raw line, which is loud, not silent.
  • AArch64: before this PR a thread's overflow corrupted a neighbour and the canary reported it late; now it is contained and the CPU loops on the vector with no report. The record says so accurately. That it is a silent hang suggests severity: [hang].

Tests

  • One boot per kind is justified: each ends the machine, 8 s for all four under KVM. The claims are clear and the assertion is positive (a line naming that stack's own guard range under a DOUBLE FAULT), so it does not need a control per arm; the control reaches only the first arm, which is enough.
  • The untested double-entry arm is acceptable to land: its hardware premise is the specification's and its logic is inspectable. It is still the only place IST1's guard is claimed to work, and one more actuator (overflow inside the report) would show both that and the arm in one boot.

Evidence

  • Host log, read. 1 of 110 steps red: the build system: cargo test --lib, one test, llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key, failing on the macOS git wrapper under SDKROOT=/no/such.sdk, as issues/the-llvm-environment-test-breaks-git-on-macos.md describes. Independent of this change. The FAILED lines under kernel-loom are the mutation controls reaching their verdicts.
  • Host on the first commit, clippy on the fix: sufficient. The fix moves one call inside init_bsp, which no host test executes; clippy compiled it across its 26 invocations, and the x86-64 guests ran at 41e545dd3.
  • Guest rows. Run 42940741605 at 41e545dd3: 7 passed, the four overflow lines as quoted. The x86-64 instrument is KVM on an Intel i7-8700K with 2 vCPUs, so the IST and #DF delivery shown is Intel hardware's; no AMD CPU has run an overflow. Control 42942201408 at 6bfb268ce: red, the guest reset with neither line.
  • The cost is unmeasured and spawn_cost cannot measure it. It discards a first spawn and prints the minimum and median of 50 sequential spawns, each of which reuses the pooled stack: no carve, no shootdown. What shows the cost is the census tlb: line (guard= count, wait, max) from a boot on each bench that prints the census, with tlb-shootdown-bench for the per-shootdown distribution if the tail matters.
  • Metal, required green before landing, one boot per bench:
    • T14 (Intel): smp_roster_and_tsc_trail (every AP up on its idle stack through the boot map, hard-lockup NMIs on the new IST2), pmm_accounting, kernel_wx (the audit over the split leaves and the image's guard on real firmware's map), spawn_cost same-session against 9349705ce, and the census tlb: line read.
    • AMD bench: the same.
    • Add syscall_cost to the same image: it rides the boot and shows what 4 KiB-mapped kernel stacks cost the syscall path.
    • AMD overflow behaviour is specified (APM) but unobserved. One hand-staged stack-overflow-nmi boot on the AMD bench would close it; that is the orchestrator's call, since no metal row can end the machine.

Manual and records

  • docs/src/architecture/memory.md: the new bullet names no code and states the design; fine. It does not cover blocker 1's departure from the Accounting and Allocators sections.
  • issues/a-threads-kernel-stack-has-no-guard-page.md: its exit is met on x86-64 (guard under every thread stack, the recursing arm's #DF names the guard page, the unguarded control is red). No citation of the slug remains in the tree. Its second paragraph's memory-return requirement is blocker 1.
  • issues/a-kernel-stack-overflow-on-aarch64-reports-nothing.md: accurate except blocker 4.
  • issues/a-double-fault-on-cpu-1-under-a-wide-suite.md: the added paragraph is accurate.
  • The PR body's "Unrelated, recorded not fixed" names two stale idle_stack_guard records and IDLE_GUARD_READ, but nothing in issues/ records that discovery; the body is the only place it lives. Orchestrator's to correct or file.

Overlap (semantic only)

SUGGESTIONS

  • kernel/src/mm/stack.rs:70-78 — carve every slot of a new superframe at once and push the spares to free: one shootdown per superframe instead of one per stack.
  • kernel/src/arch/x86_64/paging.rs:530 — "Already split" sits over the branch that handles a leaf not yet split. Pre-existing; this PR rewrote the comment beside it.
  • sdk/toyos/src/elf/layout.rs:615 — a second StackedImage with the old placement, used as far as I found only by its own test, now disagrees with toyos_kimage's. Unrelated; worth a record.

SEND BACK

Japabu added a commit that referenced this pull request Nov 11, 2026
Review r1 of #1211, to the owner's design. A thread's stack as a hole in
the direct map could not give its memory back, needed a wide shootdown
to make each new guard real, and split direct-map leaves on the spawn
path with a table allocation that panicked.

mm::stack::Stack is now frames its job holds (pmm::alloc_held), mapped
at the top of a 256 KiB slot of root slot 510, which the direct map never
reaches. The rest of every slot is never mapped in any incarnation, so
the guard is true on every CPU with nothing to flush and no direct-map
leaf is split at run time. A dropped stack's frames go back at once and
its slot's address waits: a spawn that finds no clean slot and 64 retired
ones issues one kernel-half shootdown and takes them, otherwise it takes
a slot never used; one that can do neither is refused. Until that flush
a stale translation reaches only what a kernel use-after-free of the
dead stack would, the exposure every frame has through the direct map.
On AArch64 the unmap broadcasts each page's invalidation itself.

The range's tables are the kernel's own and are never freed, since a
paging-structure cache may still name one: one for each 8 slots ever in
use at once, a directory for each 4096
(issues/the-kernel-stacks-range-keeps-every-table-it-ever-made.md). A
spawn that cannot get a frame for one is refused. No lock is held across
an allocation or a zeroing.

A CPU's own stacks stay holes in the direct map, carved at bring-up from
the kernel's own superframes: an x86-64 AP runs on its idle stack, and
loads the IDT that names its IST stacks, under the loader's map, which
holds no other range.

stack-overflow-report runs the #DF report itself off IST1: the second
#DF restarts the handler, which ends the report on one raw line.

The record of a pool that never returned memory goes with the pool. The
AArch64 record loses its stack-probe sentence: the pinned target probes
inline, as a disassembled frame shows. Two unrelated findings are filed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu Japabu changed the title Every kernel stack sits above an unmapped page, and an overflow names its stack Every kernel stack sits above unmapped memory, and an overflow names its stack Nov 11, 2026
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Round two is at 6f1f01b12, reworked to the owner's design. Evidence: pr-1211 at 3307f830. The PR body is rewritten; it carries the design, what a stale translation can and cannot reach, and the tables' bound.

  1. Memory never returned. Gone with the pool: a thread's stack is frames its job holds (pmm::alloc_held), mapped in a slot of root slot 510 and freed when it drops. The record is deleted.
  2. Panic on table exhaustion. The range's mapper returns false and the spawn is refused. guard_4k is bring-up's alone again. The range's tables are the kernel's own and never freed, by the owner's ruling, with the bound in issues/the-kernel-stacks-range-keeps-every-table-it-ever-made.md and the manual.
  3. Lock across allocation and zeroing. Frames are taken and zeroed before any lock; the slot lock is dropped across the shootdown.
  4. AArch64 probes. You were right: the kernel's frames over a page probe inline (excerpt in the evidence). The sentence and the exit's clause are deleted, and the record carries severity: [hang].
  5. The gs: sentence. It now says the caller's gs:, the boot CPU's for every block.
  6. Cost. Staged per bench: the head with spawn_cost, syscall_cost and abuse_thread_table, and the base 9349705ce with the two cost rows. A spawn pays at most one shootdown in 64 and an exit none.

Suggestions. Taken: an actuator that overflows inside the report (stack-overflow-report), which reached IST1's guard and the second-entry arm in one boot. The superframe-at-once carve went with the pool. The two unrelated findings are filed as records.

Not done. No negative control for the new range; the PR body says why.

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1211 at 6f1f01b12 (base 9349705ce)

Read as a new design: all four commits, the whole diff, mm/stack.rs, both paging.rs and tlb.rs, shootdown.rs, pmm.rs and kernel/pure/frames.rs (alloc_held, free_held, alloc_frame), mm/alloc.rs, both spawn paths, the #DF handler, the manual's memory page, every changed record, the evidence at 3307f830 (host log's failure list, both guest logs, the probe excerpt, the staging logs) and the stack-related hunks of #1200, #1204 and #1212. Nothing was built or run.

Net: production +618/−254 (+364), tests +63/−3, records and manual +111/−34. Against r1's head the rework adds about 225 production lines: the slot allocator and Stack (about 110), a 4 KiB map and unmap per architecture (about 45 each), Origin::Stack. The pool, the spawn-path Origin::Guard, the forgotten HeapPage on spawn and the never-returned record are gone.

Architecture

The decision holds up in the implementation; nothing here shows it was wrong.

  • Guard by construction. Only pages leaves at a slot's top are ever written, the stack size is one constant per boot and the gap is asserted non-empty, so no CPU can hold a translation of a guard and nothing is flushed to make one real. No direct-map leaf is split after bring-up (guard_4k has one caller, permanent).
  • Ownership is right. Frames come from pmm::alloc_held, an existing path (a pipe's bytes use it), refused whole over the job's allowance and returned in Drop with no lock held. The manual's "one owner" and "returns under pressure" now hold for stacks.
  • It is not a general vmalloc. One range, one object size, one mapper call each way, tables never freed. That is the minimum a stack needs.
  • The one piece that is not minimal or safe yet is the slot bookkeeping: two heap Vecs that grow with the peak thread count, under the slot lock. Both blockers below are in those forty lines.

Round 1's blockers

  1. Memory never returned: resolved. Pool and record deleted; frames go back at drop.
  2. Panic on table exhaustion: resolved for the panic. stack_table returns None on both architectures and the spawn is refused; an intermediate table made before the failure stays linked and is used by the next spawn, so no frame leaks. The refusal path leaks something else: blocker A.
  3. Lock across allocation and zeroing: resolved for the frames and for the shootdown, which is what cost. Not literally as the commit message says ("No lock is held across an allocation or a zeroing"): the slot lock is held across two try_reserve calls, the first thing r1's blocker named (blocker B), and the paging lock is held across a table frame's allocation and 4 KiB zeroing. The second is this kernel's existing practice (ensure_table) and I do not object to it.
  4. AArch64 probes: resolved. The sentence and the exit's clause are gone, the record carries severity: [hang], and the excerpt shows sub sp, sp, #0x1, lsl #12 / str xzr, [sp] in two frames.
  5. The gs: sentence: resolved.
  6. Cost evidence: the plan now measures what changed, with limits stated under Metal.

BLOCKERS

A. kernel/src/mm/stack.rs:117-127 — the refused-spawn path leaks a reference to the job. Stack { slot, pages, job: Arc::clone(job) } is built before map_stack, and when the map fails it is passed to core::mem::forget, which never drops its Arc<Job>. The #[expect] reason, "it holds no frame, and its slot went back above", is false: it holds a job reference.

  • Consequence: every spawn refused for want of a table frame leaves the job's strong count one too high for good, so the Job is never dropped: its allocation leaks and Job::drop's check that it ended holding nothing (kernel/pure/sched/job.rs:32) never runs for it. This is the memory-exhaustion path, where it can repeat.
  • This is the path the design names as "refused, nothing leaked".
  • Fix: do not construct a Stack until the map has succeeded (take the slot as a u32, compute the bottom from it), which also deletes the forget and its #[expect].

B. kernel/src/mm/stack.rs:61-70,73,87 — the slot lists grow into an allocator that panics, under the slot lock.

  • take_slot reserves room for fresh + 1 entries in both Vec<u32>s on every call. Vec doubles, so at fresh == 262144 the request is 2 MiB, which is over MAX_HEAP_ALLOC (2 MiB − 4 KiB), and KernelAllocator::alloc asserts on that size instead of returning null (kernel/src/mm/alloc.rs:542; mm/mod.rs:48 says a caller with an untrusted size "must check it themselves"). try_reserve does not turn an assert into an Err.
  • Consequence: once 262144 slots have been in use at once, every later spawn panics the kernel. The count is userland's: 4096 threads per process across 64 processes. It needs about 35 GiB of stack frames, more with each thread's heap charge, so no bench reaches it today and a large machine does. That is kernel-from-userland.
  • The comment is false there: "None where the range is spent or the lists cannot grow: a refused spawn". And spent (fresh == SLOTS, 2097152) is unreachable, an eighth of the way short, so the three places that handle it are dead code.
  • The same two lines are r1's blocker 3 in small: a heap allocation, which may grow the heap and zero 2 MiB inside it, with SLOTS_FREE held, behind which every other spawn and every stack drop on the idle path spins. It is rare (once per doubling), which is why I would not block on it alone.
  • Fix, implementer's choice: bound the range to what the bookkeeping can hold and refuse there, or keep the free lists without the heap. Unlinked in paging.rs already chains free tables through a not-present entry; a retired slot's cleared top leaf can hold the next slot's index the same way, which removes both Vecs, the reserve, the lock hold and the bound together.
  • kernel/src/mm/stack.rs:43-44 — "this many slots' addresses ... are the most that wait unused" is not true: after a mass exit with no spawn following, every dropped slot waits. What 64 bounds is how many retired slots a spawn passes over for a fresh one. Reword with the fix; the stated reason for the constant (one IPI round in at most 64 spawns against eight tables' worth of address) is otherwise sound, and unmeasured.

C. issues/the-kernel-stacks-range-keeps-every-table-it-ever-made.md:23, the commit message and the PR body — whose decision. The record's exit rests on "the owner accepted this bound when the range was designed"; the body says "the owner's decision" and "by the owner's ruling". The PR discussion holds no statement from the owner, and my brief calls it the orchestrator's decision after r1. r1 said a new kernel virtual region and an uncharged, never-freed allocation are the owner's to accept. If the owner ruled, say where and this is closed; if not, the record must not say so and its status is owner-bound, not accepted. Orchestrator's to settle; the record's sentence is the implementer's to match.

What holds

The mapper.

  • x86-64: seal_kernel_half installs a second-level table under every kernel root slot before the first user space, and UserTables::new copies slots 256..512 and asserts each present, so slot 510's PDPT is one shared table in every root, existing and future. Directories and leaf tables are written into that shared tree. stack_table asserts the root entry present.
  • AArch64: one TTBR1_EL1 root for every CPU; stack_table builds from the root down. bringup_root copies the root's entries as they are at that moment, which is fine: a CPU under it runs on its idle stack in the direct map.
  • Permissions: x86-64 leaf is present, global, writable, NX, no user bit; upper entries present and writable only. AArch64 leaf is valid, AF, global (no nG), PXN | UXN, EL1 read-write. Both through the one kernel_leaf.
  • A not-present entry made present owes no invalidation on either architecture; AArch64 follows with DSB ISHST; ISB.
  • Root 510 is 0xFFFF_FF00_0000_0000; the debug scratch leaf is in 511; mm::init refuses memory that would reach it.
  • No root other than the kernel's, a user space's or the loader's boot map is ever loaded, and nothing converts a stack address to a physical one (DirectMap::phys_of and from_ptr have no such caller).

The lazy flush. The invariant is sound as written.

  • A slot enters retired only after its leaves are cleared; waiting is read under the lock; the shootdown starts after that; only a flusher drains, one at a time (flushing), and only ..waiting. So every drained slot's leaves were clear before every CPU's flush.
  • Half::Kernel is Reach::Wide: INVPCID type 2 or a CR4.PGE toggle on every answering CPU, the initiator included. Before the machine answers it is a local flush, sound because an AP joins with a wide flush before it enters the scheduler.
  • Ring 0 is not preempted asynchronously here (the Ring 0 tick only polls deadlines; preemption is at a lock release), and shootdown takes no lock after it reads its CPU, so the preemptible call cannot migrate mid-round.
  • A slot that failed to map goes back to clean correctly: nothing was written for it.
  • The guard is never mapped in any incarnation, so a stale translation never names one.
  • AArch64's unmap broadcasts TLBI VAAE1IS per page between one pair of barriers, so nothing waits there; the batch still defers reuse, which only costs up to 64 slots of address.
  • Exposure. Not a meaningful new one. Reaching a stale translation takes a dangling pointer into a dead stack, and a stray kernel pointer can already write any frame through the direct map. Before this PR that write landed in freed kernel heap; now, until the flush, it lands in whatever got the frame, which can be another job's page, and after the flush it faults. The second half is an improvement and the first is the same class.

Accounting. Frames charged to the job and returned; the heap Charge no longer covers the stack. The tables' exception is recorded with a bound. Two small additions to that bound: spawns racing a flush also take fresh slots, so it is 64 plus those; and the slot lists are a second never-shrinking kernel allocation (8 bytes a slot, which blocker B's fix may remove).

Tests

  • No negative control for the range: not required before landing. The assertion is positive and specific: a #DF whose cr2 lies in the range computed from the slot's geometry. A mapped page cannot fault, so no unguarded stack passes it. What it does not show is that the whole gap is unmapped, which the map's own bound (pages leaves, asserted fewer than a slot) establishes by inspection. A control that would bite is mapping the whole slot: the first task's is slot 0, so the overflow would leave the range and the report would name no stack. It would prove little the assertion does not.
  • The untested thing that matters is reuse after a flush, whose failure is silent corruption. Its evidence is inspection, which I have done and accept. See the first suggestion.
  • IST3 untested: acceptable, same code and same hardware rule as IST2.
  • The fifth boot does what it claims: cr2=0xffff800000c21f18 is in IST1's guard page and the raw line is the second-entry arm.
  • a_kernel_stack_overflow_names_its_stack declares its target and why it is five boots. Hermetic and deterministic.

Evidence

  • Host log, read. 1 of 110 steps red: the build system: cargo test --lib, one test, llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key, failing in git under SDKROOT=/no/such.sdk as the macOS record describes. Independent of this change. The FAILED lines under kernel-loom are its mutation controls.
  • Guest rows. Run 42954073360 checked out 130d5e74f: 8 passed, 1 failed, the failure the fifth arm finding no DOUBLE FAULT on CPU line before the raw one. Run 42954506304 checked out 6f1f01b12: all five lines, pass. git diff 130d5e74f 6f1f01b12 is tests/common/faults.rs alone, inside that one test. So the set is sufficient for 6f1f01b12 and nothing needed rerunning there.
  • After the fixes mm/stack.rs changes, so rerun at the new head: a_kernel_stack_overflow_names_its_stack, std_threads_count_the_cpus_and_give_back_ended_threads, libc_children, and virt_smp and virt_user_mode for the shared code. The NMI and W^X rows need rerunning only if a paging.rs changes.
  • The staging logs name no revision, and both the base and the head image were staged from one worktree. Confirm the base image's revision from its own boot record before comparing.

Metal: what to run, once

Do not boot 6f1f01b12: blockers A and B change the code under measurement. At the fixed head, per bench (T14 and the AMD bench), two boots in one session:

  1. Base 9349705ce: spawn_cost, syscall_cost. Read spawn_cost: /system/bin/toybox min … with its median, syscall_cost: tsc … cycles/syscall over …, and the census tlb: line.
  2. Head: kernel_wx, smp_roster_and_tsc_trail, topology_readback, pmm_accounting, spawn_cost, syscall_cost, abuse_thread_table. All seven judges green, then read:
    • the two cost lines against the base's, same session. A process spawn takes exactly one kernel stack, so the per-stack cost (33 frames taken and zeroed one by one, 33 leaves written, and the same undone) is inside spawn_cost's absolute numbers. Neither value past twice the bench's recorded one.
    • tlb: shootdowns= wait= max= … guard= stack=. stack must be at least 1, or the flush path did not run and the boot shows nothing about it. guard=0 is expected: bring-up carves before the machine answers, which is an uncounted local flush. wait and max are machine-wide, not per origin, so compare them with the base's line.
    • What stack= means: abuse_thread_table spawns its threads one at a time and each exits before the next. If an exited, unjoined thread's stack is reaped at exit (not verified), that is about 4095 retire-and-reuse cycles and stack reads in the sixties, which is the best exercise of reuse after a flush this PR gets. If it reads low, the stacks were alive together and the boot exercised 512 tables and a second directory instead. Say which in the evidence. The body's "4096 threads in one process" should be corrected to whichever it is.
  • What these rows establish: every AP up on its idle stack through the boot map and the hard-lockup NMI on the carved IST2 (smp_roster_and_tsc_trail), the audit over slot 510's leaves and the image's guard on real firmware's map (kernel_wx), frames returned (pmm_accounting).
  • Not measured by any of it: the reaper's per-stack work on the idle path (33 leaf clears and 33 frame frees under two locks where one heap free was), and thread creation by itself.
  • AMD overflow delivery stays specified and unobserved. One hand-staged stack-overflow-nmi boot on the AMD bench would close it; orchestrator's call, not a landing condition.

Manual and records

  • docs/src/architecture/memory.md: the new section names no code and states the design. "Stops the machine with a report" is the design; AArch64's gap is recorded.
  • a-threads-kernel-stack-has-no-guard-page.md deleted: its exit is met on x86-64 except "a stack allocated without the guard reds it", which was shown for r1's design and not this one. Per Tests, I accept that.
  • the-kernel-stacks-range-keeps-every-table-it-ever-made.md: accurate apart from blocker C and the two additions under Accounting.
  • a-kernel-stack-overflow-on-aarch64-reports-nothing.md: accurate.
  • The two unrelated findings are filed and accurate; IDLE_GUARD_READ and idle_guard_byte do still exist with no caller.
  • issues/clippy-stage-two-is-lints-one-at-a-time.md:497-499 still describes write_stack_canary, check_stack_canary and OwnedAlloc::slice as sites that "say so"; all three are deleted here. One sentence to bring it up to date.

Overlap (semantic only)

SUGGESTIONS

  • kernel/src/mm/stack.rs:47-93 — the reuse decision (fresh, clean, retired, when a flush is owed) is the same shape as kernel::pcid::PcidPool, which is pure, host-tested and handed its lock and shootdown by the kernel. Putting this one beside it would give the only silent-failure path in the PR a host test for "no slot retired since the last flush is handed out".
  • kernel/src/arch/x86_64/paging.rs:677 — set accessed and dirty in a stack leaf, as Prot::leaf_bits does for user pages and for the same reason. These leaves are rewritten for every thread, so each new stack page's first touch otherwise pays the walker's write.
  • kernel/src/arch/x86_64/paging.rs:558, kernel/src/arch/aarch64/paging.rs:401 — assert the entries walked are tables, as Tables::directory does. mm::init keeps memory out of root 510 but map_mmio maps a device window wherever firmware put it, and one at 127 TiB would be read here as a table. Improbable, and silent if it happens.
  • kernel/src/arch/aarch64/paging.rs:438, kernel/src/arch/x86_64/paging.rs:688 — unmap_stack finds its table with the walk that creates one; a walk that only reads would make a missing table the panic the expect describes.

SEND BACK

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator, recording the design decision behind the rework (review r2's blocker C asks whose it is). It is mine, taken after review r1 and reported to the owner; it is not an owner ruling, and the record, commit message and PR body should say "the orchestrator's decision".

  • Thread stacks are mapped in a kernel virtual range of their own with gaps that are never mapped, not as holes in the direct map. The guard then holds on every CPU by construction, no direct-map leaf is split at run time, and a dropped stack's frames return to the allocator and are charged to the job that holds them.
  • A dropped slot's address is reused only after a flush, issued by a spawn when no clean slot is left; an exit pays nothing. A spawn that can neither find a clean slot nor flush is refused.
  • The range's page tables are the kernel's own and never freed, because a table freed without a flush can still be walked through another CPU's paging-structure cache. They are bounded and not charged to a job; a spawn that cannot get one is refused.
  • Per-CPU stacks (idle, IST, boot) stay carved out of the direct map at bring-up: an x86-64 AP runs on its idle stack under the boot map, which does not hold the range.

The tradeoff accepted is one new mechanism, a 4 KiB kernel mapper and unmap on both architectures, kept to what stacks need.

Japabu added a commit that referenced this pull request Nov 11, 2026
…ed spawn leaks nothing

A spawn refused for want of a table frame built a Stack holding its
job's Arc and then forgot it, so the job's count stayed one too high for
good. The Stack is now made only once the map has succeeded.

The slot lists were two heap Vecs grown under the slot lock, whose
doubling reaches the allocator's size assert at 262144 slots. Which slot
a stack is handed, and when a retired one is clean, is now
kernel::stackslots: three fixed bitmaps over 262144 slots, so nothing
allocates, a take past that is refused, and the range's bound is what
the bookkeeping holds. Its host tests hold the one silent-failure path:
no slot retired since the last flush is handed out, a slot retired
during a flush waits for the next, and no more than the batch beyond the
peak is ever used.

A stack leaf is written accessed and dirty on x86-64, the range's walk
asserts it meets tables, and an unmap looks its table up without making
one.

The range and its never-freed tables are the orchestrator's decision,
recorded on the pull request; the record is a question for the owner and
no longer claims an acceptance that was not given.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Round three is at 6dc26300d. Evidence: pr-1211 at e7ad8b3d.

A (the leaked Arc<Job>). Fixed: the slot is taken as a number, the map is tried, and the Stack is built only on success. The forget and its #[expect] are gone.

B (the slot lists). Replaced by kernel::stackslots beside PcidPool: pure, three fixed bitmaps over 262144 slots, no heap. The range's bound is now what the bookkeeping holds (RANGE is still root slot 510; the slots cover its first 64 GiB), there is no unreachable spent arm, and a take past the bound is refused. The false doc sentences are rewritten, the batch constant's among them.

C (whose decision). The record is kind: question, status: owner, says the design is the orchestrator's decision recorded on this PR, and asks the owner the one thing that is his: an uncharged, never-returned allocation at this bound. The PR body says the same. The commit message of 130d5e74f still says "the owner's design"; it is not rewritten.

Suggestions. All taken: the pure host-tested reuse decision, accessed and dirty on x86-64 stack leaves, the table asserts on both architectures, the read-only walk in unmap_stack. The clippy-stage-two record's sentence is brought up to date, the two additions to the tables' bound are in the record, and the body's description of abuse_thread_table is corrected.

The host test needed a second pass. A control that makes retired slots clean before the flush is done reds three of the five tests. My first property test ran each flush straight through and stayed green under it; 6dc26300d lands a take and a drop inside every flush.

Rows. The ones you listed plus nested_nmi_is_loud and kernel_wx_leaf_is_refused (both paging.rs changed) are green at 4b1ca3bcd, run 42980721935. The full host run with lint is at 6dc26300d.

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r3 of #1211 at 6dc26300d (base 9349705ce)

Scope: 6f1f01b12..6dc26300d, two commits. Read: both commits, the whole diff, kernel/pure/stackslots.rs and kernel/src/mm/stack.rs in full, both stack_table/map_stack/unmap_stack, alloc_held/free_held in pmm.rs and kernel/pure/frames.rs, x86-64 tlb::shootdown and wait_for, alloc_kernel_stack, the record, the manual's section, the PR body, and r3/* at evidence e7ad8b3d (host log's failure list, guest log, slots log, control diff and log, staging logs). Nothing was built or run; one figure under D is my own arithmetic over the model as read, labelled there.

Round 3 net: production +200/−84 (+116: stackslots is 133 lines and stack.rs loses 32 net), tests +128, records and manual +21/−11.

Architecture

The fix for B is the right shape. The reuse decision is now one pure state machine with no heap, no allocator assert in reach and a bound that is the bookkeeping's own; the kernel side is a twelve-line loop that supplies the lock and the flush. Stack is built only after the map succeeds, so the refusal path has nothing to forget. What does not hold is the stated bound on the tables the range keeps (D): the model's own test bound shows it.

Round 2's blockers

A. Resolved. kernel/src/mm/stack.rs:91-104: the slot is a u32, Stack is constructed only in the arm where map_stack returned true, and the forget and its #[expect] are gone. Every early return of creation, checked:

  • try_reserve_exact fails: nothing taken.
  • alloc_held refuses: whole or nothing (kernel/pure/frames.rs:804, admit before the first take_frame), so frames is empty.
  • take_slot is None: frames go back through free_held, no slot held.
  • map_stack is false: frames go back, the slot goes to unused. stack_table fails before any leaf is written (one table per stack, since eight slots share one), and a directory made before the failure stays linked for the next spawn.
  • alloc_kernel_stack: the Charge drops on ?. No Arc<Job> is cloned on any refused path.

B. Resolved.

  • No heap under the slot lock: take, retire, unused and flushed are bit operations over three fixed sets. The allocator's size assert is unreachable from here.
  • The bound is the bookkeeping's: SLOTS = 1 << 18, 64 GiB of root slot 510's 512 GiB. Spent is reachable and tested (a_spent_range_flushes_for_one_retired_slot_and_refuses_with_none), and it is a refused spawn with the frames returned.
  • It is the code the kernel runs: kernel/src/mm/stack.rs:26 uses kernel::stackslots from the package's library (kernel/Cargo.toml [lib] path = "pure/lib.rs"), the same unit cargo test -p kernel --lib tests. No copy.

C. Resolved. The record is kind: question, status: owner, attributes the range and the allocation to the orchestrator's decision recorded on this PR and says the owner has not ruled. The body says the same. The manual attributes nothing. 130d5e74f's message still says otherwise; the squash takes the body.

The pure model, judged against its invariant

The invariant is held, by the model and by the kernel's use of it.

  • Model. A retired slot moves retired → flushing only inside the take that answers Flush, and flushing → clean only in flushed. A slot retired after that take lands in retired and is not moved by that flush's flushed. in_flush admits one flusher; a second taker meanwhile gets a never-used slot or Spent, never Flush and never a slot of flushing.
  • Kernel. Every operation is one hold of SLOTS; the only window is between take → Flush and flushed, with the lock released. Stack::drop clears the leaves before retire, so a slot in flushing had clear leaves before the shootdown started. shootdown returns only when every CPU has answered and panics on one that does not (wait_for), so flushed is never reached on an incomplete round. A stack that drops between its unmap and its retire across a whole flush simply waits for the next: conservative.
  • Tests. Deterministic, hermetic, one claim each. Two mutation classes matter and both are caught:
    • clean at request instead of at completion: the committed control, 3 of 5 red, and the log's panic lines (173, 193, 229) are the head's file (blob c92cd8c2e, which is what the control diff is against).
    • a slot retired during a flush made clean by that flush: by inspection a_slot_retired_during_a_flush_waits_for_the_next reds at its last assert (Slot(64) where Slot(66) is expected), and the property test reds when dropped comes back while still in stale.
  • The property test's interleaving is one take then one drop inside every flush; the drop-then-take order is the dedicated test's. With every operation atomic under the lock, that covers the orders a second CPU can produce. I ask for nothing more here.

BLOCKER

D. docs/src/architecture/memory.md:28-31, issues/the-kernel-stacks-range-keeps-every-table-it-ever-made.md:16-20 — the tables' bound is not the peak, and the manual says it is.

  • A take that lands inside a flush always takes a never-used slot (kernel/pure/stackslots.rs:107-111: clean is empty when the flush is asked for), and nothing ever lowers fresh. In steady state a flush is asked for only when every slot not live is retired, so each raced flush adds one slot to the set in circulation for good.
  • The test states this itself: kernel/pure/stackslots.rs:258 bounds fresh by peak + FLUSH_BATCH + raced, and raced is the count over the whole run, not a number alive at once.
  • My arithmetic over the model as read (not a measurement, not the tree's test): one thread alive at a time and one more spawn inside every flush, so a peak of 2, uses 78 slots after 10^3 spawns, 452 after 10^5, 4473 after 10^7 (560 tables, 2.2 MiB). That is about sqrt(2 × spawns); several racers per flush multiply it.
  • Consequence: two threads of one process that spawn and join in a loop grow a kernel allocation that is charged to no job and never returned, toward the range's 128 MiB, while holding two stacks. At the range's limit a spawn that races a flush is refused with almost every slot free. The second needs on the order of 2^35 spawns with a racer in every flush; the first is ordinary concurrent thread creation over uptime.
  • The manual's "one for each eight stacks ever in use at once, about 2 MiB for a process at its thread limit" is false as a bound. The record's sentence is literally true ("one for each spawn that raced a flush") but its two figures tie 2 MiB to a process's thread limit and 128 MiB to 262144 stacks alive, and "this bound" is what is put to the owner. Round 2 called this term a small addition; the model shows it is the term that is not bounded, so I am reopening that sentence on new evidence, not the design.
  • Required: the record and the manual say that the slots ever handed out are not bounded by the stacks alive at once, only by the range, and what happens at the range's limit. Text alone settles this finding and invalidates no evidence. Making the bound true instead (the record's own exit, or a taker that does not draw a never-used slot beside a flush) is a design choice for the orchestrator, not something I require.

What else I checked

  • Suggestions taken, all correct.
    • kernel/src/arch/x86_64/paging.rs:685 — accessed and dirty preset on a writable, non-executable, global leaf; the comment gives the reason and is true.
    • kernel/src/arch/x86_64/paging.rs:575, kernel/src/arch/aarch64/paging.rs:415 — the PDPT and PD entries are asserted not leaves; AArch64 asserts TABLE at levels 0 to 2, on a made entry as well.
    • unmap_stack on both passes make: false, so a missing table is the expect's panic and nothing is allocated on the drop path.
    • issues/clippy-stage-two-is-lints-one-at-a-time.md:503 — true: both canary sites and OwnedAlloc are deleted on this branch.
    • The body's abuse_thread_table description matches what r2 asked for.
  • Comments. The two module headers say what a reader needs and are true, apart from the suggestions below.

Evidence

  • Guest rows stand for 6dc26300d. Run 42980721935 fetched and checked out 4b1ca3bcdb37…, 8 passed of 8, the five overflow lines as quoted in the body. git diff 4b1ca3bcd 6dc26300d is kernel/pure/stackslots.rs from line 142 down, inside #[cfg(test)] mod tests, which no kernel image contains. Both paging.rs changed in 4b1ca3bcd, and the NMI and W^X rows r2 made conditional on that are in the run.
  • Host log, read. 1 of 110 step(s) red: the build system: cargo test --lib, 383 passed and 1 failed, llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key, dying in git under /no/such.sdk as the macOS record describes; the keyed_and_built failure above it is that test's own child process. Every other FAILED is under a control step that reached its verdicts. The five stackslots tests are in the kernel's library step, and clippy's kernel invocations for both targets are in the run. The log names no revision; its first step is stamped ten seconds after 6dc26300d's commit time, which is consistent and not proof.
  • Control. Builds, exit 101, 3 of 5 red as claimed; see above for which revision it ran on.

Metal: what changed in the checklist

Round 2's checklist stands. Changes:

  1. The images are the ones staged at 6dc26300d (r3/metal-prepare-t14.log, r3/metal-prepare-amd.log) and the base at 9349705ce; anything staged at 6f1f01b12 no longer stands. If D is answered in Markdown only, these images stay valid.
  2. Both base and head were again staged from one worktree, minutes apart. Confirm each image's revision from its own boot record before comparing the cost lines, as the body already says.
  3. stack= reads as before: about 63 if abuse_thread_table's stacks retire one by one, low if they were alive together. Nothing a boot prints shows the table count, so D is not something metal can read.
  4. The branch conflicts with main (cb3f9c062) in kernel/src/arch/x86_64/paging.rs only: main deleted wrote_code beside where this branch adds map_stack. Landing needs a rebase and the same range-diff check x86-64's boot map holds no page writable and executable #1212 got. The base and head cost pair is the right comparison only as staged, before the rebase.

SUGGESTIONS

  • kernel/pure/stackslots.rs:19-20 — "a take asks for one at most once in this many" is not true of a spent range, where one retired slot is enough (line 102, and the test named for it). Add the exception or drop the clause; the body's "at most one shootdown in 64" has the same gap.
  • kernel/src/mm/stack.rs:43-44 — take_slot also answers None when the only free slots wait on another taker's flush; Take::Spent says so and this does not.
  • kernel/src/mm/stack.rs:41 — the static SLOTS (the lock) and kernel::stackslots::SLOTS (a count), which the header four lines above it names, are two things under one name in one file.
  • kernel/pure/stackslots.rs:144-145 — two blank lines where the helper was deleted.

For the orchestrator

SEND BACK

Japabu added a commit that referenced this pull request Nov 11, 2026
Review r1 of #1211, to the owner's design. A thread's stack as a hole in
the direct map could not give its memory back, needed a wide shootdown
to make each new guard real, and split direct-map leaves on the spawn
path with a table allocation that panicked.

mm::stack::Stack is now frames its job holds (pmm::alloc_held), mapped
at the top of a 256 KiB slot of root slot 510, which the direct map never
reaches. The rest of every slot is never mapped in any incarnation, so
the guard is true on every CPU with nothing to flush and no direct-map
leaf is split at run time. A dropped stack's frames go back at once and
its slot's address waits: a spawn that finds no clean slot and 64 retired
ones issues one kernel-half shootdown and takes them, otherwise it takes
a slot never used; one that can do neither is refused. Until that flush
a stale translation reaches only what a kernel use-after-free of the
dead stack would, the exposure every frame has through the direct map.
On AArch64 the unmap broadcasts each page's invalidation itself.

The range's tables are the kernel's own and are never freed, since a
paging-structure cache may still name one: one for each 8 slots ever in
use at once, a directory for each 4096
(issues/the-kernel-stacks-range-keeps-every-table-it-ever-made.md). A
spawn that cannot get a frame for one is refused. No lock is held across
an allocation or a zeroing.

A CPU's own stacks stay holes in the direct map, carved at bring-up from
the kernel's own superframes: an x86-64 AP runs on its idle stack, and
loads the IDT that names its IST stacks, under the loader's map, which
holds no other range.

stack-overflow-report runs the #DF report itself off IST1: the second

The record of a pool that never returned memory goes with the pool. The
AArch64 record loses its stack-probe sentence: the pinned target probes
inline, as a disassembled frame shows. Two unrelated findings are filed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…ed spawn leaks nothing

A spawn refused for want of a table frame built a Stack holding its
job's Arc and then forgot it, so the job's count stayed one too high for
good. The Stack is now made only once the map has succeeded.

The slot lists were two heap Vecs grown under the slot lock, whose
doubling reaches the allocator's size assert at 262144 slots. Which slot
a stack is handed, and when a retired one is clean, is now
kernel::stackslots: three fixed bitmaps over 262144 slots, so nothing
allocates, a take past that is refused, and the range's bound is what
the bookkeeping holds. Its host tests hold the one silent-failure path:
no slot retired since the last flush is handed out, a slot retired
during a flush waits for the next, and no more than the batch beyond the
peak is ever used.

A stack leaf is written accessed and dirty on x86-64, the range's walk
asserts it meets tables, and an unmap looks its table up without making
one.

The range and its never-freed tables are the orchestrator's decision,
recorded on the pull request; the record is a question for the owner and
no longer claims an acceptance that was not given.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
A take that landed inside a flush drew a never-used slot, and the count
of slots ever used never falls, so every raced flush grew the range's
never-freed tables for good: two threads spawning and joining in a loop
could walk an uncharged kernel allocation toward the range's limit while
holding two stacks.

A take that finds no clean slot while a flush is under way, or with a
batch of retired slots waiting, now answers Flush and changes nothing.
The kernel passes such a taker through a gate one at a time: the first
flushes, the rest find the slots it cleaned. The wait at the gate is the
flusher's shootdown, which ends or panics on a CPU that does not answer.
A never-used slot is drawn only where fewer than the batch are retired
and no flush is under way, so the slots ever used are at most the most
alive at once and 64 more, which the model's test now asserts with no
term for raced takes.

The tables' record states that bound and is a finding again, not a
question for the owner.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
Review r1 of #1211, to the owner's design. A thread's stack as a hole in
the direct map could not give its memory back, needed a wide shootdown
to make each new guard real, and split direct-map leaves on the spawn
path with a table allocation that panicked.

mm::stack::Stack is now frames its job holds (pmm::alloc_held), mapped
at the top of a 256 KiB slot of root slot 510, which the direct map never
reaches. The rest of every slot is never mapped in any incarnation, so
the guard is true on every CPU with nothing to flush and no direct-map
leaf is split at run time. A dropped stack's frames go back at once and
its slot's address waits: a spawn that finds no clean slot and 64 retired
ones issues one kernel-half shootdown and takes them, otherwise it takes
a slot never used; one that can do neither is refused. Until that flush
a stale translation reaches only what a kernel use-after-free of the
dead stack would, the exposure every frame has through the direct map.
On AArch64 the unmap broadcasts each page's invalidation itself.

The range's tables are the kernel's own and are never freed, since a
paging-structure cache may still name one: one for each 8 slots ever in
use at once, a directory for each 4096
(issues/the-kernel-stacks-range-keeps-every-table-it-ever-made.md). A
spawn that cannot get a frame for one is refused. No lock is held across
an allocation or a zeroing.

A CPU's own stacks stay holes in the direct map, carved at bring-up from
the kernel's own superframes: an x86-64 AP runs on its idle stack, and
loads the IDT that names its IST stacks, under the loader's map, which
holds no other range.

stack-overflow-report runs the #DF report itself off IST1: the second

The record of a pool that never returned memory goes with the pool. The
AArch64 record loses its stack-probe sentence: the pinned target probes
inline, as a disassembled frame shows. Two unrelated findings are filed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…ed spawn leaks nothing

A spawn refused for want of a table frame built a Stack holding its
job's Arc and then forgot it, so the job's count stayed one too high for
good. The Stack is now made only once the map has succeeded.

The slot lists were two heap Vecs grown under the slot lock, whose
doubling reaches the allocator's size assert at 262144 slots. Which slot
a stack is handed, and when a retired one is clean, is now
kernel::stackslots: three fixed bitmaps over 262144 slots, so nothing
allocates, a take past that is refused, and the range's bound is what
the bookkeeping holds. Its host tests hold the one silent-failure path:
no slot retired since the last flush is handed out, a slot retired
during a flush waits for the next, and no more than the batch beyond the
peak is ever used.

A stack leaf is written accessed and dirty on x86-64, the range's walk
asserts it meets tables, and an unmap looks its table up without making
one.

The range and its never-freed tables are the orchestrator's decision,
recorded on the pull request; the record is a question for the owner and
no longer claims an acceptance that was not given.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
A take that landed inside a flush drew a never-used slot, and the count
of slots ever used never falls, so every raced flush grew the range's
never-freed tables for good: two threads spawning and joining in a loop
could walk an uncharged kernel allocation toward the range's limit while
holding two stacks.

A take that finds no clean slot while a flush is under way, or with a
batch of retired slots waiting, now answers Flush and changes nothing.
The kernel passes such a taker through a gate one at a time: the first
flushes, the rest find the slots it cleaned. The wait at the gate is the
flusher's shootdown, which ends or panics on a CPU that does not answer.
A never-used slot is drawn only where fewer than the batch are retired
and no flush is under way, so the slots ever used are at most the most
alive at once and 64 more, which the model's test now asserts with no
term for raced takes.

The tables' record states that bound and is a finding again, not a
question for the owner.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Round four is at 6d7e3557a, on 422915e2c (main with #1212 merged). Evidence: pr-1211 at 80f0cf23.

D. Fixed in code, by the orchestrator's decision. Slots::take answers Flush, changing nothing, whenever no slot is clean and a flush is under way or a batch is waiting; a never-used slot is drawn only with fewer than the batch retired and no flush in flight. The kernel sends such takers through FLUSH_GATE one at a time: the first calls begin_flush and flushes, the rest find begin_flush false and the slots it cleaned. The wait at the gate is the flusher's shootdown, stated at the site. The property test now asserts fresh <= peak + FLUSH_BATCH with no raced term and that a take beside a flush is Flush; a control that lets it draw fresh reds 3 of 5.

The record. kind: finding, status: open, with the bound as it now is and the decision named as the orchestrator's. The manual's line matches.

Suggestions. All four taken: the batch constant's doc no longer claims "at most once in this many" for a spent range, take_slot's doc is true now that Spent means every slot is in use, the lock is IN_USE so SLOTS names one thing, and the stray blank lines are gone.

Rebase. Twice: onto cb3f9c062 with the paging.rs conflict you named (wrote_code stays deleted), and onto 422915e2c once #1212 merged, where the only conflict was the umbrella record's list. The code both PRs touch merged without one.

Checks at the head. The guest rows you listed are green (run 43013777372), the full host run with lint has its one recorded red, and both benches are staged once with the baseline at 422915e2c.

Not exercised: the gate with two real takers. The model holds the decision; no row puts two CPUs' spawns inside one flush.

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r4 of #1211 at 6d7e3557a (base 422915e2c)

Scope: what changed since 6dc26300d. git range-diff 9349705ce..6dc26300d 422915e2c..83095bf81 for the rebase, then 6d7e3557a in full. Read: kernel/pure/stackslots.rs and kernel/src/mm/stack.rs whole, sync.rs (Lock::acquire), x86-64 tlb.rs (shootdown, wait_for, poll), shootdown.rs, AArch64 tlb.rs, preempt.rs, both spawn call sites, alloc_pcid, #1212's diff (smp.rs, control_regs.rs, main.rs, lib/bootmap), kernel_main's region list as merged, the record, the manual's section, the PR body, the log of guest run 43013777372, and the evidence branch. Nothing was built or run; the test traces below are by hand.

Round 4 net: production +58/−26 (about half of it comment), tests +38/−33, record and manual +18/−21.

Architecture

The fix is the small one. The model gains one predicate (owed) and one entry point (begin_flush); take no longer changes state when it answers Flush; the kernel adds one lock. Nothing was added to make the bound true except the wait itself, and the two-lock split is what the design needs: IN_USE is held for bit operations only, so a drop on the reaper's path never waits behind a shootdown, while FLUSH_GATE is the only thing held across one. It is the same pattern, with the same justification, as alloc_pcid holding PCID_POOL across its reclaim shootdown (kernel/src/arch/x86_64/paging.rs:432-446).

D: resolved. The bound is true

By construction, not by test. Every slot below fresh is in use, clean, retired or flushing, and in one only (Set::insert asserts it). A never-used slot is drawn at one place (kernel/pure/stackslots.rs:116-120), reached only with no clean slot, no flush under way (so flushing is empty) and fewer than 64 retired. So after any draw fresh is at most the slots in use and 63. No interleaving of kernel operations changes that, because each is one hold of IN_USE and the condition is checked in the hold that draws.

  • "In use" is a slot from take to retire or unused: a Stack being built, alive, or inside drop. An exited thread not yet reaped still holds one. The record's "stacks alive at once" is the right word.
  • The record's figures follow: (4096 + 64) / 8 = 520 tables, 2.03 MiB; 262144 / 8 tables = 128 MiB.

What a waiter does when it passes the gate. At the gate no flush is under way. begin_flush then either flushes (at least one slot becomes clean, 64 unless the range is spent), or returns false because slots are clean or nothing is owed. The taker's next take:

  • finds a clean slot; or
  • finds none clean (others took them), fewer than a batch retired and no flush begun: draws a never-used slot. This is inside the bound, since the draw's own condition holds; or
  • finds none clean, the range spent and nothing retired: Spent, a refused spawn, and now true to its comment (every slot is in use); or
  • answers Flush again, only if another taker has begun a flush or a new batch has retired since.

A flush that cleans fewer slots than there are waiters (more than 64 CPUs, or a spent range where one retired slot is a flush) sends the surplus down the second, third or fourth arm above. None is unbounded in fresh.

Livelock and starvation. With the other CPUs idle a taker ends in at most two passes: there is no state in which take answers Flush and begin_flush answers false twice running without another CPU having acted. A taker repeats only when others took every slot it cleaned (64 completed takes) and a further batch retired or a flush began, so each repeat is paid for by other spawns succeeding. That is not a per-taker bound, as with any retry under contention; the gate itself is FIFO. I do not ask for more.

The gate

  • What it is. sync::Lock<()>, the kernel's FIFO ticket spinlock. acquire raises the preempt count before drawing its ticket and leaves IF as it found it (kernel/src/sync.rs:136-162). Each spin calls arch::tlb::poll.
  • Holder. Preemption off across the shootdown, interrupts as the syscall had them. It answers itself locally (tlb.rs:171) and answers any other initiator from wait_turn's serve_if_owed whatever IF is, so a holder that is another CPU's target cannot deadlock with it, and two initiators serve each other.
  • Waiter. Spins with preemption off and polls, so it answers the holder's shootdown. That is what makes holding across the shootdown legal, and the comment says so.
  • Order. The spawn path holds nothing when it reaches take_slot; then FLUSH_GATE, then IN_USE. IN_USE is never held when the gate is taken (kernel/src/mm/stack.rs:54 binds the answer; the if condition's guard at :60 is dropped before the body, so :65 locks again and does not nest). Stack::drop and the failed-map arm take IN_USE alone, after the paging lock and the pmm are released. shootdown takes no lock. Nothing on the reaper's path touches the gate.
  • Callers. alloc_kernel_stack only: spawn_thread (kernel/src/process.rs:578, "outside any lock") and process spawn (kernel/src/loader/mod.rs:178, after the child space's lock scope ends). Both are syscall context holding nothing. No handler and no idle-path caller, so no caller that must not wait.
  • The wait's failure bound: sufficient. Each hold is one shootdown, which ends or panics after DEAF_CPU (5 s, wait_for). Waiters spin with preemption off, so at most one per CPU is ahead. The lock itself reports at 50M spins and panics at 500M. Expiry here means a CPU that takes no interrupt, a kernel invariant, and it is loud; refusing the spawn instead would hide it. The comment's quantity is slightly off (first suggestion).

The model and the earlier invariant

  • Still the code the kernel runs. kernel/src/mm/stack.rs:26 uses kernel::stackslots from the package's library, as in r3. No copy.
  • The invariant holds with the gate. A slot moves retired to flushing only in begin_flush, under IN_USE and before the shootdown starts, and flushing to clean only in flushed. A slot retired in between lands in retired and waits for the next. Stack::drop clears leaves before retire. The gate adds exclusion and removes nothing: begin_flush asserts no second flush, and the flusher can no longer be preempted mid-round.
  • Tests, traced by hand against the head's file. All five pass as I read them. The property test's bound assert is fresh <= peak + FLUSH_BATCH with no raced term, checked after every operation, and it now asserts directly that a take inside a flush answers Flush.
  • The control bites, by inspection. With a take beside a flush allowed to draw: the take-beside-a-flush test gets Slot(66) where it expects Flush; the spent-range test gets Spent where it expects Flush; the property test fails "a take beside a flush drew a slot" at its first flush. Three of five, as claimed. The published diff and log are still owed (Evidence).

Cost, and what the staged boots can show

  • What changed. A spawn that meets another's flush used to draw a slot at once and now spins for the rest of that shootdown, then takes a cleaned slot. In steady state that is at most once per 64 retired stacks; in the worst case a waiter sits behind one hold per other CPU. It is the same order as the shootdown any spawn already pays when it is the flusher.
  • spawn_cost will not show it. It is one process spawning 51 children one after another (tests/guest/rust-tests/src/bin/spawn_cost.rs), so no taker ever meets another's flush. It shows the per-stack cost, as before, and at most one of its spawns carries a flush, which neither its minimum nor its median moves on.
  • The tlb: line shows the flushes, not the waiting. stack= counts shootdowns issued; wait and max are the initiator's time over every origin. A waiter's spin is counted nowhere. max bounds any one hold of the gate.
  • So the contended gate is argued, not measured, and no staged row can measure it. I accept that: the bound per waiter is by inspection and a row built to contend the gate would be a benchmark, not a judge. The body should say it under "not measured".

Record and manual

  • issues/the-kernel-stacks-range-keeps-every-table-it-ever-made.md:17-24 — true now, sentence by sentence: the slots ever used, when a never-used one is drawn, that a spawn meeting a flush waits, and both figures.
  • docs/src/architecture/memory.md:28-31 — true, and names no code.
  • The frontmatter change is under "For the orchestrator".

The rebases

Evidence

  • Guest rows at the head: sufficient. Run 43013777372, read from its log: it fetched 6d7e3557a4632fd7…, 8 passed of 8, the five overflow lines present, both AArch64 rows among them. That also shows both kernels build at the rebased head.
    • Its limit: a passing row keeps no serial and none is known to retire 64 stacks, so no machine is known to have run the flush arm under the gate. Metal's stack= is where that is read.
  • Not yet at the linked evidence (branch pr-1211 is still e7ad8b3d, round 3). What I need to see, at 6d7e3557a:
    1. The host check's log: only the macOS llvm test red; the kernel's library step listing the five stackslots tests under their new names; clippy's kernel invocations for both targets. The last is what covers the heap-tripwire, stack-witness and actuator arms after a rebase across four pull requests.
    2. The control's diff and log, 3 of 5 red, with the three tests named above.
    3. The evidence README and the body's link moved to that commit.
  • If any of these reads otherwise, the branch comes back. Nothing else in the tree is waiting on them.

Metal: what changed in the checklist

  1. Images are the ones staged at 6d7e3557a and the base at 422915e2c; confirm each from its own boot record. Anything staged at 6dc26300d or 9349705ce no longer stands.
  2. stack= at least 1 on each bench's head boot is now also the only run of the gate's flush arm on a machine. A mistake there is a DEADLOCK at kernel/src/mm/stack.rs or a stack slots: two flushes at once panic, not a silent pass.
  3. Read each head boot's serial for LOCK CONTENTION naming kernel/src/mm/stack.rs. There must be none.
  4. stack= should be about the stacks retired divided by 64: in the sixties if abuse_thread_table's stacks retire one by one, as before. A count well above that would mean flushes for less than a batch.
  5. The base now contains x86-64's boot map holds no page writable and executable #1212, so smp_roster_and_tsc_trail at the head is the first metal boot of APs on their idle stacks under the W^X boot map.
  6. The rest of round 2's and round 3's checklist stands.

SUGGESTIONS

  • kernel/src/mm/stack.rs:43-46 — "waits here, for no longer than that shootdown takes" is one hold, not the wait. Takers ahead in the queue may each flush (in a spent range each retired slot is a flush), so the wait is at most one shootdown per CPU ahead. Say that, or drop the quantity and keep "each hold is one shootdown, which ends or panics".
  • kernel/src/mm/stack.rs:12-13 — "a table for each 8 slots ever in use at once" omits the 64 more, the shortfall the manual and the record have just corrected. kernel::stackslots' header states it; this one can point there.
  • kernel/pure/stackslots.rs:260-262 — the property test's continue is unreachable with one taker: there take answers Flush only when begin_flush will flush. assert!(slots.begin_flush()) makes a model that asks for a flush nothing is owed fail instead of spin.

Each is comment or test text, changes no image, and can ride any later commit.

For the orchestrator

  • The question to the owner is withdrawn by this commit. Round 3's record was kind: question, status: owner, said the owner had not ruled, and asked whether an allocation charged to no job and never returned is accepted. The head's record is finding, open; the sentence and the question are gone, and freeing tables behind a flush moved into the exit as contingent on a workload. D's fix made the bound true; it did not answer that question.
    • I do not hold it as a blocker: the record still attributes the decision to you, which is what your settlement of C asked for, and nothing in it claims an owner ruling.
    • What it changes: nothing in issues/ now waits on the owner about this allocation, while the body still says it is put to the owner. If the owner is to rule, restore the frontmatter and the last paragraph (Markdown only, no evidence touched). If not, the body's sentence goes.
  • Body, now false or stale:
    • "or one that finds the only free slots waiting on another spawn's flush, is refused": it waits; a spawn is refused only when every slot is in use.
    • "otherwise it takes a slot never used. So a spawn pays at most one shootdown in 64": a spawn beside a flush waits for it, and a spent range flushes for one retired slot.
    • "(at most 64 more than the most threads alive at once)": true now; "stacks", to match the record.
    • The kind: question sentence, per the item above.
    • Head, base, "not yet rebased", the list of open pull requests the files overlap (A child's arguments and environment are memory its parent lays out #1200 and x86-64's boot map holds no page writable and executable #1212 have landed), the checks table, the evidence link, the metal paragraph's revisions.
    • The five quoted overflow lines are 4b1ca3bcd's; the boot stack's guard is at 0xffff80007a314000 in the head's run.
    • Add under "not measured": a spawn waiting at the gate.
  • a3373a7e4's message lost the line that began #DF to git's comment stripping in the rebase, leaving "the second" hanging. The squash takes the body, so nothing to do.

LAND, once the three evidence items above are published and read as stated. No blocker remains in the tree.

Japabu added a commit that referenced this pull request Nov 11, 2026
…nd are, and the model's test refuses an unowed flush

The gate's wait is one shootdown for each taker ahead, not one; the
header's table bound pointed at slots in use at once and now points at
kernel::stackslots, which bounds the slots ever used. The property
test's lone taker asserts begin_flush instead of looping where it
answers false.

Comment and test text only: both comments keep their line counts, so no
panic location moves, and the x86-64 kernel builds to the same bytes
before and after.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Japabu and others added 8 commits November 11, 2026 15:09
A thread's kernel stack was a heap allocation with live heap on both
sides and a canary word a scheduler pass read after the fact; an IST
stack's guard was a filled page; an AP ran its bring-up on a leaked heap
block; the boot stack began on the page after the image. Only the idle
stacks had an unmapped page under them.

mm::stack::Stack is now the one representation: whole pages carved out
of a superframe taken as the heap takes its own (so a thread's Charge
still covers it), above a page guard_4k takes out of the direct map,
followed by a kernel-half shootdown so no CPU keeps the superframe's
2 MiB translation. A thread's stack, each CPU's idle stack and each IST
stack are Stacks. An AP runs on its idle stack from its entry, so the
bring-up stack is gone. The loader leaves a page between the image and
the boot stack and the kernel's tables leave it unmapped. The canary and
the heap bands' read of a live stack go with what they stood in for.

On x86-64 a fault on a guard page cannot push its frame and is a #DF on
IST1, whose report now names the stack (KERNEL STACK OVERFLOW: ... under
the kernel stack of pid= tid= / cpuN's idle stack / cpuN's ISTn stack /
the boot stack); a kernel #PF that still has stack says the same. A #DF
loads IST1's top whatever it interrupted, so an IST stack's overflow,
IST1's included, reaches the handler; the handler reports once and a
second #DF on its own CPU ends the report with one raw line instead of
restarting it.

a_kernel_stack_overflow_names_its_stack runs each kind of stack off its
bottom with a stack-overflow-* actuator, one boot each.

AArch64 gets the same guard pages and still reports nothing, since its
vectors run on the stack that overflowed:
issues/a-kernel-stack-overflow-on-aarch64-reports-nothing.md. A dropped
stack's memory stays in the pool:
issues/a-dropped-kernel-stack-never-goes-back-to-the-frame-allocator.md.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A guard page's flush names its CPU (`tlb::flush` records it against
`percpu::cpu_id()` once the tables are sized), and `init_bsp` carved the
idle and IST stacks before the `wrmsr` that makes `gs:` valid: every
x86-64 guest reset there, with no IDT to report the fault (guest-rows
run 42939714086). The stacks are carved after the control registers are
declared and before the IDT whose gates name them.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Review r1 of #1211, to the owner's design. A thread's stack as a hole in
the direct map could not give its memory back, needed a wide shootdown
to make each new guard real, and split direct-map leaves on the spawn
path with a table allocation that panicked.

mm::stack::Stack is now frames its job holds (pmm::alloc_held), mapped
at the top of a 256 KiB slot of root slot 510, which the direct map never
reaches. The rest of every slot is never mapped in any incarnation, so
the guard is true on every CPU with nothing to flush and no direct-map
leaf is split at run time. A dropped stack's frames go back at once and
its slot's address waits: a spawn that finds no clean slot and 64 retired
ones issues one kernel-half shootdown and takes them, otherwise it takes
a slot never used; one that can do neither is refused. Until that flush
a stale translation reaches only what a kernel use-after-free of the
dead stack would, the exposure every frame has through the direct map.
On AArch64 the unmap broadcasts each page's invalidation itself.

The range's tables are the kernel's own and are never freed, since a
paging-structure cache may still name one: one for each 8 slots ever in
use at once, a directory for each 4096
(issues/the-kernel-stacks-range-keeps-every-table-it-ever-made.md). A
spawn that cannot get a frame for one is refused. No lock is held across
an allocation or a zeroing.

A CPU's own stacks stay holes in the direct map, carved at bring-up from
the kernel's own superframes: an x86-64 AP runs on its idle stack, and
loads the IDT that names its IST stacks, under the loader's map, which
holds no other range.

stack-overflow-report runs the #DF report itself off IST1: the second

The record of a pool that never returned memory goes with the pool. The
AArch64 record loses its stack-probe sentence: the pinned target probes
inline, as a disassembled frame shows. Two unrelated findings are filed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…rains after the raw line

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ed spawn leaks nothing

A spawn refused for want of a table frame built a Stack holding its
job's Arc and then forgot it, so the job's count stayed one too high for
good. The Stack is now made only once the map has succeeded.

The slot lists were two heap Vecs grown under the slot lock, whose
doubling reaches the allocator's size assert at 262144 slots. Which slot
a stack is handed, and when a retired one is clean, is now
kernel::stackslots: three fixed bitmaps over 262144 slots, so nothing
allocates, a take past that is refused, and the range's bound is what
the bookkeeping holds. Its host tests hold the one silent-failure path:
no slot retired since the last flush is handed out, a slot retired
during a flush waits for the next, and no more than the batch beyond the
peak is ever used.

A stack leaf is written accessed and dirty on x86-64, the range's walk
asserts it meets tables, and an unmap looks its table up without making
one.

The range and its never-freed tables are the orchestrator's decision,
recorded on the pull request; the record is a question for the owner and
no longer claims an acceptance that was not given.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Run straight through, a flush that made retired slots clean before it
ended was not seen: nothing took a slot while it was under way.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A take that landed inside a flush drew a never-used slot, and the count
of slots ever used never falls, so every raced flush grew the range's
never-freed tables for good: two threads spawning and joining in a loop
could walk an uncharged kernel allocation toward the range's limit while
holding two stacks.

A take that finds no clean slot while a flush is under way, or with a
batch of retired slots waiting, now answers Flush and changes nothing.
The kernel passes such a taker through a gate one at a time: the first
flushes, the rest find the slots it cleaned. The wait at the gate is the
flusher's shootdown, which ends or panics on a CPU that does not answer.
A never-used slot is drawn only where fewer than the batch are retired
and no flush is under way, so the slots ever used are at most the most
alive at once and 64 more, which the model's test now asserts with no
term for raced takes.

The tables' record states that bound and is a finding again, not a
question for the owner.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…nd are, and the model's test refuses an unowed flush

The gate's wait is one shootdown for each taker ahead, not one; the
header's table bound pointed at slots in use at once and now points at
kernel::stackslots, which bounds the slots ever used. The property
test's lone taker asserts begin_flush instead of looping where it
answers false.

Comment and test text only: both comments keep their line counts, so no
panic location moves, and the x86-64 kernel builds to the same bytes
before and after.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: checked before queueing. git range-diff of the reviewed head 22a577a against 384db88 shows only the first commit's crate::process import changed (#1204 made ENDOW_ENTRY_LEN private); commits 2–8 are identical. The body carries review r4's three evidence items: the head, the checks at 384db88 and the four judged metal boots. Review r4: LAND. Queueing.

@Japabu
Japabu marked this pull request as ready for review November 11, 2026 15:44
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit 67713cb Nov 11, 2026
10 checks passed
@Japabu
Japabu deleted the kmem-guards-wx branch November 11, 2026 16:04
Japabu added a commit that referenced this pull request Nov 11, 2026
…ct map holds

Since #1211 the direct map leaves out the bring-up guard pages, so it no
longer maps every frame; it maps every frame a user page can hold, which is
what the read relies on.

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