Skip to content

test(mm): probe shared-fault races and fail-stop same-CPU lock reentry - #86

Open
YUZHEthefool wants to merge 7 commits into
mainfrom
test/st-k2-p2
Open

YUZHEthefool wants to merge 7 commits into
mainfrom
test/st-k2-p2

Conversation

@YUZHEthefool

Copy link
Copy Markdown
Member

Problem and outcome

The shared-anonymous fault paths had no guest evidence under real concurrency, which kept P0-A strict qualification open (#61). This PR adds the four probes from the #61 execution plan, each with a guest oracle that fails when the property it checks is broken. It also fixes the one defect probe (a) is meant to catch.

  • (a) Same-CPU reentry: repaired. handle_shared_page_fault try-locks PROCESS_TABLE, the faulting task's PCB and its MmState, and returns Busy when any of them is taken. Only PT_LOCK recorded its owner. If the context the fault interrupted held one of the other three on the same CPU, the fault retried through IRETQ forever: a silent livelock, with IF=0 on the usercopy path. The three locks now record their holding CPU, and that case fail-stops with same-CPU <lock> reentry during shared fault.
  • (b) Cross-CPU page-table holder and shootdown ACK. A munmap holding PT_LOCK before its range shootdown receives its acknowledgement from a CLONE_VM sibling spinning on an IF=0 Busy fault. The ACK can only arrive through the #PF Busy arm's mailbox drain.
  • (c) Concurrent final owner and parent-first unmap. A shared region's last owner is a fork child, or a CLONE_VM thread, exiting while the parent unmaps the region and churns another one. memory.current and the buddy's free pages return exactly to baseline after every round, 200 rounds per variant.
  • (d) Private rollback reclamation. A private population beside a page table built by a shared fault fails by injection, three times each for mmap and mremap growth, with exact ENOMEM and exact memory.current. On SMP, a third case makes the rollback itself prune the shared-fault table, so only settle_failed_populate can release its charge.

Related issue or audit finding

Closes #61 once the maintainer confirms at merge review that P0-A strict qualification closes (see "Decisions needed").

Plan item: ST-K2-P2

Design record: Zero-kernel/Nilix-docs-private#2 (design/st-k2-p2-race-probes-design.md). It covers each race window, how the hook forces it, the exact markers, and the 16-vCPU finding below (§6.1).

Design and scope

One commit per probe, plus the fixture refresh:

Commit Change
fix(mm): stop on a same-CPU lock reentry in the shared fault kernel_core::owner_lock::OwnerCpuMutex<T> is a spin::Mutex plus an owner record, set after acquiring and cleared in the guard's Drop before release, mirroring PT_LOCK_OWNER_CPU. It is now the type of ProcessArc, Process.mm and PROCESS_TABLE. A failed fault-path try-lock owned by the current CPU returns SharedFaultResult::Reentry(lock), and the #PF arm panics next to the existing PT assertion. Contention from another CPU stays Busy. Two hosted tests.
test(mm): probe same-CPU lock reentry in a dedicated guest image Feature shared_fault_reentry_probe (the open_fault_probe pattern). A musl guest maps one untouched shared page and asks, through a probe-only prctl, to fault it in while the kernel holds the chosen lock. In this image the panic handler exits QEMU through isa-debug-exit (status 35), so a fail-stop can be told apart from a hang. scripts/gates/qemu/shared_fault_reentry_probe.py runs one guest per lock. Its verdict has mutation tests.
test(mm): prove a remote page-table holder gets its ACK from a Busy fault A syscall_test hook pauses munmap Phase 2, PT_LOCK held, before the range shootdown. It waits until a Busy on the armed cold page has been seen while another CPU owns PT_LOCK. Both #PF Busy arms call tlb_shootdown::drain_from_fault(), the same drain as before; in syscall_test builds it counts the drains that delivered an ACK. New mm feature syscall_test.
test(mm): race a shared region's final owner against a parent-first unmap Ring-3 leg, both variants, the other owner pinned to CPU 1 on SMP. Kernel-stack page tables are built per new PID slot and never freed, so buddy free pages are compared together with a syscall_test count of those tables. The check stays exact: one leaked frame fails it.
test(mm): fail private populations beside a shared-fault page table Re-armable syscall_test population failure at an exact page count, optionally paused first with no lock held. It uses 515 pages, so it never prints an R192-RES-2 marker or consumes the boot-armed 16387-page probe.
test(userspace): refresh the syscall fixture for the ST-K2-P2 race legs Rebuilt on the dev host from the previous commit's userspace source.

The owner record is a per-CPU token read from MSRs, not current_cpu_id(). The token is the CPU's SYSCALL_PERCPU slot address. It comes from IA32_GS_BASE, or from IA32_KERNEL_GS_BASE while a Ring-3 entry still has the user base live. A user GS base is always in the user half (validate_user_segment_base), so user code cannot forge a token. Hosted builds use current_cpu_id(), because rdmsr faults in a user process. The first version recorded current_cpu_id(), which reads the LAPIC ID over MMIO on every acquisition of these hot locks. Under TCG at 16 vCPUs that made a hang already present on main strike within a few rounds of the (c) leg, 3 runs out of 3. With the owner-lock commit reverted, or with the store kept but no MMIO read, (b), (c) and (d) completed instead (details below and in the design record §6.1).

Deviations from the handoff text, with reasons in the design record:

  • PROCESS_TABLE also records its owner. The fault try-locks it first, so a holder of it livelocks the same way.
  • (d) re-arms the injection by page count instead of reusing the boot-armed probe. Reusing that probe would add three result=enomem markers and break the R192-RES-2 oracle's "exactly one" check.
  • Not covered:
    • brk in (d): its 2 MiB block cannot also hold a shared page.
    • The COW path in (a): it takes none of the three locks.
    • The stack-grow path in (a): it try-locks them only for Ring-3 faults, when no kernel context on that CPU can hold a spin lock.

Non-goals: no new memory features.

Kernel safety and invariants

  • Trust boundaries and attacker-controlled inputs: the new prctl operations exist only in syscall_test and shared_fault_reentry_probe builds. Production kernels gain the owner record and the fail-stop, nothing user-reachable. The owner token cannot be influenced from user space; see above.
  • unsafe / MMIO / page-table / usercopy justification: one new unsafe block reads IA32_GS_BASE and IA32_KERNEL_GS_BASE, which have no side effects and exist on every x86-64 CPU. The probe image's isa-debug-exit write sits in the panic handler's existing unsafe block.
  • IRQ, preemption, lock ordering, and SMP behavior: an owner test is sound because kernel mode is not preemptible and none of these guards is held across a task switch, the same assumption PT_LOCK_OWNER_CPU makes. The record is set after acquisition and cleared before release, so it never names a CPU that has released the lock. Lock order is unchanged. The (b) pause holds PT_LOCK with IF=0, the (d) pause holds no lock with IF=1, and both are TSC-bounded like cow_test.
  • Fallible allocation, quotas, and rollback/commit ordering: unchanged. (c) and (d) measure them.
  • ABI/layout/syscall compatibility: no ABI change. The PCB and MmState mutexes grow by one AtomicUsize; the existing size_of assertion still holds.
  • Fail-closed and error-path behavior: a same-CPU hold now panics instead of livelocking. Cross-CPU contention keeps its retry.

Validation evidence

Dev host (QEMU 6.2, TCG, 40 cores), CI_GUEST_RETRIES=0. Baseline for comparison: main @ f998237 (P0.1, handoff §9). Logs and serials stay on the host under /tmp/nilix-evidence/st-k2-p2/<sha>/. .validation/st-k2-p2/ records each command, status and log hash.

The guest gates ran at 8f9d736. It differs from the head 99267d9 only in which cfg guards the MSR read in owner_lock.rs: target instead of feature, a fix for a hosted SIGSEGV in ipc-robust-futex. The bare-metal kernels are byte-identical: default 759d0e09…, syscall_test 24274492… at both commits (.validation/st-k2-p2/kernel-identity.sha256). L0/L1 ran at 99267d9. fa4b588 and 8f9d736 are local revisions from before the owner-token fixes were folded into the first commit; they are not on the remote, and the evidence files keep their names.

make fmt-check / clippy / lint / build                    PASS (0)
usercopy exception-table runtime check                    PASS (0)
make test-hosted-subcrates (debug / release)              PASS, 640 unit tests each (+2: owner_lock)
entrypoint quality                                        exit 1: lint, shell-syntax, ext3-fixture PASS; pytest 243 passed,
                                                          0 failed, 9 errors, all private_fuzz_evidence_test (`age` is not
                                                          installed on the host, as in the baseline)
entrypoint license (reuse lint)                           PASS (0)
entrypoint runtime: boot / runtime / SMP-4                QUALIFIED 28/46/0, 35/39/0, 37/37/0 (same as baseline)
entrypoint musl: 1 / 4 CPU                                PASS / PASS
make test-smp-extended (8 and 16 CPUs)                    QUALIFIED 37/37/0 each, failed=0 (make exit 3, as on main)

Probe gates:

(a) shared_fault_reentry_probe.py, PROCESS_TABLE / PCB / MmState   PASS (status 35, marker exactly once, no other fatal marker)
    negative control, fail-stop disabled (MmState)                 FAIL as expected: spins after the HOLD marker until the timeout
ring3-mm 1 vCPU                                            RING3-MM OK, 53 passed / 0 failed
ring3-mm 4 vCPU                                            RING3-MM OK, 55 passed / 0 failed, min_drain_acks=2 min_busy=1
ring3-mm 3 GiB lane (1 vCPU, RING3_MM_MIN_RELOC_PAGES=131072)  RING3-MM OK, 53 passed / 0 failed, relocation pages=131072
ring3-mm 8 vCPU                                            RING3-MM OK, 55 passed / 0 failed
ring3-mm 8 vCPU, NILIX_ST_K2_REMOTE_REPS=300, run 1        (b) PASS reps=300 (min_drain_acks=2 min_busy=1); the guest then hung
                                                           inside (c). Hot PCs: Scheduler::lock_ready_queue_or_defer,
                                                           reschedule_now, ap_idle_loop, SocketTable::drain_deferred_port_uncharges,
                                                           the signature of the 16-vCPU hang on main. Gate: FAIL
ring3-mm 8 vCPU, NILIX_ST_K2_REMOTE_REPS=300, run 2        RING3-MM OK, 55 passed / 0 failed, ST-K2-REMOTE-HOLDER-ACK PASS reps=300
ring3-mm 16 vCPU                                           FAIL, as on main: hangs in test_selective_wait_nested_zombie before
                                                           any ST-K2-P2 leg runs (44 legs passed first, no panic)
16 vCPU, that one call removed (diagnostic, x2)            (b)(c)(d) PASS in both; both then hung in the R192 bounded-population
                                                           leg
    negative control (b): Busy arm without the mailbox drain   FAIL as expected: "CRITICAL: TLB shootdown ... did not ACK"
    negative control (d): settle without the ledger debit      FAIL as expected, only that leg: "concurrent: the pruned shared-fault
                                                               table stayed charged" (54 passed / 1 failed)

Markers in every passing run: ST-K2-FINAL-OWNER PASS variant=process|thread iterations=200 once each, and ST-K2-PRIVATE-ROLLBACK PASS site=mmap|mremap cycles=3 once each. On SMP, also ST-K2-REMOTE-HOLDER-ACK PASS reps=<n> and site=mmap-concurrent cycles=3.

The 16-vCPU finding, at the first version fa4b588 (the MMIO owner read). Each run skipped only the known-hanging test_selective_wait_nested_zombie call:

fa4b588, MMIO owner read                        (b) PASS 3/3; (c) hung after 2-3 rounds 3/3
fa4b588 with the owner-lock commit reverted     (b)(c)(d) PASS; hung later in a futex PI leg
fa4b588, owner store kept, no MMIO read         (b)(c)(d) PASS 2/2; hung later (futex PI, R192 bounded population)

Observed, not caused by this PR: in one (d) negative-control guest at fa4b588, run while six TCG lanes loaded the host, the existing leg "COW sibling cold read during the paused PTE replacement" failed with host status 0xc8. That leg waits inside a TSC-bounded pause. It passed in every other run, including the same negative control at 8f9d736.

Compatibility, performance, and rollout

Two rdmsr at most and one atomic store per acquire and release of the PCB, MmState and process-table locks. 8-vCPU ring3-mm took 846 s at 8f9d736, against 1076 s with the MMIO version. The hosted count is now 640 per profile. README and README_zh still say 632, and #84 changes those lines to 638, so they need a follow-up to 640 after #84 merges. They are left alone here to keep the two PRs off the same files.

Decisions needed

  • Whether P0-A strict qualification closes with this evidence (maintainer call at merge review, per [Tracking]: qualify shared-fault races (ST-K2-P2) #61).
  • ring3-mm at 16 vCPUs still fails, as on main. The guest stops in test_selective_wait_nested_zombie, and when that leg is skipped it stops in a later one. That hang is the separate 16-vCPU item from the P0 baseline (handoff §9) and is not addressed here. The 16-vCPU evidence for the new legs therefore comes from diagnostic runs with that one call removed.
  • The (c) leg's fork/exit churn can also trigger that hang below 16 vCPUs: one of two 8-vCPU ×300 runs hung inside (c) with the same scheduler signature. This is the pre-existing defect showing up under a new workload, not a defect in the probed paths. How that defect is reported (issue or private advisory) is still open from the P0 handoff, so no issue was opened for it here.

Checklist

  • The change is focused, bisectable, and free of unrelated refactoring.
  • I added a regression test or explained why an automated test is not practical.
  • I ran the relevant formatting, lint, build, runtime, ABI, SMP, storage, or fuzz gates listed above.
  • New unsafe blocks have local SAFETY reasoning and a minimized unsafe scope.
  • I preserved IRQ/lock-order rules, bounded attacker-controlled work, and reserve-before-commit accounting.
  • I updated architecture, safety, testing, roadmap, or user documentation when behavior changed. (README count follow-up after docs: sync the hosted test count with the pinned suites #84; plan and design results after merge)
  • New fixtures/artifacts are minimal, reproducible, redistributable, and contain no confidential data.
  • Breaking behavior, residual risk, deferred validation, and untested hardware are called out explicitly.

handle_shared_page_fault try-locks PROCESS_TABLE, the faulting task's
PCB and its MmState, and returns Busy when any of them is taken. If the
context the fault interrupted on the same CPU holds one of them, the
retry through IRETQ can never succeed, and the guarded usercopy path
retries with IF=0, so the CPU livelocks silently. Only PT_LOCK had an
owner record to tell that case apart.

Add OwnerCpuMutex, a spin mutex that records its holding CPU like
PT_LOCK_OWNER_CPU, and use it for those three locks. A failed try-lock
whose owner is the current CPU now returns SharedFaultResult::Reentry,
and the #PF arm panics with "same-CPU <lock> reentry during shared
fault". Contention from another CPU stays Busy.

The owner record is the CPU's SYSCALL_PERCPU slot address, read from
IA32_GS_BASE (or IA32_KERNEL_GS_BASE while a Ring-3 entry still has the
user base live), not cpu_local::current_cpu_id(). That function reads
the LAPIC ID over MMIO, and doing so on every acquisition of these hot
locks slowed 16-vCPU TCG guests enough that a hang already present on
main struck within a few rounds of the new final-owner leg; with the
MSR token that leg completes there.

Plan item: ST-K2-P2
The shared_fault_reentry_probe image boots a musl guest that maps one
shared-anonymous page and asks the kernel, through a probe-only prctl,
to fault it in while holding PROCESS_TABLE, its PCB or its MmState. The
panic handler of this image leaves QEMU through isa-debug-exit, so the
fail-stop ends the guest with status 35 while a kernel that retries the
fault as contention spins until the timeout.

scripts/gates/qemu/shared_fault_reentry_probe.py runs one fresh guest
per lock and requires status 35, the HOLD marker once, then exactly one
panic from the page-fault handler naming that lock, and no other
terminal marker. Its verdict has mutation tests.

Plan item: ST-K2-P2
…ault

When a CPU in munmap holds PT_LOCK and waits for shootdown ACKs, a
sibling of the same address space that takes a shared fault from a
guarded usercopy gets Busy and retries with the saved IF=0. It cannot
take the shootdown IPI, so the holder's wait finishes only because the
#PF Busy arm drains the local mailbox. Nothing exercised that path.

Add a syscall_test-only hook that pauses munmap Phase 2, with PT_LOCK
held, right before its range shootdown until a Busy has been seen on an
armed cold shared page while another CPU owns PT_LOCK. Both #PF Busy
arms now call mm::tlb_shootdown::drain_from_fault(), the same drain,
which in syscall_test builds counts the drains that delivered an ACK
(new mm feature syscall_test, forwarded from kernel and kernel_core).

The Ring-3 leg runs the holder on CPU 0 and a CLONE_VM sibling on CPU 1
that copies uname into the cold page. Each round needs munmap to return
0, the copy to finish, the hook to complete, Busy >= 1 and drain ACKs
>= 1. NILIX_ST_K2_REMOTE_REPS sets the rounds at fixture build time.
The ring3-mm oracle requires the leg and exactly one
"ST-K2-REMOTE-HOLDER-ACK PASS reps=<n>" marker on SMP.

Plan item: ST-K2-P2
…nmap

The last address space to drop a shared-anonymous region frees its
frames and refunds its charge. Nothing checked that when the final
owner is a fork child exiting after its parent unmapped first, or a
CLONE_VM thread exiting while the parent unmaps, with another shared
region churning on the other CPU.

The Ring-3 leg runs both variants, the other owner pinned to CPU 1 on
SMP, NILIX_ST_K2_FINAL_OWNER_ITERS times each (default 200) after one
warm-up. After every round memory.current and the buddy's free pages
must come back to the baseline with no tolerance. The page tables
allocate_kernel_stack builds for a new PID slot are never freed, so
free pages are compared together with a new syscall_test count of
those tables (prctl op 6); one leaked data or table frame still fails.
The ring3-mm oracle requires one "ST-K2-FINAL-OWNER PASS" marker per
variant at the configured iteration count.

Plan item: ST-K2-P2
MmState::settle_failed_populate (LEAD-ROLLBACK-PRUNE-LEDGER-1) debits
the ledgered page tables a failed population's rollback prunes,
including one a shared fault built. It had no guest evidence: the only
injected failure is the boot-armed 16387-page probe, which fires once.

Add a re-armable syscall_test failure of the next private population
of an exact page count in the arming address space (prctl op 7), at
its last data frame like the R192-RES-2 probe, optionally paused first
with no lock held. The guest leg maps a shared page at 160 GiB, faults
it in, and fails a 515-page population beside it three times each for
mmap and for mremap growth: ENOMEM exactly, memory.current unchanged by
the failed call, and back to the baseline after the shared munmap. On
SMP a third case pauses the population while a CLONE_VM sibling unmaps
the shared page; the rollback then empties and prunes the shared-fault
table itself, and memory.current returns to the baseline only if
settle_failed_populate debits it. brk is not covered: its 2 MiB block
cannot also hold a shared page.

Plan item: ST-K2-P2
Rebuilt on the dev host with the pinned nightly from the userspace
source of the previous commit, with no NILIX_* build-time overrides:
one remote-holder round and 200 final-owner iterations per variant.

Plan item: ST-K2-P2
Command, exit status and identity (revision, environment, log and serial
hashes, notes) for every gate run on the dev host. L0/L1 ran at the
branch head. The guest gates ran at 8f9d736, whose bare-metal kernels
are byte-identical to the head's (kernel-identity.sha256). The fa4b588
runs are the 16-vCPU diagnostics behind the MSR owner token. Logs stay
on the dev host; README.md indexes the files.

Plan item: ST-K2-P2
@github-actions github-actions Bot added this to the N1 Preview chain milestone Nov 7, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation area: boot Bootloader, early boot, kernel entry, and image layout area: arch/x86 x86_64 interrupts, context switch, APIC, SMP, and TLB operations area: memory Allocators, page tables, COW, admission, cache, and OOM area: process/ABI Processes, syscalls, usercopy, signals, ELF, and Linux ABI area: IPC Pipes, futexes, message queues, and IPC namespaces area: userspace Ring-3 programs, libc compatibility, and userspace support area: tests Unit, hosted, runtime, QEMU, SMP, stress, and conformance tests area: tooling/CI Build system, scripts, hooks, automation, and repository tooling dependencies Dependency or toolchain updates size: XL At least 1000 review lines, excluding generated/binary artifacts labels Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: arch/x86 x86_64 interrupts, context switch, APIC, SMP, and TLB operations area: boot Bootloader, early boot, kernel entry, and image layout area: IPC Pipes, futexes, message queues, and IPC namespaces area: memory Allocators, page tables, COW, admission, cache, and OOM area: process/ABI Processes, syscalls, usercopy, signals, ELF, and Linux ABI area: tests Unit, hosted, runtime, QEMU, SMP, stress, and conformance tests area: tooling/CI Build system, scripts, hooks, automation, and repository tooling area: userspace Ring-3 programs, libc compatibility, and userspace support dependencies Dependency or toolchain updates documentation Improvements or additions to documentation size: XL At least 1000 review lines, excluding generated/binary artifacts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Tracking]: qualify shared-fault races (ST-K2-P2)

1 participant