Skip to content

fix(fs,#728): park instead of spin on contended ext2 lock -- aarch64 proven, x86 unproven by capture - #749

Merged
ryanbreen merged 16 commits into
mainfrom
fix/728-ext2-lock-discipline
Sep 1, 2026
Merged

fix(fs,#728): park instead of spin on contended ext2 lock -- aarch64 proven, x86 unproven by capture#749
ryanbreen merged 16 commits into
mainfrom
fix/728-ext2-lock-discipline

Conversation

@ryanbreen

@ryanbreen ryanbreen commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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_EXT2 lock acquisition —
previously an unconditional, non-yielding busy-spin with preemption
effectively disabled — with a park-on-contention path
(ext2_acquire/ext2_acquire_write in kernel/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.

  • RED (pre-fix code + the same harness): EXT2_LOCK_SPIN_STALL lock=ROOT_EXT2_write fires 3 of 3 times, gate FAILs.
  • GREEN (fixed code): [LOCKRACE:COMPLETE:pass=2:fail=0], both
    filesystems PASS, EXT2_LOCK_PARKS delta observed nonzero on all 4
    of 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 part
    of this closure round.
  • Service-sequence battery: 49/50 GREEN at merged fix bytes (one
    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.

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)

  • Deleted a fabricated evidence record. An earlier round's gate
    header claimed a x86 pass=1:fail=0 GREEN that no archived serial
    or 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).
  • Two-sided oracle repairs (review finding B4). The race harness
    (kernel/src/fs/ext2_lock_race.rs) previously could report a false
    PASS 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; a
    positive EXT2_LOCK_PARKS delta is required for PASS, checked both
    in-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 COMPLETE line in the same
    chronological log).
  • Structural ratchet, mutation-proven (review finding B5), 26
    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 == 1 preempt-count
    check, guard-drop release-before-wake ordering, checked-vs-bare
    prepare_to_wait, the mount-check accessors routing through the
    park-capable path instead of a raw .read() (both scoped to those
    three 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."
  • Wake-path correctness and honesty
    (Ext2ReadGuard/Ext2WriteGuard::drop).
    A has_waiters() "fast
    path" added in an earlier round was found, this round, to take the
    same lock wake_up_one()/wake_up() already take — saving nothing
    on 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).
  • Corrections carried in this PR (closing out round-2 review
    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_write
    only — 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 variable failure hit once in an earlier round's own
    archived RED run; the in-kernel EXT2_LOCK_PARKS doc comment and the
    gate 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

  • A writer now parks while holding the UPGRADED bit across every
    retry 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 held
    UPGRADED only while actually running. Real, new, user-visible
    latency; not exercised by the service-sequence battery (not a
    contention soak); the alternative (drop UPGRADED between 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.
  • New exposure this fix creates, filed as ext2 lock: SIGKILL to a thread parked in the park path leaks the guard, wedging ROOT_EXT2/HOME_EXT2 forever #746 (open): SIGKILL
    to a thread parked mid-acquisition abandons its stack without
    running the held guard's Dropterminate_process_threads()
    unconditionally marks every victim thread terminated without
    unwinding it, so a killed thread that was BlockedOnIO inside
    ext2_acquire/ext2_acquire_write wedges ROOT_EXT2/HOME_EXT2 for
    the 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-delivery
    fix, 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_PARKS is a single global counter, read as a delta
    across 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 besides
    the one documented, pre-existing, not-from-this-repo core
    future-incompat notice.
  • scripts/check-kernel-no-neon.sh — PASS, 0 FP/SIMD instructions.
  • All 22 tests/*structure*.rs suites 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=126 total, kernel live after COMPLETE.
  • Branch merged forward with 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

ryanbreen and others added 16 commits September 1, 2026 11:10
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>
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.

Both arches: concurrent ext2 read-park vs write-spin is a livelock shape (root & home filesystems)

1 participant