Skip to content

P4 (T2-c): kernel-stack single ownership + creation-path lock order (#579, #527 remainder) - #601

Merged
ryanbreen merged 7 commits into
mainfrom
feat/teardown-p4-creation-parity
Aug 19, 2026
Merged

P4 (T2-c): kernel-stack single ownership + creation-path lock order (#579, #527 remainder)#601
ryanbreen merged 7 commits into
mainfrom
feat/teardown-p4-creation-parity

Conversation

@ryanbreen

Copy link
Copy Markdown
Owner

P4 (T2-c): kernel-stack single ownership + creation-path lock order (#579, #527 remainder)

Revert story (quoted verbatim from docs/planning/teardown-unification/PLAN.md)

Revert — one story, one PR (rule 5; artifact §5.2 T2-c, "per-site, each call site
independent").
Restore the five Box::leak(Box::new(...)) calls with kernel_stack_allocation: None
at each site and re-nest the three creation sites, deleting the ratchet extension with them: the
leak returns, the freed-row path goes back to being unreachable, and the PM→SCHEDULER nesting returns
— one git revert of the merge commit, and the story is per-site all the way down, which is what
makes an eight-site change revertable alone. Nothing outside this phase consumes the new ownership
API. Verified by a git revert dry run on the merge commit before merge, and written into the PR body
first.


Ledger row 5 of docs/planning/teardown-unification/PLAN.md §0 (18 rows, unamended — see the seam
adjudication below). Closes #579. Completes #527's creation-path remainder (#527 itself stays
closed and is referenced, not reopened). #546 (user-stack GuardedStack reclamation) is untouched
and does not substitute for AC-8. This lands tranche 2's fourth and final phase (P3 #587, P5a #590,
P5b #595 already landed) — merging this PR completes tranche 2 of the teardown-unification plan.


1. What was wrong

Five sites in kernel/src/process/manager.rs did Box::leak(Box::new(kernel_stack)); and stored
kernel_stack_allocation: None — a permanent per-process kernel-stack leak on the primary creation
paths of both architectures and on both x86 fork paths. The reason was structural: threads live
in two places (the process-table row and the scheduler's own Box<Thread>), publication is a
Thread::clone, and Thread::clone cannot clone a KernelStack.

Three further defects were found while building the fix, and are fixed here:

  • x86 had no grace machinery at all. Scheduler::reclaim_terminated_threads, RetirementGrace,
    retirement_grace_elapsed and is_kernel_stack_slot_live were #[cfg(target_arch = "aarch64")].
    Transferring ownership to the scheduler copy on x86 without them would have handed the stack to an
    object that is never reclaimed.
  • x86 KernelStack::drop returned only the bitmap bit — no unmap, no deallocate_frame. Each reuse
    of a freed slot mapped 128 new frames over the same VA range and orphaned the previous 128, and
    map_kernel_page silently overwrote the present PTE that made it possible. A bounded per-process
    slot leak would have become an unbounded per-reuse frame leak.
  • The never-select-a-live-slot assertion was a debug_assert! and there is no [profile.release]
    section anywhere in this tree, so --release — every gate build — compiled it out. It had never
    gated anything, and x86 had no such predicate at all.

2. What this does

Ownership. Thread::publish_to_scheduler() is the single API that publishes a row thread: it clones
and moves kernel_stack_allocation to the published copy. The scheduler copy is the single owner; the
row's copy holds None because ownership moved, not because it leaked. All five Box::leak sites
are gone, and the two pre-existing hand-written transfers (syscall/clone.rs,
aarch64/syscall_entry.rs) now go through the same API so the census has one shape to anchor on.

Grace. The two-epoch retirement grace is now architecture-neutral, with x86 callers in idle_loop
and on the x86 fork path. Reclaimed Box<Thread>s are dropped after the scheduler guard and the
interrupt-disabled region end, because x86's Drop now takes the frame-allocator lock.

Frames. x86 KernelStack::drop unmaps the stack's VA range and releases its 128 frames — but only
behind a liveness refusal: a slot an online CPU still names is counted and leaked, never freed
(drop_refused_live). map_kernel_page refuses, counted, to overwrite a PRESENT PTE inside the
kernel-stack range.

Live-slot guard. A real release-mode, counted check on both arches: if the allocator selects a
slot a CPU still names it releases the bit and fails the allocation at the source.

Lock order. The process-manager guard is released before every scheduler publication, at six
sites (the plan named three; test_exec.rs carries three more of the identical class), and the
publication seam (spawn / spawn_front / spawn_as_current) now detects a publication made while
this CPU holds the process-manager lock.

Dead code. Both #[allow(dead_code)] spawn_userspace_from_elf implementations are deleted with
their two user-stack Box::leak sites (verified dead: the only textual references outside the
definitions are in a stale build log). complete_fork's stale #[allow(dead_code)] is replaced with
honest gating — both its callers live inside #[cfg(feature = "testing")] blocks, so the function is
genuinely absent without that feature.

3. Evidence (AC-8)

One oracle, [KSTACK_OWNER_ORACLE:<arch>:…], launched on both profiles through all five links
(definition → aarch64 TestDef → x86 run_x86_…_gate() → gate-script pin → launch ratchet), 33 fields,
every one asserted.

aarch64 ([BOOT_TESTS:PASS], 106/106):

[KSTACK_OWNER_ORACLE:aarch64:creation_rows=1000:creation_owned=1000:one_owner=1000:two_owner=0:zero_owner=0:fork_rows=1:fork_owned=1:slot_returns_exact_one=1:slot_alloc_delta=1000:slot_free_delta=1000:slot_balance=0:frames_mapped_delta=0:frames_released_delta=0:frame_balance=0:frame_used_delta=0:frame_used_bounded=1:live_checks=1104:live_refusals_production=0:live_refusals_injected=1:drop_refused_live=0:pte_overwrite_refusals=0:pub_pooled=1073:pub_sched_owned=1073:pub_row_residual=0:pub_unowned=0:classifier_sched_owned=1:classifier_row_residual=1:classifier_unowned=1:classifier_not_pooled=1:sched_publications=26:sched_pm_held_production=0:sched_pm_held_injected=1:balance=0]

x86 (beast, USERSPACE TEST COMPLETE, TEST_TALLY: exited=100 nonzero=0 failed=[]):

[KSTACK_OWNER_ORACLE:x86:creation_rows=1000:…:frames_mapped_delta=128000:frames_released_delta=128000:frame_balance=0:frame_used_delta=0:frame_used_bounded=1:…:balance=0]
  • 1000-iteration stress, driven through the real production constructors. allocated == freed,
    both >= 1000, never a vacuous zero — on unmodified main the same workload cannot complete: it
    exhausts the 481-slot (aarch64) / 254-slot (x86) pool. Mutation M1 reproduces exactly that.
  • Frame steady-state, not just slot equality. x86: frames_mapped_delta == frames_released_delta == 128 × slot_alloc_delta == 128000, and frame_used_delta bounded strictly below one stack's 128
    frames. aarch64's zeros are asserted as legitimately zero (HHDM-preallocated, no frames mapped).
  • Ownership after every creation path and both fork paths. Per-iteration assertions over 1000
    creation rows plus a fork arm driving both x86 fork helpers / the aarch64 one, plus production
    counters over every publish_to_scheduler on the boot (pub_sched_owned == pub_pooled,
    pub_row_residual = 0, pub_unowned = 0).
  • Exactly one slot return per process death. slot_returns_exact_one in the new oracle, and
    kstack_returns=64 on the P0/P2 per-PID pairing oracle (kstack_returns == children, asserted).
  • Census. Box::leak(Box::new( across kernel/src as (file, item, count) triples with an
    empty expected set, paired with a non-empty companion census over the four
    Box::leak(v.into_boxed_slice()) sites so the machinery is proven able to see rows.

4. Mutations (each turned a real gate RED, then was reverted)

# Mutation Gate Result
M1 Restore one of the five Box::leak sites aarch64 boot gate + census RED — pool exhausts at creation_rows=939:zero_owner=470:balance=555
M2 Re-nest test_disk.rs's publication under the PM guard exec_lock_order_structure RED (structural)
M2′ Re-nest the x86 create_user_process publication beast x86 boot gate RED — SCRIPT_EXIT=1, 4 × [CREATION_LOCK_ORDER:VIOLATION:PM_HELD]
M3 Neuter is_kernel_stack_slot_live on aarch64 aarch64 boot gate RED — live_refusals_injected=0, "live-slot guard accepted the injected live stack"
M4 Make publish_to_scheduler copy instead of move aarch64 boot gate + census RED — one_owner=0:pub_row_residual=1073:balance=1014
M5 Add a sixth Box::leak(Box::new(...)) in a new item teardown_structure RED — item-anchored, kernel/src-wide

A vacuity finding this PR fixes. M2's runtime half came back GREEN the first time:
run_userspace_from_disk never executes in the aarch64 boot-test profile, so a counter asserted at zero
had no driver — precisely the condition-6 vacuity the standard gate forbids. Fixed at source with a
deliberate injection arm that drives the detector through the same
process_manager_held_on_current_cpu() predicate the production seam uses and emits a distinct
[CREATION_LOCK_ORDER:INJECTED:PM_HELD], pinned present, while the production
[CREATION_LOCK_ORDER:VIOLATION:PM_HELD] is pinned absent. M2′ then proved the production marker
fires from a real creation site and reddens the x86 gate.

5. Pinned residuals moved — with the derivation, not a re-pin

EXEC_DETACH_ORACLE stack_residual, x86: 149 → 21. Mechanism: x86 KernelStack::drop now
releases its frames, and stack_residual is a frame_allocator_used_frames() delta across the oracle
body. Arithmetic stated before the run: old − new = 128 × (kernel stacks dropped inside the window);
a kernel stack is 512 KiB / 4 KiB = 128 frames. Observed: 149 − 21 = 128 = 128 × 1, to the frame.
The oracle now derives it: it measures kstack_frames_released over its own window and asserts
EXPECTED_STACK_RESIDUAL_PRE_KSTACK_RELEASE − stack_residual == kstack_frames_released (and
% 128 == 0 on x86), so a one-frame discrepancy fails with its own message. Measured:
kstack_frames_released=128 (x86) and =0 (aarch64, where 18 − 0 = 18 is unchanged).

Not moved, and stated explicitly: EXPECTED_LEAF_RESIDUAL (16, both arches) and the
creating-dispatch oracle's leaf_residual=16:user_stack_residual=16 are user-leaf-mapping quantities
and did not move; #588's counted creation-failure residue did not move either
(construct_residual=4 x86 / =2 aarch64, unchanged). The in-code #583 comment block is updated so
it no longer claims the kernel-stack component of stack_residual is a #583 manifestation.

PT_RETIRE_COHORT / PT_RETIRE_ORACLE gain kstack_returns=64 — derived, not pinned: the oracle
asserts kstack_returns == children over its 64-child cohort.

6. Seam adjudication (rule 5) — ONE PR, dependency named

Both scopes were built on one branch and rule 5's criterion was tested against the implementation:

  • Reverting the lock-order commit alone is legal.
  • Reverting the ownership commit alone is not: the lock-order commit rewrote all six publication
    sites to call main_thread.publish_to_scheduler() under a &mut guard and publish after the guard
    drops. Reverting ownership deletes that method and leaves those call sites referencing it — a compile
    failure. A git revert dry run confirms it: oldest-to-newest conflicts in process/creation.rs,
    boot/test_disk.rs, test_exec.rs and aarch64/syscall_entry.rs; newest-to-oldest (which is what
    reverting the merge commit does) applies cleanly.

Exactly one legal revert order — the same shape that kept P5a at one PR — so the seam does not fire.
Ledger row 5 stands; §0 is not amended (18 rows). Both seam commits nevertheless build independently
on both architectures, so the commit boundary the plan asked for is real.

seamOutcome: CONFIRMED as ONE PR — the whole-series revert applies cleanly onto a tree
byte-identical to main; reverting the ownership commit alone conflicts in
arch_impl/aarch64/syscall_entry.rs, boot/test_disk.rs, process/creation.rs, test_exec.rs.

7. AC verdict

All 8 P4 acceptance criteria MET (AC-P4-1 through AC-P4-8) and campaign AC-8 MET, with rulings
R21–R27 all applied as authorised (R21 cfg table + coverage sentence corrected, both profiles gated;
R22 grace machinery de-cfg'd with x86 callers; R23 unmap + 128-frame release behind a fail-closed
liveness refusal, frame steady-state asserted; R24 creation-path lock order moved to the publication
seam; R25 counted release-mode live-slot guard on both arches; R26 seam adjudication above; R27 zero
Box::leak(Box::new( census, item-anchored).

8. Gates

Gate Result
Zero-warning builds, x86 testing,external_test_bins and boot_tests,… clean
Zero-warning builds, aarch64 aarch64-breenix-kernel.json, plain and boot_tests clean
scripts/check-kernel-no-neon.sh against the booted ELF PASS, 0 FP/SIMD, allowlist still empty
run-aarch64-full-test.sh --rebuild --boot-tests-only ARM64 BOOT TESTS: PASSED, 106/106
run-aarch64-boot-test-strict.sh PASS 20/20
run-aarch64-boot-test-native.sh PASS
run-aarch64-service-sequence-gate.sh (default 25/profile, both profiles) PASSED — 50 boots total, 575=0 576=0 DATA_ABORT=0 589=21 596=0 P5B=0 GREEN=29 UNATTRIBUTED=0; every #589 occurrence carries the exact pre-adjudicated signature
beast run-x86-boot-tests.sh SCRIPT_EXIT=0, all eleven pinned custody lines
beast run-boot-parallel.sh SCRIPT_EXIT=0, x86 userspace gate: PASS — exited=100 expected>=100 nonzero=0 allowlist=0
clean-gate.sh (100 boots) 100/100 PASS, fail_count=0
starved-gate.sh (100 boots, 14 host CPU hogs) 99/100 PASS, 1 × pre-adjudicated #555 softirq_aarch64 (serial preserved)
run-aarch64-boot-test-strict.sh 20 × 3 60/60 PASS, 0 failures
Parallels 3× (./run.sh --parallels, fresh epoch VM) 279/282/280 heartbeats, zero fault markers, all three VMs stopped
Structural suites 10 files, 222 → 256 tests, all green (measured via cargo test --test <name> -- --list on this branch: teardown_structure 45→53, context_restore_structure 50→61, exec_lock_order_structure 25→34, serial_line_atomicity_structure 3→9; the other six files unchanged)

The x86 script now pins eleven custody lines (was ten) and, for the first time, greps a lock-order
violation marker.

Red-run ledger (review remedy B1). The first run-aarch64-full-test.sh --rebuild --boot-tests-only
on this branch failed at Phase 1c (clonevm_exec_test never completed, 30s timeout) on an unstarved
host. Attribution: pre-adjudicated #589 (clonevm sibling-exit liveness flake) by exact signature
match — 30s Phase-1c-only timeout, heartbeats continuing normally afterward, no further
CLONEVM_EXEC_TEST line, no other subsystem failure, no DATA_ABORT/#596-class marker. Recorded as a
comment on #589 (an unstarved hit is new information relative to that issue's starved-vs-unloaded
table): #589 (comment). The retry (same
binaries, no rebuild) reused the serial path and overwrote the failing run's full serial before it was
archived — only the tool's own last-20-lines excerpt survives (preserved as step1-full-test.log in the
evidence bundle); the retry itself passed cleanly with every P4-relevant marker present. This is a
process gap (the retry should have copied the failing serial aside first) and is disclosed, not hidden.

9. Doc repair carried in this PR

  • The P4 five-site cfg column was an authoring error at both 2c7b8798 and b344e4f2: zero of the
    five sites are "both". The split is 3 x86-only / 2 aarch64-only, so both profiles are
    mandatory and neither alone covers the surface. PLAN and DESIGN corrected, anchors re-based to
    b344e4f2.
  • Gate extra 2 re-stated as a kernel/src-wide (file, item, count) census.
  • Gate extra 1 re-stated: the live-slot assertion is a counted release-mode check on both arches, and
    the stress asserts frame steady-state.
  • Gate extra 4 re-stated: the exec path's marker is unreachable from a creation site, so a distinct
    creation-class marker with an injection arm replaces it; the creation-site count is six, not three.
  • Review remedy B2 — the structural-suite count was previously misstated as 239 → 251; re-measured
    all ten suites via cargo test --test <name> -- --list on this branch (256 total) and corrected
    PLAN.md:470-476, which still carried the pre-branch 222-test inventory, to the measured truth.

10. Disclosed, not hidden

Both aarch64 kernel builds emit Cargo's cached future-incompatibility summary for the pinned
toolchain's own sysroot core
under -Z build-std. It reproduces byte-identically on main @
b344e4f2
, is not this change's doing, and was not suppressed: .cargo/config.toml is
byte-identical to main (an attempt to silence it with [future-incompat-report] frequency = "never"
was reverted). Its fix is a nightly-pin migration, filed separately.


Closing: every leg of the evidence battery is green; only pre-adjudicated signatures observed
(#589 × 21 across the service-sequence gate, #555 × 1 in the starved-gate); zero #596-class DATA_ABORT
anywhere; all QEMU/Parallels processes cleaned up. Tranche 2 is complete.

Co-Authored-By: Ryan Breen ryan@ryanbreen.com
Co-Authored-By: Claude Fable 5 noreply@anthropic.com

ryanbreen and others added 7 commits August 18, 2026 20:48
… two-epoch grace (#579)

Move kernel-stack ownership from process-table rows into scheduler publication copies, then
reclaim those copies only after every CPU crosses the two-epoch fence. Unmap x86 stack
pages, release frames, return per-PID slots, and refuse live-slot reuse so the freed-row
path cannot free a running stack.

Co-Authored-By: Ryan Breen <rbreen@jrni.com>
…publication (#527 remainder)

Extract scheduler-owned thread values while the process-manager guard is held, then let
the guard fall before calling every creation-path scheduler publication. Instrument the shared
publication seam so boot tests can prove the PM-to-scheduler lock nesting is gone and that the
detector itself can fire.

Co-Authored-By: Ryan Breen <rbreen@jrni.com>
…C-8)

Add the AC-8 boot oracle for ownership classification, pool reuse, publication transfer,
and post-grace reclamation on both architecture profiles. Register and launch it with the
teardown suite, and align existing teardown evidence with kernel-stack frame release so the
new ownership behavior is measured honestly.

Co-Authored-By: Ryan Breen <rbreen@jrni.com>
…er (#579, #527)

Ratchet the structural checks around single stack ownership, two-epoch reclamation, and
process-manager guard release at every creation publication site. Pin both x86 and AArch64
QEMU gates to the ownership and lock-order oracle markers so regressions cannot pass through
missing or vacuous evidence.

Co-Authored-By: Ryan Breen <rbreen@jrni.com>
…findings (#579)

Correct the P4 architecture-cfg matrix and narrow the coverage language to the evidence the
gates actually provide. Record the ownership and creation-lock-order vacuity findings and their
ratchets so the plan and design describe the shipped guarantees without overstating them.

Co-Authored-By: Ryan Breen <rbreen@jrni.com>
…ruth (B2)

PLAN.md:470-476 still carried the pre-branch 222-test inventory even after
the P4 doc commit; teardown_structure, context_restore_structure,
exec_lock_order_structure and serial_line_atomicity_structure all grew on
this branch. Re-measured all ten suites via `cargo test --test <name> --
--list` (256 total) and updated the counts in place. Companion fixes to
PR-BODY.md/p4-impl-notes.md (out-of-repo scratchpad) correct the same
239->251 error to 222->256, and p4-mac.md/PR-BODY.md attribute the branch's
first red aarch64 full-test run to #589 with the serial-loss admission (B1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ledger row, tranche 2 complete

Closes #579, completes #527's creation-path remainder. Ledger row 5
lands as ONE PR per rule 5 (ownership-alone revert conflicts; whole-series
revert clean). Tranche 2 (P3 #587, P4 #601, P5a #590, P5b #595) is now
complete.

Co-Authored-By: Claude Fable 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