C5 residue: mprotect against fork, and one descriptor table per process
C5 closed with two items written down as out of its scope (phases.md, "Still open"). Both are closed here (2026-09-27), together with a third defect found while proving the second.
mprotect held the address-space lock for half the call (M-071)
hw_sys_mprotect took hw_mm_lock around the region-list update and released
it before narrowing the page tables. The reason was real when it was written:
narrowing does a TLB shootdown, and a sibling spinning in hw_mm_lock with
interrupts off could not acknowledge the IPI, so the shootdown timed out and
panicked. So a fork on another thread - which walks the page tables under the
same lock - could copy an entry mprotect was halfway through changing.
The reason has gone. The lock's spin opens an interrupt window every turn, and since M-065 a waiting core answers flush requests itself. mprotect now holds the lock from the range check to the last entry, with a single exit.
Reading the function for that found the other half. The range check and the
page-table pass both asked hw_pte_lookup, which answers only for a present
entry - so mprotect on a page in swap was refused with EFAULT, although a
swapped entry keeps its permissions (M-063) and vibeos_vmspace_protect
already knew how to change them. The check now accepts present or swapped, and
the pass hands every page to vibeos_vmspace_protect.
Proof. svc-reclaim now makes every block read-only before reading it
back; by then most of the oldest are on disk. With the check put back to
"present only" (scripts/dev/cases/mm-mprotect.txt) the boot goes red with
reclaim_load_failed - RECLAIM_FAIL: mprotect refused block 0 - for the
right reason, and green without the sabotage. The lock itself has no case:
a fork has to read an entry in the few instructions between the list update and
the narrowing, which one boot does not arrange. Recorded as untested.
The ABI self-test's case that mprotect refuses an unmapped page
(linux-abi.txt) had its anchor re-pointed at the new check, found by
check-sabotage-anchors.py on the first check.sh all.
Descriptors were copied per thread (M-072)
clone(CLONE_VM|CLONE_THREAD) gave the new thread a copy of its creator's
table, and the source said so in a comment. So a descriptor one thread opened
did not exist in the others, and a close in one left the rest holding it - the
last of the five process-state copies core_c5_process_state.md lists.
The table moved into hw_procstate_t, beside the dispositions and the region
list, and a thread shares it by holding the same reference. fork and exec take
a copy (hw_fds_copy, every pipe end in it gaining an owner); a thread takes
nothing.
Sharing the table made two things true that were not:
- Two threads can open at once.
openlooked for a free slot, then read the disk, then marked the slot used - a sibling opening meanwhile found the same slot free. Claiming now goes throughfiles_lockand happens before the lookup; a failed lookup gives the slot back.pipeclaims its two ends the same way instead of scanning by hand.closetakes the entry out of the table in one step under the lock and finishes with its own copy, so a second close of the same descriptor from another thread gets EBADF instead of releasing a pipe end twice.dup2reads, replaces and re-references in one critical section. The lock is not held across I/O: two threads reading one descriptor at the same moment share its position without ordering, a difference from Linux recorded in the structure's comment. - Who closes the table. Every exit closed its own copy. Now the table goes
with the last thread to leave it, counted by
files_users- apart from the process reference, because exit gives that back only after it has switched away, and closing a pipe has to happen before. The same change fixed a defect nobody had reported: each thread's exit released all of the process's sockets by thread-group id, so a worker that finished closed the connections its process was still using. They now go with the last thread too.
Proof. THREADS_C5_FILES in tests/linux/musl_threads.c: a thread makes a
pipe and main reads from it; then main closes the write end while another
thread waits to write through it, and that write must fail. The gate asserts it
(thread_descriptors_not_shared, threads_c5_files_did_not_report). Run
against the kernel before this change - the test first, the fix stashed - it
printed THREADS_C5_FILES_FAIL: a pipe a thread opened is not open in main.
Sabotage (scripts/dev/cases/fd-shared.txt): a clone that does not count
itself as a user of the table, and an exec that does not copy it. The lock has
no case, for the same reason as mprotect's.
The first of those went NOT RED at first, and the reason is worth more than the
case. With the increment gone, the first thread the threads test ever creates
takes the count to zero on exit - closing a table that held nothing of interest
yet - and every exit after that wrapped it below zero, so by the time
THREADS_C5_FILES ran nothing closed anything and the test passed. The symptom
appears once, wherever the first exit happens to fall; the underflow happens on
every exit after it. So the leave refuses to go below zero and counts the
attempt as files_double_leave, on the [TASKS] MUSTBEZERO line the gate
already asserts - the mechanism gated, not one lucky symptom.
check-task-identity.py asserted the table was embedded in hw_task_t; it now
asserts the opposite and that hw_procstate_t holds it, and its ratchet on
lines that index the table directly went from 17 to 12.
rmap_missing_remove was an eviction finishing inside a teardown (M-073)
Running the new threads test against the old kernel was supposed to show one
failure. It showed three, and one of them was mm_rmap_missing_remove=1 - the
rare counter that had fired once in about fifty boots and had been instrumented
since. The instrumentation named it at once: RMAP_MISSING with a caller in
vibeos_vmspace_swap_out, and a virtual address inside svc-reclaim's blocks.
That boot's svc-reclaim had exited early - the old mprotect refused its first
block - with some 1,400 blocks still mapped, so its teardown ran while the clock
was evicting from it. vibeos_vmspace_destroy calls vibeos_rmap_forget_root,
which removes every holder recorded for the root first and only then waits
for reclaim's claims on it (M-056). A reclaimer that claimed a page before the
teardown finishes its swap-out afterwards: the entry is still there, the commit
succeeds, and its vibeos_rmap_remove finds nothing, because the teardown took
it. Nothing is wrong - the frame is released once, by the eviction, and the
teardown frees the slot the entry now names - but the must-be-zero counter
said otherwise.
forget_root now marks the claims it waits on, and a remove that finds nothing
for a root with a marked claim outstanding is counted as
removed_after_forget, which is printed and is not a must-be-zero. A remove
that finds nothing anywhere else is still missing_remove. The host test
test_eviction_during_teardown plays the reclaimer from the wait's relax hook,
and the sabotage that stops marking (mm-rmap-claims.txt) turns it red. Clearing
the mark when the last claim goes was tried as a case too and went NOT RED: the
mark is read only while a claim is outstanding and a new claim clears it, so the
clearing was redundant and was removed rather than tuned.
Lesson worth keeping from the order of events: the rare defect was not found by hunting for it. It appeared when a load that normally runs to completion stopped early and left a large teardown to race the clock - an arrangement no test had set up on purpose.
The arrangement, made on purpose
That arrangement is also where the two defects still open were seen: M-070
(a page lost and a slot freed twice) in a boot where svc-press exited beside
the load, and M-068 in the boot where the load's first version outlived init.
Twenty-four boots with the clock's second chance disabled - the thrash in which
M-070 first appeared, rebuilt in a separate worktree - were all clean, and all
had rmap_claim_waits=0: not one teardown ever met an eviction in progress.
Thrash alone does not produce it.
So svc-reclaim has a second half. After RECLAIM_OK, a child fills memory
past the low mark as the main load did, tells the parent through a pipe, and
exits with everything still mapped; the parent allocates at once, below the low
mark, so every page it takes goes through reclaim, and the clock lands mostly
on the child's frames. The first version had the child exit immediately and the
race still did not happen: every page it owned had just been written, so the
clock's first pass only cleared accessed bits, and the teardown was over before
it came round again. The child now waits until swap has taken 64 more pages -
until reclaim is evicting - before it exits. The first boot of that version
reported rmap_claim_waits=1 rmap_removed_after_forget=1: exactly the event that
had been rmap_missing_remove=1, now arranged and classified. The gate asserts
RECLAIM_ABANDON_OK (reclaim_abandon_did_not_finish); what the race must not
produce - a slot freed twice or left allocated, a frame lost - is caught by
counters it already asserts.