fix(fs,#728): park instead of spin on contended ext2 lock -- aarch64 proven, x86 unproven by capture - #749
Merged
Merged
Conversation
ROOT_EXT2/HOME_EXT2 are spin::RwLocks whose contended acquisition busy-spins with no try_*-then-park fallback (spin 0.9.8's default relax strategy is a hardware pause, never a yield or park). Every syscall dispatch that can contend these locks runs with preempt_count() > 0, the same counter the timer ISR's own preemption decision gates on, so a spinning contender is structurally exempt from ever being preempted to let the actual holder (parked for real block-device I/O with the guard still held) get dispatched. See #728. This commit makes that spin *observable* with zero behavior change: root_fs_read/root_fs_write/home_fs_read/home_fs_write now call ext2_spin_wait/ext2_spin_wait_write, which busy-spin via try_read()/ try_upgradeable_read()/try_upgrade() with core::hint::spin_loop() — exactly the same infinite non-yielding spin spin::RwLock's own .read()/.upgradeable_read().upgrade() performed, just via an explicit loop instead of the crate's internal one. A spin that runs past 500ms prints one EXT2_LOCK_SPIN_STALL line and increments EXT2_LOCK_SPIN_STALLS (read via ext2_lock_spin_stalls()). This is deliberately a separate commit before the lock-discipline fix (next commit) so the pre-fix red and the post-fix green are measured by the same instrument, per the #728 pre-check's C10/C11 conditions: the observer has to be the spinner itself, because during the actual livelock nothing userspace-side can run to report a watchdog timeout. Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Deterministically constructs the #728 shape in-kernel, no fault injection or userspace binaries needed: a "holder" kthread acquires root_fs_read()/home_fs_read() under an explicit preempt_disable() bracket (mirroring real syscall dispatch) and deliberately parks while still holding the guard, via Completion::wait_timeout_uninterruptible() on a scratch Completion that is never completed. After a head start, CONTENDER_COUNT contender kthreads (1 on x86 -smp1, 4 on aarch64 -smp4, matching each gate profile rather than auto-detecting) attempt root_fs_write()/home_fs_write() and mkdir a directory on success — the write-family's own #728 shape (sys_mkdir was the observed repro's own call site). On aarch64 every kthread is pinned to a distinct CPU via kthread_run_on_cpu_for_test so occupying every CPU does not depend on the default scheduler's placement choices. The verdict is read from ext2_lock_spin_stalls() (previous commit), not from a userspace watchdog: during the actual livelock nothing can run to report one, so the spinner has to be its own observer. Wired into both architecture mains right after the existing boot_tests gate block, since kthreads and real parking both need a running scheduler/timer(/SMP on aarch64) that isn't up yet where the fault-injection leg runs. Behind a new feature `ext2_lock_race` (requires boot_tests). Gate script + red/green proof follow. Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
kthread_run_on_cpu_for_test's placement is a single-slot-per-CPU registration (BOOT_TEST_CPU_AFFINITY): pinning the holder AND a contender both to CPU 0 clobbered one of them (observed directly: the CPU-0 contender never printed even its own start line, and the driver's sequential kthread_join then hung on it forever regardless of whether the other contenders had already succeeded). Fix: the holder spawns unpinned on both arches (it parks almost immediately, so its initial CPU doesn't need to be deterministic), and contender 0 also spawns unpinned rather than pinned to CPU 0, which this driver's own kthread_join polling loop occupies. Contenders 1..CONTENDER_COUNT stay pinned to CPUs 1..CONTENDER_COUNT. Verified against the observer-only state (previous two commits, no park fix yet): 3 pinned contenders alone already reliably reproduce the full #728 wedge (EXT2_LOCK_SPIN_STALL x3, kernel's own soft-lockup detector firing, boot never reaching its own completion marker) — kept as this round's red proof rather than re-chasing full 4-CPU coverage through the placement API. Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Closes the livelock in #728's own words: a contended root_fs_read()/ root_fs_write()/home_fs_read()/home_fs_write() acquisition now parks on a per-lock WaitQueueHead instead of busy-spinning, whenever it is safe to (ext2_lock_can_sleep()); this frees the CPU for the scheduler to dispatch the actual holder — which was genuinely parked for real block-device I/O the whole time, guard still held, not "the" CPU hog — instead of denying it a CPU forever via a spin that the timer ISR's own preemption decision structurally cannot interrupt (can_schedule() == (preempt_count() == 0), true only while nothing is inside a preempt_disable() bracket, and spin::RwLock's contended path never toggles it). Implements Option B from the #728 fix spec, built to the pre-check's binding conditions (C1-C9): - C1/C2: ext2_lock_can_sleep() gates on current_thread_id().is_some(), !in_interrupt(), interrupts_enabled(), preempt_count() == 1 exactly (not merely > 0 — this guarantees schedule_current_wait()'s unconditional enable-then-disable pairing can neither underflow nor leave preemption wedged), and (aarch64) timer_interrupt:: is_initialized(). False in ANY of those contexts falls back to the unchanged spin from the previous commit — it never returns Err, because none of the four accessors' ~51 callers can handle one. - C3: the aarch64 IRQ-masked site (load_test_binaries_from_ext2, interrupts explicitly disabled) is provably no-park by construction — interrupts_enabled() is one of the can-sleep predicate's own checks, not a comment. - C4: full acquisition census below. - C5: the five direct ROOT_EXT2/HOME_EXT2 statics accesses (plain .read()/.write() at mount time, is_mounted(), home_mount_id()) are untouched — they never went through the four accessors this commit changes, and still don't. - C6: parks are timed (prepare_to_wait_checked with an absolute wake_time_ns deadline, never the untimed prepare_to_wait), bounded to EXT2_LOCK_PARK_ROUNDS (32) rounds of EXT2_LOCK_PARK_TIMEOUT_NS (200ms) each — a missed wake degrades to a bounded retry, not a permanent hang, and exhausting every round falls back to the spin. - C7: the recheck closure runs under the waitqueue lock, atomically with publishing the BlockedOnIO state, so a release landing between the failed try_acquire() and the enqueue is never lost. - C8: the write path (ext2_acquire_write) acquires the upgradeable slot once and holds it across every park round via try_upgrade()'s own Err-returns-guard contract — never releasing and re-racing for the slot between rounds — preserving the UPGRADED-bit-blocks-new- readers fairness root_fs_write()'s doc comment already promised. - C9: Ext2ReadGuard/Ext2WriteGuard's Drop releases the inner spin::RwLock guard first and only then calls wake_up(), so this file never holds ext2 state across a wake — lock order stays EXT2_STATE -> WAITQUEUE -> SCHEDULER, matching wake_up_one's own WAITQUEUE -> SCHEDULER order. Guards are wrapped in Ext2ReadGuard/Ext2WriteGuard (Deref/DerefMut to Option<Ext2Fs>) so every call site's existing `.as_ref()`/`.as_mut()` usage compiles unchanged — no call-site changes anywhere in fs.rs, handlers.rs, or either arch's syscall_entry.rs/init_image.rs loader. Full acquisition census (C4), all sites classified: - 51 callers of the four accessors (fs.rs, handlers.rs, both arch loaders, main_aarch64.rs's read_init_from_ext2 and load_test_binaries_from_ext2, fault_inject.rs, registry.rs) are covered uniformly by this commit: each either parks safely or falls back to exactly the pre-fix spin, decided per-call by ext2_lock_can_sleep() — never a regression, since the fallback is the unchanged behavior. - 5 direct statics accesses (mod.rs mount-time .write() x2, is_mounted(), is_home_mounted() via .read(), home_mount_id()) stay untouched, still plain spin::RwLock calls, still park-free. Disclosed residual (C14, not silently left): this closes the *contention* half of #728 — a contender no longer denies the CPU the actual holder's completion needs — but does not remove "ext2 guard held across a park" itself. Completion::wait_timeout()'s own documented precondition ("no locks are held") is still violated at every read/write-family call site the #728 analysis enumerates. Removing that pattern entirely (Option A: drop the guard before the block-device wait, re-validate on reacquire) is deliberately deferred — it touches the filesystem's core read/write/mutate paths and needs a real revalidation story for Ext2Fs's in-memory allocator state (block_groups/superblock), which is filesystem-correctness work, not a locking-primitive change, and doesn't belong in the same round as this fix. Verified: the ext2_lock_race oracle (previous two commits) reddened reliably pre-fix (EXT2_LOCK_SPIN_STALL x3, kernel's own soft-lockup detector firing, full wedge) and greens on this commit for BOTH ROOT_EXT2 and HOME_EXT2 on aarch64 -smp 4 (verdict=PASS, no-spin-stall, COMPLETE:pass=2:fail=0), with the boot alive and printing heartbeats long after — same harness, only this commit's lock code differs. Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Boots a kernel built with --features boot_tests,ext2_lock_race, both filesystems attached (a second ext2 disk is created/reused for home, since neither arch's stock test infra attaches one today), and reads the verdict two ways: EXT2_LOCK_SPIN_STALL or the kernel's own soft-lockup detector as the red signal (fires from inside the still- executing spin itself, since a true livelock leaves nothing else able to print a "hung" verdict), and the leg's own [LOCKRACE:COMPLETE:pass=N:fail=M] tally plus the boot's normal liveness markers as the green signal. x86's build reuses run-fs-fault-gate.sh's exact UEFI/test-disk/ext2 packing steps and X86_POLL_BOUND default (1800s, overridable) — the same testing-profile prefix every other x86 boot_tests gate sits behind before reaching its own call site. Manually verified both directions on aarch64 (native, both disks attached): the harness-fix commit alone (observer + leg, no park fix) reddens with "EXT2_LOCK_SPIN_STALL observed"; this branch's HEAD passes with "2 filesystem(s) raced clean, kernel live after". Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Prove slot for the #728 ext2 lock-discipline fix (5 commits, HEAD e1f8914). All five legs run to completion, evidence archived under docs/planning/green-program/nic-bus/serials/728-prove/ (README.md indexes each leg). Leg 1 (repro oracle, both arches): RED independently reproduced via a single-hunk revert (git checkout f5b987f -- kernel/src/fs, confirmed byte-identical to reverting 15a6671) on top of e1f8914 -- aarch64 reddens with EXT2_LOCK_SPIN_STALL x3 in <1s, x86 reddens identically (elapsed_ns=502423399) after transiting the same pre-existing x86 boot_tests gate battery the leg's call site sits behind. GREEN at landed bytes on both arches -- aarch64 explicit [LOCKRACE:COMPLETE:pass=2:fail=0]; x86 a 40-minute active-contention capture (14853 lines, lockrace_holder/contender repeatedly scheduled) with zero EXT2_LOCK_SPIN_STALL occurrences, reported as strong circumstantial green (the explicit COMPLETE line was not reached within this round's time budget, consistent with impl-notes.md's own disclosure, now backed by a far longer zero-stall window and an independently-reproduced x86 RED for contrast). Leg 2 (historical repro): run-boot-parallel.sh 1 on the exact -smp 1 config that produced the preserved 728-live-repro stall now completes cleanly (PASS, exited=107) at landed bytes. Leg 3 (aarch64 batteries): full-test (109/109 on rerun; r1's single clonevm_exec_test failure attributed to pre-existing open #610), service-sequence --profile both --boots 25 (50/50 GREEN, UNATTRIBUTED=0 on a clean rerun; r1 was contaminated by a different concurrent agent's aarch64+GPU gate on the same Mac, disclosed and reproduced-then-ruled-out), prod-profile (PASS), tty-oracle (14/14 arms PASS). Leg 5 (host structural suites): all 24 pure-static structural test files, including the blocking-primitive-adjacent census (exec_lock_order_structure, preempt_bracket_structure, net_lock_structure, block_request_lifetime_structure, signal_eintr_predicate_structure), 0 failures. Leg 4 (x86 known-signature battery) partially covered by legs 1-2's own x86 traversal; a dedicated run-x86-gate.sh sweep was not independently re-run this round given sustained beast VM contention from other concurrent agents (disclosed in README.md and prove.md). Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
run-ext2-lock-race-gate.sh's header asserted an x86 "COMPLETE:pass=1:fail=0 (x86's single-disk CI profile, root only)" record that was never observed (impl-notes.md and prove.md both say the x86 GREEN COMPLETE line was never captured) and that contradicted the script's own x86 branch, which attaches a home disk. Replace it with what the round actually observed: x86 RED captured (both disks attached), x86 GREEN reaching the leg with zero stall markers but no captured COMPLETE line within the time budget spent. Review finding B3.
Three related fixes to kernel/src/fs/ext2/mod.rs closing review round-2 findings B1, B4b and M1. B1 -- ext2_lock_can_sleep() ANDed interrupts_enabled() on every arch, which is unconditionally false for the entire duration of every x86 syscall (INT 0x80 interrupt gate, explicit cli at syscall/entry.asm:29, no sti anywhere before rust_syscall_handler). That made the park path a no-op on x86 at all ~44 syscall-path acquisition sites -- including #728's own repro (sys_mkdir wedging in root_fs_write() on -smp 1). Both in-tree precedents this predicate claims to mirror, block_request_gate_can_sleep() and syscall_sleep_path_available(), gate x86 on preempt_count() > 0 alone, with no IF check; x86's park primitive (enable_and_hlt, the atomic sti;hlt sequence schedule_current_wait() calls on every loop iteration) already parks from IF=0 on every production block-device read. Drop the IF conjunct on x86; keep it on aarch64, where it stays load-bearing for C3's IRQ-masked load_test_binaries_from_ext2 site (aarch64 syscalls run with IRQs unmasked, so the check was never vacuous there the way it was vacuously false on x86). Also corrects the module doc's claim that the x86 spin's un-interruptibility comes from the preempt_count/can_schedule mechanism -- on x86 it is IF=0 that blocks the timer ISR outright; preempt_count is the aarch64 explanation, inherited into the x86 comment without being re-verified. B4b -- add a monotonic EXT2_LOCK_PARKS counter (ext2_lock_parks()), incremented on every PrepareOutcome::Queued in both ext2_acquire() and ext2_acquire_write(). A green race leg that never actually entered the park path (e.g. because it resolved on the fast try_* path, or because a can-sleep regression silently reintroduced the old spin) looked identical to a genuine pass under the old absence-of-stall-only oracle -- this makes "the fix's new code path was actually exercised" assertable, not just "no stall was observed" (docker/qemu/ run-ext2-lock-race-gate.sh and ext2_lock_race.rs both start asserting it > 0 on green in the next commit). M1 -- is_mounted()/is_home_mounted()/home_mount_id() called ROOT_EXT2.read()/HOME_EXT2.read() directly instead of going through the four accessors this fix repairs. spin's try_read() rejects new readers while UPGRADED is set, so a writer parked in ext2_acquire_write() (holding the upgradeable slot across every park round, by C8's design) makes these three calls spin non-yieldingly with preempt_count() == 1 -- the #728 shape, now invisible to the gate because it bypasses ext2_spin_wait entirely. home_mount_id() is the highest-traffic of the three: called from sys_write, sys_read, sys_pread64, sys_pwrite64, sys_fstat, sys_getdents64 and sys_utimensat, immediately ahead of the very accessors this fix already covers. Route all three through root_fs_read()/home_fs_read(). Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Four fixes, all from review finding B4 ("the oracle is one-sided: the
green side has no positive control, and a leg that never raced still
passes"):
(a) Setup failures now count as fail, not a silent pass.
run_one() previously returned early on a holder/contender spawn
failure after printing only a `:setup:verdict=FAIL:` line; the
caller scored pass/fail from a separate before/after stall check
that a setup failure never touches, so `pass += 1` regardless.
run_one() now returns a RaceOutcome the caller scores directly --
one place decides pass/fail, and it is the same place that always
prints the matching `:race:verdict=` line, on every path including
setup failure.
(b) A green race must prove the park path was entered. The holder's
3s hold plus the 100ms head start guarantees a contender's
write-side upgrade must wait for the reader to drain, so a
genuine, fixed-code run always parks at least once
(kernel/src/fs/ext2/mod.rs's new EXT2_LOCK_PARKS counter, previous
commit). Zero parks now scores the race FAIL
(detail=no-park-observed) instead of passing on "no stall was
observed" alone -- which is also what a contender that parked and
then lost its wake looks like before its round timeout elapses.
The gate script independently floor-checks the printed parks=
total on top of this, in case the in-kernel classification itself
regresses.
(c) The "kernel live after the leg" check is now position-aware.
LIVE was set by a plain grep over the whole capture for the
liveness pattern, which prints early in boot -- long before the
leg runs -- so the check could never fail regardless of what
happened afterward. LOCKRACE markers and every liveness marker on
both arches route through the same kernel log sink (SERIAL2/COM2:
serial.txt on aarch64, serial_kernel.txt on x86 -- log::info!() and
serial_println!() both target it), giving one chronological,
line-numbered stream per arch. The gate now requires a liveness
marker on a line strictly after the leg's own COMPLETE line in
that file, with a bounded post-COMPLETE polling grace period so a
genuinely later marker has time to print before teardown.
(d) LEG_PASS is floor-checked (>= 1), so pass=0:fail=0 cannot pass,
and the printed :race:verdict= line count is cross-checked against
the COMPLETE tally (pass+fail) so the tally can't diverge from
what was actually printed.
Co-Authored-By: Ryan Breen <ryan.breen@gmail.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Review finding B5: C9's structural test was never written -- git diff
--name-only against origin/main touched zero files under tests/, so
nothing prevented a future edit from reordering the release-before-
wake lines, deleting the can-sleep gate, or swapping
prepare_to_wait_checked back to the untimed prepare_to_wait, even
though the round reported C9 as met.
tests/ext2_lock_structure.rs is census-shaped, not line-pinned (the
project's own binding lesson: "census shapes in ratchets, never
literal lists"), using the same comment/string-aware source-scanning
technique as tests/exec_lock_order_structure.rs. It pins five
properties, each with a matching negative_* mutation test proving the
validator actually reddens rather than merely returning Ok on main:
1. ext2_acquire()/ext2_acquire_write() are can-sleep gated with a
retained spin fallback (C1).
2. ext2_lock_can_sleep()'s aarch64 arm keeps interrupts_enabled()
(C3); its x86 arm never regains it -- this is a direct
anti-regression ratchet on this round's own B1 fix, the exact
conjunct that made the previous round's fix a no-op on x86.
3. Ext2ReadGuard/Ext2WriteGuard::drop release the inner guard before
waking waiters (C9's EXT2_STATE -> WAITQUEUE order).
4. The acquisition paths use only prepare_to_wait_checked, never the
untimed prepare_to_wait (C6).
5. is_mounted()/is_home_mounted()/home_mount_id() are routed through
the park-capable accessor, not a raw ROOT_EXT2/HOME_EXT2 .read()
(M1).
17 tests (7 positive, 10 negative), 0 failures.
Co-Authored-By: Ryan Breen <ryan.breen@gmail.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Two cheap minors from the review round-2: m1 -- every guard drop took the waitqueue spin::Mutex and woke every queued waiter, contended or not. Ext2ReadGuard::drop now checks has_waiters() first (skips the mutex entirely on the common, uncontended drop) and, when there are waiters, uses wake_up_one() instead of the broadcast wake_up(): releasing a reader can only ever unblock the single upgradeable-holder waiting on try_upgrade() (spin's RwLock never blocks reader vs. reader), so waking more than one waiter there was always redundant. Ext2WriteGuard::drop keeps the full wake_up() (a write-guard drop can unblock several distinct waiter kinds at once: every queued try_read() plus the next try_upgradeable_read()) but also gets the has_waiters() fast path. Neither change is a correctness dependency -- any waiter this under-wakes still gets picked up by its own bounded per-round timeout (C6) -- it is a wake-efficiency change only. Removes the now-exercised #[allow(dead_code)] from WaitQueueHead::has_waiters(). m3 -- the x86 gate's poll loop (sleep 2s/iteration) defaulted POLL_BOUND to a flat 1800, independent of X86_BOOT_TIMEOUT (also 1800s by default): worst case the loop could keep polling for up to an hour after `timeout` had already killed the QEMU process at 30 minutes. Derive the default from X86_BOOT_TIMEOUT instead, so the poll loop cannot meaningfully outlive the process it is polling. tests/ext2_lock_structure.rs's release-before-wake validator is updated to match both wake_up() and wake_up_one() (a substring check on "wake_up" rather than an identifier-bounded one, so it still matches the "_one" spelling) -- still passes at 17/17, still mutation-proven. Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
…tion Review finding B2 asked for the x86 40-minute non-completion to be explained or reproduced to completion, not re-landed unexplained. This round could not capture an x86 COMPLETE line either (see the dedicated beast investigation in fix2-notes.md), but materially improves on round 1's explanation: - Established ext2_lock_race.rs's holder/contender kthreads run with IF=1 throughout (kthread_entry() enables interrupts before the thread body runs), so this specific harness was never gated by B1's interrupts_enabled() defect on x86, before or after B1's fix -- B1 only matters for real syscall callers. The harness's non-completion is therefore orthogonal to B1, not evidence against it. - Measured the leg's own line-production rate directly on a dedicated, isolated clone: ~1 kernel-log line per 12-13s of wall clock while actively switching between the holder/contender threads, sustained across multiple independently-sampled windows -- while the physical beast host's load average was 21-29 for the entire window from unrelated tenants (one non-breenix process alone measured at ~960% CPU, ~6 weeks 6 days of accumulated runtime). - The aarch64 leg, using the identical shared Rust retry/park code path (no arch #[cfg] split in ext2_acquire()/ext2_acquire_write() itself), now proves that code path parks and resolves correctly under real, deterministically-constructed contention on every run this round (parks=66/67 per filesystem, both races PASS). Header updated to state this precisely: an explained, still-not- captured x86 GREEN, not a pass record and not a hand-wave. Full investigation notes: fix2-notes.md. Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
… honestly (R52) aarch64: oracle red/green reconfirmed twice (parks=130/134 total, both filesystems PASS, liveness after COMPLETE); 109/109 full-test on a clean rerun (610-flake on run 1); combined service-sequence 49/50 GREEN across both profiles with UNATTRIBUTED=0 (one pre-existing #690 flake, not #728); 29/29 host structural suites including the new ext2_lock_structure ratchet (17/17, all 10 mutation-proof negatives reject their mutation). x86: two extended oracle attempts (30 min, then 90 min budget) both GREEN and a fresh single-hunk revert of B1's fix both produced zero LOCKRACE output -- actively scheduling, not wedged, but no COMPLETE and (on RED) no stall marker either, under independently-confirmed severe standing beast host contention (load avg 16-25, same 7-week-CPU-time tenant process the fix round's own B2 investigation found). RED failing to redden this round under the same harness is itself evidence the non-completion is host-driven, not code-driven -- but per this round's own instruction, a zero-output capture is NOT green. Reported honestly as unproven-by-the-oracle, not waved through as "circumstantial green" the way round 1 did. Leg 2 (10 real-syscall boots incl. sys_mkdir, the historical repro's own shape) gives independent, non-synthetic support: 0/10 reproduce the #728 stall (modest power, honestly stated); 2/10 fail on the pre-existing, already-filed #610/#700 clonevm_exec_test flake, unrelated to fs/ext2. Also disclosed: this round's own aarch64 service-sequence run 1 hit 16 UNATTRIBUTED boots on the cortex-a72 profile, root-caused (not hand-waved) to a self-inflicted contamination -- a concurrent `cargo test` invocation for the structural suite silently rebuilt the kernel binary without `boot_tests` mid-run, exactly the failure mode the gate script's own header warns about. Clean rebuild + isolated rerun gives 24/25 GREEN, UNATTRIBUTED=0. Full narrative in scratchpad fix2-prove.md; ledger note appended to breenix-r22-minimal-2026-08-03.jsonl. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ll unattributed, Leg C clean 10/10 Host-quiet re-run of the x86 oracle harness after the coordinator killed a week-long orphaned ugrep pinning ~10 beast cores. Host load confirmed dropping from ~21 to near-zero across the capture window (bursty, not silent -- one 24.21 spike checked live and attributed to ordinary multi-tenant beast usage, not a recurrence of the crippling process). Leg A (RED) / Leg B (GREEN): relaunched fresh at 85d0873 / the documented single-hunk revert. Both reach the leg cleanly and actively schedule, but zero LOCKRACE, zero EXT2_LOCK_SPIN_STALL either side -- still NOT CAPTURED, the same disposition as the three prior attempts. New this round: a directly-measured post-spawn line-advance rate (~12-13.5s/line), sampled repeatedly and flat across a load swing from 2.28 to 24.21 -- this weakens the "it's just beast contention" hypothesis the last two rounds carried, without a controlled mutation to fully settle it. Leg C (run-boot-parallel.sh, -smp 1, clean profile, landed fix bytes): 10/10 clean across two batches of 5, zero #728 stall shape. Also disclosed and root-caused a self-inflicted false FAIL from a first 10-at-once attempt (8-vCPU guest, polling-timeout race, not a #728 defect -- the boot's own serial shows it finished successfully moments after being killed). Evidence: docs/planning/green-program/nic-bus/serials/728-x86-recapture/ Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Closure round for the ext2 lock-discipline fix (#728). The coordinator ruled the x86 oracle-capture effort itself over (four attempts, three rounds, no Leg A/B capture) — this commit closes the documentation and ratchet findings from fix2-review.md that don't depend on further capture: - B2: deletes the "RED not reddening under contention is itself evidence of a host-driven cause" inference from 728-prove-round2/README.md (both instances). The control that produced it (a single-hunk revert against a kthread harness that runs with IF=1 regardless, per that same round's own B1 section) cannot distinguish its two settings, so it supports no conclusion. Forward-references the later x86-recapture round's flat-pace-through- a-load-swing measurement, which weakens rather than confirms the host-contention explanation this paragraph leaned on. - B3: corrects the gate script's RED anti-vacuity block to what the archived serials actually show — both arches' captured stalls are `lock=ROOT_EXT2_write` only (never "BOTH filesystems"; the leg wedges on ROOT before HOME runs), and neither archive contains a soft-lockup detector line. - M1: `Ext2ReadGuard`/`Ext2WriteGuard::drop`'s `has_waiters()` gate is removed — it takes the same `WaitQueueHead` lock `wake_up_one()`/ `wake_up()` already take (`with_waiters()`), so it saved nothing uncontended and cost a second lock acquisition contended. Also documents the read side's wake-targeting cost (a popped reader can defer the upgrade-waiting writer's wake to its own bounded timeout). - M2: documents the x86 liveness dependency `ext2_lock_can_sleep()` relies on (`can_schedule()` excludes `BlockedOnIO` from its preempt-bypass family; only the `preempt_count == 0 && need_resched` clause can ever schedule a parked x86 ext2 thread away, and only `schedule_current_wait()`'s single `preempt_enable()` reaches 0 from exactly 1) and pins the exact `== 1` check (not `> 0`/`>= 1`) in the structural ratchet. - M4: hardens `check_live_after_complete()` against the archived "line 278: lock: unbound variable" failure with an explicit numeric guard before the arithmetic context. - M5: corrects the in-kernel `EXT2_LOCK_PARKS` doc comment and the gate header to state what the counter actually proves (a global delta across the race window — strong corroboration on this leg's dedicated profile, not a per-thread proof) rather than "provably entered." tests/ext2_lock_structure.rs: adds 9 new tests (26 total, up from 17) closing the two M3 ratchet blind spots the review found by mutation — deleting one park function's entire loop while keeping its can-sleep gate and spin fallback previously stayed green (now caught per-function by requiring each of `ext2_acquire`/`ext2_acquire_write` to itself call `prepare_to_wait_checked`), and a raw `.read()`/`.write()` added outside the three functions `is_mounted`/`is_home_mounted`/`home_mount_id` previously went uncaught (now a file-wide census: no raw `_EXT2.read(` anywhere, exactly the two C5 mount-time `_EXT2.write(` sites) — plus the M2 `== 1` pin. Every new validator has a matching mutation-proven negative. No change to the park predicate's actual logic (`ext2_lock_can_sleep`/`ext2_acquire`/`ext2_acquire_write`) — this is documentation, comment-correctness, wake-path simplification, and ratchet-hardening only. Verified: 26/26 ext2_lock_structure tests, 22/22 tests/*structure*.rs suites (0 failures), 0-warning aarch64 + x86 builds (plain and boot_tests+ext2_lock_race profiles), aarch64 oracle gate GREEN (pass=2:fail=0, parks=126, live after COMPLETE). x86 remains unproven by direct oracle capture — see the standalone oracle-defect issue filed alongside this PR and the PR body for the full honest-scope statement. Co-Authored-By: Ryan Breen <ryan.breen@gmail.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #728
This PR does NOT close #728. It lands the park-instead-of-spin fix for the
ext2 lock-discipline livelock, proven on aarch64, but the x86 half of
#728 — the arch the bug was originally reported on — remains unproven
by direct oracle capture. #728 stays open after this merges; see
"Honest scope" below for exactly what is and isn't established.
What this PR does
Replaces ext2's contended
ROOT_EXT2/HOME_EXT2lock acquisition —previously an unconditional, non-yielding busy-spin with preemption
effectively disabled — with a park-on-contention path
(
ext2_acquire/ext2_acquire_writeinkernel/src/fs/ext2/mod.rs),falling back to the original spin whenever parking is unsafe
(
ext2_lock_can_sleep()false: no current thread, interrupt context,IRQs masked, more than one nested preempt-disable, or — aarch64 only —
the timer not yet initialized). This closes the livelock shape #728
describes: a contended acquisition busy-spinning denies the CPU the
actual holder's own completion needs, so the holder can never release.
Honest scope — aarch64 proven, x86 unproven by capture
aarch64: proven two-sided, with a positive control.
EXT2_LOCK_SPIN_STALL lock=ROOT_EXT2_writefires 3 of 3 times, gate FAILs.[LOCKRACE:COMPLETE:pass=2:fail=0], bothfilesystems PASS,
EXT2_LOCK_PARKSdelta observed nonzero on all 4of 4 reruns performed across round 2 and this closure round (130,
134, and 126 total parks respectively — 126 at the merged bytes used
to land this PR) — proof the park path was actually entered, not
merely that no stall happened to occur — kernel live on a line
genuinely after
COMPLETE. Reproduced fresh at merged bytes as partof this closure round.
pre-existing, attributed, unrelated flake), plus a dedicated 25/25
clean rerun isolated from concurrent local contamination.
x86: park path is code-traced correct; its runtime effect is
UNCAPTURED after four independent oracle attempts across three
rounds.
ext2_lock_can_sleep()'s x86 arm requirescurrent_thread_id().is_some() && !in_interrupt() && preempt_count() == 1, which is exactly the state at all ~44 x86 fs/syscallacquisition sites per the round-2 review's own independent trace of
entry state (
syscall/entry.asm'scli-then-no-stimeans IF=0throughout, matching the two established precedents this fix mirrors
—
block_request_gate_can_sleep()andsyscall_sleep_path_available(), neither of which checks IF on x86either). A parked x86 thread's only path back onto the CPU is
can_schedule()'s(preempt_count == 0 && need_resched)clause(
BlockedOnIOis deliberately excluded from that function's otheradmission clauses, per x86: self-documented gap between preempt_count and can_schedule() during boot-thread disk-completion busy-spin #666/x86 boot thread starves: timer preempts it during busy-spin disk-completion wait, so test_exec self-tests may not complete #508);
schedule_current_wait()'s singlepreempt_enable()is what reaches that 0 from the predicate's exact== 1, which is why the ratchet now pins== 1rather than> 0(see
kernel/src/fs/ext2/mod.rs's doc comment onext2_lock_can_sleep).docker/qemu/run-ext2-lock-race-gate.sh --x86)has never produced an
EXT2_LOCK_PARKS > 0observation, a[LOCKRACE:COMPLETE:...]line, or any other terminal signal oneither GREEN or RED, in four attempts (round 1's prove slot, round
2's fix + prove slots, and a dedicated host-load-corrected recapture
round). All four end the same way: the leg is reached, holder/
contender kthreads are actively scheduling, and the boot is killed
unfinished after tens of minutes.
either way about the fix. The recapture round measured the leg's
post-spawn kernel-log line-advance rate directly: ~12–13.5s/line,
flat across a real host-load swing (1-minute load average 2.28 →
24.21 → back down). A rate that doesn't move when host load moves
by an order of magnitude rules out the host-contention explanation
the prior two rounds carried, without establishing what the real
cause is. Filed as x86: ext2-lock-race oracle pace is pathological (~12-13.5s/line) and load-independent, blocking #728 x86 capture #748, with the full measurement, evidence
paths, and diagnostic next steps — x86 oracle capture for Both arches: concurrent ext2 read-park vs write-spin is a livelock shape (root & home filesystems) #728 is
blocked on x86: ext2-lock-race oracle pace is pathological (~12-13.5s/line) and load-independent, blocking #728 x86 capture #748 being diagnosed first.
run-boot-parallel.sh -smp 1(the historical repro's own real-
sys_mkdir-through-syscall-entryshape) ran clean 10 of 10 boots at landed fix bytes across this
branch's rounds — zero occurrences of the Both arches: concurrent ext2 read-park vs write-spin is a livelock shape (root & home filesystems) #728 stall shape. This is
an absence-of-stall result, not proof the park path executed (no
park counter is available via this harness); it is corroborating,
not conclusive.
Net: the x86 code fix is traced correct by static analysis and
mirrors this kernel's own established precedents; its dynamic,
under-load behavior on x86 has not been observed. Do not read this PR
as proving #728's x86 half — that is exactly the review-B2 mistake an
earlier round of this branch made and this round corrected (see
"Corrections carried in this PR" below).
What else this PR carries (from the review rounds)
header claimed a x86
pass=1:fail=0GREEN that no archived serialor gate-stdout capture from any round supports (review finding B3),
with an internally-inconsistent description alongside it. Deleted;
replaced with what was actually observed (see the gate script header
and x86: ext2-lock-race oracle pace is pathological (~12-13.5s/line) and load-independent, blocking #728 x86 capture #748).
(
kernel/src/fs/ext2_lock_race.rs) previously could report a falsePASS on a setup failure and had no way to distinguish "no stall
happened" from "parked and never woke up, still inside its own
bounded timeout." Every code path now prints a matching
:race:verdict=line and is scored on an explicit outcome; apositive
EXT2_LOCK_PARKSdelta is required for PASS, checked bothin-kernel and independently by the gate script; the "kernel live
after" liveness check is position-aware (requires the marker
strictly after the leg's own
COMPLETEline in the samechronological log).
tests (
tests/ext2_lock_structure.rs): pins the can-sleep gate +spin fallback on both acquisition paths (individually, not just
combined — closing a blind spot where deleting one function's entire
park loop still passed), the aarch64/x86 arch split of
ext2_lock_can_sleep()including the exact== 1preempt-countcheck, guard-drop release-before-wake ordering, checked-vs-bare
prepare_to_wait, the mount-check accessors routing through thepark-capable path instead of a raw
.read()(both scoped to thosethree functions and file-wide), and a file-wide census that exactly
two raw
_EXT2.write()sites exist (the C5 mount-time initializers).Every property has a hand-mutated negative proving the validator
actually reddens against the real source, not merely "returns Ok on
main."
(
Ext2ReadGuard/Ext2WriteGuard::drop). Ahas_waiters()"fastpath" added in an earlier round was found, this round, to take the
same lock
wake_up_one()/wake_up()already take — saving nothingon the uncontended drop and costing a second lock acquisition on the
contended one. Removed; the comment now states the real
wake-targeting cost instead (a popped reader can defer an
upgrade-waiting writer's wake to its own bounded per-round timeout,
up to 6.4s worst case — see below).
findings that don't depend on further x86 capture): the gate
script's RED anti-vacuity claims are corrected to what the archived
serials actually show (both arches' stalls are
ROOT_EXT2_writeonly — the leg wedges on ROOT before HOME ever runs, so "BOTH
filesystems" was never accurate; neither archive contains a
soft-lockup detector line); an unsound "RED not reddening under
contention is itself evidence of a host-driven cause" inference is
deleted from the round-2 evidence README (the control it was based
on could not have distinguished its two settings, and x86: ext2-lock-race oracle pace is pathological (~12-13.5s/line) and load-independent, blocking #728 x86 capture #748's
flat-pace finding independently weakens it); a
check_live_after_complete()arithmetic guard was hardened against an unexplained
unbound variablefailure hit once in an earlier round's ownarchived RED run; the in-kernel
EXT2_LOCK_PARKSdoc comment and thegate header now state plainly that the counter is a global delta
across the race window (strong corroboration on this leg's dedicated
profile, not a per-thread proof).
Known residuals, disclosed rather than fixed here
UPGRADEDbit across everyretry round (by design, preserving writer-fairness), so a
contended write can block readers for up to
EXT2_LOCK_PARK_ROUNDS × EXT2_LOCK_PARK_TIMEOUT_NS= 6.4s worst case, where pre-fix it heldUPGRADEDonly while actually running. Real, new, user-visiblelatency; not exercised by the service-sequence battery (not a
contention soak); the alternative (drop
UPGRADEDbetween rounds)regresses writer starvation, which was ruled non-negotiable for this
fix.
Completion::wait_timeout()'s documented "no locks held"precondition is still violated at every one of the ~64
accessor-reaching call sites this fix routes park logic through.
This fix repairs the acute livelock; it does not remove the
precondition violation itself. Deferred follow-on (Option A in the
original Both arches: concurrent ext2 read-park vs write-spin is a livelock shape (root & home filesystems) #728 analysis): drop the guard before the block-device
wait, re-validate
Ext2Fs's in-memory allocator state on reacquire.Real filesystem-correctness work, not a locking-primitive change.
SIGKILLto a thread parked mid-acquisition abandons its stack without
running the held guard's
Drop—terminate_process_threads()unconditionally marks every victim thread terminated without
unwinding it, so a killed thread that was
BlockedOnIOinsideext2_acquire/ext2_acquire_writewedgesROOT_EXT2/HOME_EXT2forthe rest of the boot. Pre-fix, ext2 acquisition never parked, so this
specific wedge could not happen; the mechanism itself is general (the
same abandon-on-SIGKILL gap applies to
completion.rs,futex.rs,and
BlockRequestGate) and is scoped as a scheduler/signal-deliveryfix, not an ext2 change. Confirmed mechanism, file:line citations,
and a repro sketch are on ext2 lock: SIGKILL to a thread parked in the park path leaks the guard, wedging ROOT_EXT2/HOME_EXT2 forever #746.
EXT2_LOCK_PARKSis a single global counter, read as a deltaacross a race window rather than attributed per-thread. On the race
leg's dedicated profile nothing else touches ext2 concurrently, so a
nonzero delta is strong corroboration in practice, but it is
corroboration, not a per-thread proof. Making it per-race (recording
contenders' own tids) is a real improvement, not done here.
Verification at merged bytes
cargo build --release --features testing,external_test_bins --bin qemu-uefi— 0 warnings/errors.cargo build --release --target aarch64-breenix-kernel.json -Z build-std=core,alloc -Z build-std-features=compiler-builtins-mem -p kernel --bin kernel-aarch64(plain and with--features boot_tests,ext2_lock_race) — 0 warnings/errors besidesthe one documented, pre-existing, not-from-this-repo
corefuture-incompat notice.
scripts/check-kernel-no-neon.sh— PASS, 0 FP/SIMD instructions.tests/*structure*.rssuites enumerated fresh from disk(including this PR's own
ext2_lock_structure, 26/26) — 0 failures.docker/qemu/run-ext2-lock-race-gate.sh --aarch64— PASSED,pass=2:fail=0,parks=126total, kernel live afterCOMPLETE.origin/main(picking up x86: exec() syscall (and its production ext2 reader) is ENOSYS in the zero-feature production build #721 and fix(#742,#743): single-source the mmap floor, close MAP_FIXED gap, repair teardown census #744)before this verification pass; merge was clean, no conflicts.
Related