Skip to content

Add machine-checked proofs for critical arithmetic paths - #277

Open
jserv wants to merge 30 commits into
mainfrom
formal
Open

Add machine-checked proofs for critical arithmetic paths#277
jserv wants to merge 30 commits into
mainfrom
formal

Conversation

@jserv

@jserv jserv commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary by cubic

Adds machine-checked proofs for critical arithmetic paths and enforces them in CI with mutation testing. Further hardens process management and CI checks.

  • New Features

    • Proved headers: src/core/stack-math.h, src/syscall/cmsg-math.h, src/syscall/fuse-math.h, src/syscall/netlink-math.h, src/syscall/sigframe-math.h, src/syscall/sockaddr-math.h; targets: verify-stack, verify-cmsg, verify-fuse, verify-netlink, verify-sigframe, verify-sockaddr, verify-gva.
    • Mutation testing: make verify-mutants sharded one job per target; supports MUTANT_JOBS and MUTANT_SINCE; requires UNPROVED verdicts; inputs come from the compiler’s include closure; harness inputs include mk/toolchain.mk and .github/workflows/main.yml.
    • CI guard: adds a matrix drift check to keep the verify-mutants YAML in sync with mk/analysis.mk targets, and rejects duplicate matrix entries.
  • Bug Fixes

    • Process waits: guard host PID reuse after unlocked waits by also matching guest_pid on relock; covers proc_deactivate_slot_if_matches, targeted waits in sys_wait4/sys_waitid, and deferred-reap polling.
    • Fork IPC: close leaked host fds when a received guest_fd is out of range.
    • Sysroot probing: treat a real read(2) error as failure instead of success with a truncated buffer.
    • Proc table shrink: remove stale-slot indexing that could go out of bounds; switch to host-pid lookups after relock; include host_reap_pending in “any live” checks.
    • msync: open fsync fds with F_DUPFD_CLOEXEC, dedup per backing file, fall back to direct fsync on dup failure, and size scratch arrays after taking mmap_lock.

Written for commit 76ca8d1. Summary will update on new commits.

Review in cubic

jserv added 8 commits August 7, 2026 10:27
A guest supplies msg_control verbatim, and sys_sendmsg walked it as a
hand-rolled TLV loop. Each entry's cmsg_len is eight bytes read straight
out of guest memory and drives both the payload extent the host copies
and the advance to the next entry, so the no-underflow and termination
argument rested entirely on the ordering of two guards.

Move that arithmetic to src/syscall/cmsg-math.h and prove it:
make verify-cmsg discharges 17 of 17 obligations. Follows the gva-math.h
pattern, since net-msg.c cannot be given to Frama-C (it includes the
macOS socket headers) while the extracted header needs only stdint.h.

The host CMSG_SPACE / CMSG_LEN / CMSG_FIRSTHDR / CMSG_NXTHDR macros stay
outside the proof boundary. They describe the macOS layout, and a stub
for them would be an unchecked model of the thing in question; the caller
keeps them and passes only Linux-side numbers in. The recvmsg direction,
which does use those macros, is not covered.

Two details the prover forced. The align-up is written as
advance -= advance % CMSG_LINUX_ALIGN rather than (len + 7) & ~7: the
bitwise form leaves both next_pos bounds open because the prover must
first establish the mask is one less than a power of two. And the
contract needs an exact-advance postcondition, not just a bound: with
only next_pos > pos and the overshoot bound, dropping the align-up
entirely satisfies every clause, staying memory-safe while walking to a
misaligned next header. Those two next_pos bounds are corollaries of it,
kept because they state termination and the caller's own bound check in
the form the caller reasons about.

Four mutations confirm the gate bites: dropping either guard, changing
the align-up addend, and dropping the align-up altogether.

Both directions now use the named constants. sys_sendmsg bounds
msg_controllen against CMSG_LINUX_CTL_MAX before narrowing it to size_t,
so the check that establishes the proof's precondition is spelled the
same way the precondition is; sys_recvmsg, which builds the control
buffer rather than walking a guest-supplied one and so stays outside the
proof, was migrated off its own literals to stop the two drifting apart.

Behavior preservation checked rather than assumed: 46 million
differential cases against the original inline arithmetic found zero
mismatches, and the subtract-the-remainder align-up is a pure identity
with (x + 7) & ~7 across the whole uint64 range including wraparound.
The FUSE daemon is another guest process, so the reply header it writes
to /dev/fuse is hostile input. Its len field decides how many bytes the
host copies out of the frame buffer. That is not a hypothetical concern:
the fuse_read_common overrun fixed earlier was a defect of exactly this
class, in this file.

Move the frame arithmetic to src/syscall/fuse-math.h and prove it:
make verify-fuse discharges 27 of 27 obligations. fuse.c cannot be given
to Frama-C (pthread plus the macOS headers), while the extracted header
needs only stdint.h. fuse_out_header_t moves with it so a _Static_assert
ties FUSE_OUT_HDR_BYTES to the struct the code actually memcpys.

Three helpers. fuse_frame_count_ok bounds the daemon's write.
fuse_reply_extent computes the payload extent, and its load-bearing
postcondition is FUSE_OUT_HDR_BYTES + reply_len <= count, which is what
the caller's memcpy from buf + FUSE_OUT_HDR_BYTES rests on.

fuse_clamp_negotiated_write is why this target is worth more than a
subtraction. A read reply is a header plus at most max_write payload
bytes, and fuse_dev_write rejects frames above FUSE_FRAME_CAP, so
FUSE_OUT_HDR_BYTES + max_write <= FUSE_FRAME_CAP is what keeps a
successfully negotiated size from producing replies the write path then
refuses. That was a prose claim in a comment and is now a proved
postcondition. A mutation setting FUSE_MAX_NEGOTIATED_WRITE equal to
FUSE_FRAME_CAP fails the proof, which is what shows the 256 bytes of
slack are load-bearing rather than arbitrary.

Five mutations confirm the gate bites: dropping either half of the
hdr_len guard, dropping the frame ceiling, making the clamp a no-op, and
removing the slack.

Scope is the frame level only. Per-opcode payload extents would drag
about 125 lines of ABI struct definitions out of fuse.c and would
collapse to reply_len >= want, generating almost nothing to prove; they
stay test-covered.

FUSE_FRAME_CAP changed type from size_t to unsigned long long in the
move, so every use in the tree was audited. Both macros are now
referenced only inside the proved header and two call sites, with no
signed comparison and no test uses, so no conversion changed. A
361,344-case differential test of the new helpers against the original
inline arithmetic, covering the uint32 hdr.len boundaries and the clamp
round trip, found zero mismatches.
guest.c cannot be given to Frama-C, so nothing checked that its call
sites honor gva-math.h's nine requires clauses. One of them says so in a
comment at guest.c:1477. This narrows that gap; it does not close it.

Only five of the nine clauses have a C expression. The four pointer ones
do not: assert(p != NULL) is strictly weaker than \valid(p), and
assert(a != b) misses overlapping distinct pointers into one object,
which is exactly the drift worth catching. Those stay review-only and the
header says so.

Rather than restating each precondition inline, the expressible clauses
are factored into two predicates carrying ensures \result != 0 <==> (...)
contracts, proved by make verify-gva. A restated condition can drift
weaker than the clause it mirrors with nothing to notice; a predicate
with an iff cannot, because weakening it fails the proof. One expression
serves both the contract and the runtime check.

verify-gva now runs with -DELFUSE_CONTRACT_ASSERT so the prover sees the
checks too and must discharge each from the clause it mirrors. That
extends the guarantee from the predicate bodies to the wiring: a check
handed permuted arguments fails the proof rather than surfacing later as
a spurious abort. 40 of 40 becomes 66 of 66.

What the proof cannot catch is a check wired to a constant that satisfies
the predicate instead of to the real argument, since that stays
derivable. tests/test-gva-contracts.c covers it from the other side by
violating each conjunct in a forked child and requiring the check to
reject. Verified complementary: that mutation keeps verify-gva green at
66 of 66 and fails the test.

make check-contracts rebuilds with the checks live and runs the suite;
82 passed, 0 failed, no check fired, so every call site the suite reaches
honors all five clauses. Injecting a deliberately wrong call into guest.c
aborts under that build and is silent without it.

Two hazards found in review and fixed. assert.h re-reads NDEBUG at every
include, so -DNDEBUG would have rebuilt, run the whole suite, and
reported success while checking nothing; #undef NDEBUG closes that.
And check-contracts now builds into its own directory: sharing BUILD_DIR
left instrumented objects behind, so a later make elfuse relinked them
into a default-looking binary carrying asserts on the guest_read /
guest_write hot path. Confirmed by symbol count, 0 in the default build
after a full contracts run, 1 in the contracts tree.
build_linux_stack lays out argc/argv/envp/auxv the way binfmt_elf does,
and two things there are easy to get wrong and hard to see wrong. The
string region walks down from stack_top by a guest-controlled number of
guest-controlled lengths. The structured area must leave SP 16-byte
aligned AND pointing directly at argc; a libc that finds either wrong
does not fail cleanly, it reads argv from the wrong offset.

New src/core/stack-math.h, proved by make verify-stack at 36 of 36.
stack.c cannot be given to Frama-C (it pulls in Hypervisor.framework via
guest.h and proc.h) while the extracted header needs only stdint.h.

Not a bug fix. An earlier reading suspected a guest could drive the
descent past the stack guard; it cannot, because read_string_array caps
combined argv/envp bytes at 2 MiB and the entry count at 131072 against
an 8 MiB stack. What changes is that containment is now held by
stack_take's own postcondition rather than by an argument about caps in
a different file.

The other half is worth more. total_entries = 35 + extra + argc + envc
is a hand-maintained count of the pushes below it, explained by a
fifteen-line comment. Adding an AUX() entry without updating it would
leave SP misaligned or short of argc with nothing to say so. The count
now precomputes the expected final SP through stack_pushed_words and
stack_final_sp, and the function fails loudly if the SP actually reached
differs. Verified by injecting an extra AUX(): it reports which count is
out of sync instead of booting a broken stack.

The floor is stack_base + STACK_GUARD_SIZE, not stack_base. Two
reviewers independently caught that: the low 4 KiB of the region is the
PROT_NONE guard, so bare stack_base would let the descent walk into it
and fail later inside guest_write with a worse diagnostic.

argc is now bounded below as well as above. A negative argc makes the
uint64 total_entries cast about 2^64, violating the STACK_MAX_WORDS
precondition, after which the word count wraps and stack_final_sp can
report success with a garbage SP. No caller does this today; the check
makes the code discharge the precondition rather than leaving it as an
argument about other files.

sys_execve now checks build_linux_stack's 0 return before programming
SP_EL0 from it. Past the point of no return there is no image to go back
to, so it aborts in the same shape as the other post-reset fatal errors.
The argc bound it could reject on is enforced upstream by an identical
cap, so the live path is allocation failure.

_Static_assert pins STACK_ALIGN to 16 and STACK_WORD to sizeof(uint64_t):
the proofs are parameterized over both, so changing either would still
prove while breaking the layout.

The push-count identity was checked rather than assumed: 20 AUX sites, 2
conditional, so the auxv words plus the three terminators equal
total_entries for all four combinations of execfd and vdso presence.
Confirmed empirically over 107584 cases, every final SP 16-aligned.
Named verify-stack, not verify-auxv, to match the file and prefix the
way the five sibling targets do.
Linux and macOS disagree about sockaddr: Linux has a 2-byte family and no
length byte, macOS has a 1-byte length plus a 1-byte family. Every bind,
connect, sendto, recvfrom and accept reshapes an address, and the length
driving that reshape is guest-supplied on the way in. Both directions
subtract the family bytes and clamp against the destination, and each
subtraction underflows if its guard is dropped. An underflowed length
here is a memcpy extent, so the result is a host overrun rather than a
truncated address.

New src/syscall/sockaddr-math.h, proved by make verify-sockaddr at 11 of
11. One clamp serves both directions because the destination capacity
arrives as a plain integer rather than a struct: the proof names no
socket type at all. That matters beyond tidiness. Frama-C 31 does define
struct sockaddr_storage, but with the POSIX layout and no ss_len field,
so proving against it would be proving against the wrong struct.

Both converters now call the helpers and the clamp logic is gone from
their bodies, which is the condition the TODO set for keeping this as a
target rather than closing it. At 11 obligations it is the smallest of
the seven, but each of the four mutations breaks a distinct property:
dropping the destination clamp, accepting under-length addresses,
returning no payload at all, and dropping the source precondition.

Two pins added in review, both the same shape: a constant the proofs are
parameterized over would still prove if changed while the code around it
broke. SOCKADDR_FAMILY_BYTES is asserted to be 2, since setting it to 1
would make net-abi.c accept 1-byte sockaddrs and copy from the wrong
payload boundary.

The other pin is load-bearing in a subtler way. mac_len is socklen_t and
is now widened to uint64_t before the length guard, where the old code
compared it directly. That is equivalent only because socklen_t is
unsigned on this platform; were it signed, a negative length would widen
to a huge value, pass the guard, and be clamped to the destination size
rather than rejected. _Static_assert((socklen_t) -1 > 0) makes the
dependency fail the build instead of failing silently. Writing the guard
as mac_len < 0 instead would be a tautological compare on an unsigned
type.
Every claim that a proof actually binds has been established by a
reviewer editing a body and watching the target fail. None of it was
recorded, so each contract change re-established it from scratch or, more
often, did not. make verify-mutants now carries 30 mutations, each of
which must make its target fail, and reports which proved functions have
none yet (8 of 27 today).

Mutations run against copies under build/mutants via
VERIFY_<TARGET>_SRC, so a run never edits the tree. That is not
fastidiousness: hand mutation testing with cp-and-restore left a mutated
source on disk twice during this work, and a reviewer independently
observed one of those windows.

Most of the effort went into not reporting success for the wrong reason,
because a harness that does that is worse than none. Three ways it did:
- An early version wrote logs to a directory that did not exist. Every
  run failed on the redirect, and all 27 mutations reported caught while
  proving nothing. A baseline control now requires an UNMUTATED copy of
  each source to still prove before any verdict counts.
- Concurrent runs shared build/verify-<name>.log. A clobbered log makes
  the recipe exit non-zero, which reads as caught. Each run now gets its
  own log via a NAME override.
- A mutation naming a file its target does not analyze silently analyzed
  the wrong file. The first rsp entry mutated hex_nibble in utils.h,
  which verify-rsp includes rather than analyzes; the baseline control
  caught it. A guard now rejects any mutation whose source is not its
  target's VERIFY_<T>_SRC, and the rsp entries moved to gdbstub-rsp.c.

The subtlest one came from review. A non-zero exit is not evidence: a
crashed prover or an unparsable mutant produces one too. Worse, MIN_GOALS
sits at exactly the baseline count for all seven targets, so deleting a
documented-redundant ensures clause, with the code untouched and every
remaining goal proving, also fails the target. Both scored as caught.
Verdicts are now classified from the output: a goal must go UNPROVED.
Floor-only failures report FLOOR and infrastructure failures report
INFRA, and both fail the run rather than counting. Under that stricter
rule all 30 mutations still pass, which is the evidence the harness was
supposed to gather and previously only assumed.

check-acsl-coverage.py also gained the plain-char check. gcc_x86_64
makes plain char signed where arm64 macOS makes it unsigned, and what
keeps the proofs signedness-independent is that no proved function reads
a plain char without an explicit cast. Deciding that for an expression
needs a C front end, which this tree does not have; what a regex can do
is require any proved function taking a plain char to be on an
allowlist, so a new one cannot appear silently. Writing it found the
makefile comment had listed three such functions when there are four:
gdb_parse_hex was missing.
make verify-netlink proves the arithmetic behind both netlink walks:
44 of 44 obligations. The request parse reads an rtattr chain out of
bytes the guest wrote, so its per-entry bounds are worth a proof on their
own; the reply span walk is here for termination.

The termination half turned out to matter more than expected.
nl_complete_span rounded hdr->nlmsg_len up in uint32 arithmetic, which
wraps to 0 for nlmsg_len >= 0xFFFFFFFD, so the cursor advanced by nothing
and the loop spun forever holding nl_lock.

An earlier reading of this, mine, called that unreachable because
ns->buf holds only elfuse's own synthesized replies. The bytes are ours;
the cursor is not. nl_complete_span returns to_copy when no whole message
fits and the caller advances buf_pos by it, so a guest receiving fewer
bytes than one header moves the cursor to a non-header offset. Eight
bytes into a reply sits nlmsg_seq, echoed straight from the guest's own
request. A guest that asks with seq 0xFFFFFFFF and then receives 8 bytes
reads its own value back as an nlmsg_len on the next receive. Widening to
uint64_t removes the wrap; the proved postcondition that the span is
strictly positive is what makes the loop terminate for any header.

The cursor landing off a message boundary is a separate defect and is
recorded in TODO.md. It no longer hangs, but it still parses from
mid-message.

Also here: the two walk structs and their header lengths moved into the
proved header with static assertions tying the byte constants to the
struct sizes, NLMSG_ALIGN and RTA_ALIGN collapsed into the one proved
align-up so the reply builders and the walks cannot round differently,
and the span header is now read by memcpy rather than a typed cast, since
the cursor is not guaranteed to be suitably aligned.

Behavior preservation checked rather than assumed: 107 million cases over
the rtattr guard rewrite (off + rta_len > total became rta_len >
total - off), the uint16 cast site in the attribute builder, and the span
computation, with zero divergence outside the three wrap values above.

Four mutations added to verify-mutants, all caught. A fifth, changing the
wire alignment from 4 to 8, is deliberately not in the table: the static
assertion rejects it at build time, so the harness correctly classified
it INFRA rather than crediting the proof for catching it.
make verify-sigframe proves where a signal frame lands: 15 of 15
obligations. The base must be 16-byte aligned, because the handler runs
with it as SP under AAPCS64, and the frame must sit wholly below the
interrupted SP without the subtraction wrapping. Placed wrong, the
handler runs on a misaligned stack or over memory that was still live.

The header names no frame type. The size arrives as a plain integer, so
no guest ABI struct had to move out of signal.h, which is what the TODO
entry named as the condition for dropping this target rather than
building it.

Scope is placement only. Whether the frame's field offsets match
arch/arm64/kernel/signal.c is the property that actually breaks libc,
nothing here establishes it, and no golden-layout test covers it either.
That gap is recorded as its own item rather than left as an unexamined
assumption.

The contract pins both directions. Every other clause is guarded by a
non-zero result, so a body that always refuses would satisfy them all;
an iff fixes when the function must succeed, which is also what catches
an off-by-one rejection at the floor. The distance bound does something
narrower than it looks: with the alignment it leaves exactly one legal
base, so the frame cannot sit an aligned slot lower than required.

Two related defects fixed here. The static assertion tying the frame size
to LINUX_MINSIGSTKSZ has to account for the up-to-15 bytes align-down
consumes, or a future frame of 5110 would pass it while minimum-size
altstacks failed. And sigaltstack validated ss_size but not that
ss_sp + ss_size wraps: delivery computes the altstack top as that sum, so
a wrapping pair gave a low address unrelated to the stack the guest
described. Rejected with EINVAL now, stricter than Linux, whose
do_sigaltstack checks only the minimum size.

The altstack floor is the one behavior change: placement can now fail
there, where before only absolute underflow was checked. It cannot fire,
and the static assertion is what says so, since sigaltstack rejects under
5120 and a frame is 4688. The margin is real but not large, and it
shrinks whenever the frame grows.

Behavior preservation on the normal-stack path checked rather than
assumed: 1.25 million differential cases over the guard rewrite and the
align-down, including the full uint64 edges, with zero divergence.
@jserv jserv changed the title Formal Add machine-checked proofs for critical arithmetic paths Aug 7, 2026
cubic-dev-ai[bot]

This comment was marked as resolved.

Seven of the eight findings from the PR #277 review were valid. The
eighth is real but predates this branch, so it is recorded rather than
fixed here.

One was a genuine miss of mine. sigframe-math.h claimed the frame's field
layout "is checked by a golden-layout test". No such test exists, and I
had written that correction once already; the edit was an unguarded
string replacement that silently matched nothing after the text had been
reworded, so it never landed. The header now states the gap, which
TODO.md already tracks.

The contract-check plumbing had two holes worth closing:

- gva-math.h reached its checks through assert.h with NDEBUG undefined.
  That enabled every unrelated assert in the including translation unit
  and left NDEBUG cleared for whatever it included next. Calling abort()
  directly cannot be switched off by a caller's -DNDEBUG, needs no
  header, and fails with SIGABRT specifically.
- test-gva-contracts.c accepted any terminating signal as a rejection.
  A mis-wired check would fall through into the arithmetic the
  precondition guards, and gva % granule with granule == 0 is undefined
  behavior that can itself kill the child, which would have been scored
  as the check firing. It now requires SIGABRT, which is why abort() is
  the right primitive above. Verified by neutering the check: six cases
  fail where the old test reported them caught.

check-acsl-coverage.py scanned only the parameter list for a plain char,
so a plain char local or return type in a proved function passed the
gate. It now scans the whole definition with comments and literals
blanked first. A char reached through a typedef still needs a C front
end, and the comment says so instead of implying full coverage.

Two comments were wrong: FUSE_MAX_PAGES is the capability flag that
enables max_pages negotiation, not a page count, and sigframe_base leaves
*base untouched on failure rather than zeroing it.

CI time, which is what the mutation step actually cost:

The step ran over 43 minutes against a 60-minute job timeout, not the
5 minutes the comment claimed. Two compounding causes. --jobs defaulted
to half the cores, and a 3-core runner resolves that to 1, so the set ran
serially where it had been 4-way parallel locally. And a caught mutation
is the expensive case: the prover grinds against goals it cannot
discharge until the per-goal timeout expires, measured at 71s per caught
mutation against 15s to prove the same file unmutated.

Mutation runs now use a 5s per-goal timeout instead of 30s, which is
sound because the baseline control runs at the same value: a timeout too
tight for correct code fails the baseline and reports SETUP FAILED rather
than scoring a mutation as caught. With --jobs set explicitly in CI, the
full set drops from 318s to 149s locally and all 40 mutations are still
caught.
cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 2 commits August 7, 2026 12:29
All five findings were valid. The important one reverses my own change
from the previous commit.

Lowering the per-goal prover timeout for mutation runs is unsound, and
the argument I gave for it only covered one direction. A broken contract
is never refuted; the goal simply becomes unprovable and the prover
grinds until the budget expires, so a caught mutation is reported as a
timeout. Measured: every caught mutation in the table fails with
[Timeout] at both 5s and 30s, never with a definitive verdict. A goal
that is merely hard but still true times out identically, so a shorter
budget silently converts a genuine MISS into a "caught" and hides the
gap this file exists to find. Proving the unmutated file at the short
budget shows the budget suits correct code, not weakly-broken code.
Reverted to the engineering timeout, with the reasoning recorded so the
next person who sees the obvious speedup finds out why it is not taken.

The CI cost is handled by parallelism alone, which is sound: 318s to
248s locally. MUTANT_JOBS now threads through the make target, so CI
calls `make verify-mutants MUTANT_JOBS=4` instead of invoking the script
behind make's back, which also makes the step name true again.

Three smaller ones:

- The brace matcher in the plain-char scan had no guard for a definition
  with no closing brace. It would have run to end of file and blamed one
  function for chars belonging to every function after it. Unterminated
  definitions are now reported as a parse failure instead.
- Two headers pointed readers at TODO.md, which is a per-developer
  working doc and deliberately untracked, so it does not exist from the
  repository's point of view. They now state the gap directly.
- The plain-char check is documented as a tripwire rather than as
  enforcement. It cannot see a char reached through a typedef or a
  macro, which is why the allowlist entries are hand-audited; the
  comment said so about typedefs but claimed more than it delivers.
Second pass on the one finding from the review round I answered by
weakening the claim rather than strengthening the check. The complaint
was that a proved function can hide a plain-char access behind a macro,
so the gate does not enforce the invariant it describes. Rewording the
comment resolved the mismatch; it did not close the hole.

check-char-signedness.py closes it. Each proved source is compiled twice,
once with -fsigned-char and once with -funsigned-char, and the emitted
code compared. A source whose code is identical contains no plain-char
access at all, which the same front end that expands the macros and
resolves the typedefs settles outright. Verified against the case the
regex cannot see: a char reached through a macro in gva-math.h is invisible
to check-acsl-coverage.py and reported by this immediately.

What the comparison does NOT mean is worth stating, because the first
version of this script got it wrong and would have failed the build. A
source that differs is not thereby broken. A correct read casts to
(unsigned char) first, and that cast costs a mask under -fsigned-char and
nothing under -funsigned-char, so correct code differs too. The compiler
cannot separate a safe read from an unsafe one. So the result is a
complete detector of plain-char USE, not of signedness dependence, and a
source that reads any plain char goes on an allowlist carrying the audit
that justifies it.

Today that is 8 sources containing no plain char at all and one,
gdbstub-rsp.c, that takes it off the RSP wire in four functions and casts
at every read. That is exactly the invariant mk/analysis.mk records, now
held by something other than a comment.

The regex stays. It names the offending function, which is what makes a
failure actionable, and the two run together under make verify; neither
replaces the other and each says which half it covers.
cubic-dev-ai[bot]

This comment was marked as resolved.

…diff

Four review findings, all valid, and two of them show the char gate was
measuring the wrong thing twice over.

Comparing whole objects answers the wrong question. A unit like elf.c is
full of ordinary code that reads a plain char quite properly, and judging
the file reported that as though a proved function were at fault. It now
compares per function, by splitting the disassembly on symbol labels, so
the answer is about proved code and the report names the function.

Comparing at -O2 lets the optimizer delete a plain-char read whose result
is unused, so the objects agree while the source Frama-C reasons about
still contains the read. Measured: a dead widening read is missed at -O1
and -O2 and caught at -O0.

Those two together change what the gate means, for the better. At -O0 an
explicit (unsigned char) cast emits the same mask under both settings, so
a correctly written read compares equal and only a genuine dependence
stands out. That is the invariant itself rather than a proxy, which is
why the allowlist is gone: it admits no exception, and a proved function
that depends on plain-char signedness needs the cast, not a waiver. All
31 proved functions pass with nothing waived.

Also: the no-code guard tested object length, which an object with
metadata and no code would pass, so it asks nm for defined symbols; and
CC is now tokenized, so a wrapper or a flag-bearing override such as
CC='cc -DTEST' works instead of arriving as stray arguments.

The brace matcher in check-acsl-coverage.py ran on unpreprocessed source,
so a definition whose #if branches open braces unevenly never balanced
and would have failed make verify on perfectly good code. It now narrows
to the signature rather than erroring, which is the right call now that
the compiler gate covers bodies completely.

Separately, on CI cost. verify-mutants is slow because each caught
mutation grinds unprovable goals to the per-goal timeout, and that
timeout cannot be lowered without making "caught" unsound. Narrowing
-wp-fct to the mutated function does not help either: the cost is the one
timeout, not the goal count, and it trips MIN_GOALS. What does help is
not re-verifying targets a branch never touched, so MUTANT_SINCE scopes
the run to sources that differ from a ref and CI passes the PR base. The
full set still runs on a push to the base, so the guarantee is never
weaker than the branch being merged into. This branch creates almost
every proved source, so it only drops 40 to 34; a later PR touching one
header would run four.
cubic-dev-ai[bot]

This comment was marked as resolved.

The verify job failed on the last commit, and the scoping I added for CI
cost is what broke it:

    fatal: <base>...HEAD: no merge base
    cannot diff against <base>
    make: *** [verify-mutants] Error 2

Three-dot diff asks git for a merge base, which a CI shallow clone does
not have. Two-dot asks what this actually wanted, which files differ
between two trees, and needs no ancestry. Reproduced the exact failure
locally against a commit with no common history, and confirmed two-dot
works where three-dot reports the same fatal.

The deeper mistake was making a speed-up able to fail the gate at all. It
now falls back to running everything when it cannot work out what to
skip, and CI only passes a base once the fetch for it succeeded. An
optimization that cannot determine scope must do all the work; failing,
or skipping silently, both turn it into a correctness problem.

Filtering on the target's source path alone was too narrow in the same
way. verify-rsp reads utils.h, so a change there can flip a verdict while
gdbstub-rsp.c is untouched, and that mutation would have been skipped.
Scoping now uses each target's full proof-input set, which VERIFY_*_SCAN
already records, and any change to the harness or the proof config runs
the whole set regardless.

check-acsl-coverage.py's brace matcher degrades to the signature when
#if branches leave braces unbalanced, and that was only safe if something
else covered the body. Nothing did: check-char-signedness.py was a
separate target, so `make verify-gva` on its own had no body coverage. It
is now part of the shared recipe, so every verify-* target carries it.

That change needed one more thing, which the baseline control caught
immediately: mutation runs override NAME for the log path, so passing
NAME to the signedness check asked about a target that does not exist and
all nine baselines failed. NAME is the log name and TARGET is the
identity; they were one variable doing two jobs.

Also: otool missing now degrades to the error the surrounding code
already reports rather than a traceback, since that binary is
load-bearing for the whole gate.

make check fails locally on test-thread-churn under this session's memory
pressure and passes standalone in 0.8s; all four CI Runtime jobs pass it.
That is the munmap-zeroing item already recorded, not a regression here.
cubic-dev-ai[bot]

This comment was marked as resolved.

Three findings, all valid.

--target on check-char-signedness.py silently emptied the target dict on
a typo and fell through to "no proof targets found in mk/analysis.mk",
which blames the wrong file. It now checks membership first and lists
the valid names.

HARNESS_FILES was missing mk/toolchain.mk, which sets CC, and CC is
exactly what check-char-signedness.py compiles the probe with. A change
there can flip a verdict with no proved source touched at all.

The bigger one: target_inputs() trusted VERIFY_*_SCAN as each target's
complete dependency set. SCAN is hand-maintained, which is exactly the
kind of thing that goes stale silently, a proved header gains an
include, nobody remembers to mirror it into SCAN, and a mutation
touching only the new file gets skipped with no diagnostic. It now runs
`cc -MM` against each proved source and uses the compiler's own include
closure instead. That is not a hypothetical improvement: elf.c and
gdbstub-rsp.c both pull in headers SCAN never listed (debug/log.h,
gdbstub-rsp.h), so the old filter was already narrower than it claimed
to be, not just theoretically capable of becoming so.

Verified: cc -MM on gva-math.h returns exactly itself, matching that it
has no internal includes today; on gdbstub-rsp.c and elf.c it returns
the wider set the SCAN lines were missing. Full mutation set stays 40 of
40 caught, all nine targets still prove.
cubic-dev-ai[bot]

This comment was marked as resolved.

include_closure fell back to {src} whenever cc -MM failed to run or
produced output it could not parse. That is the same silent-narrowing
bug the SCAN-based approach had, just moved one layer down: {src} alone
is indistinguishable from a correctly-scanned header that genuinely has
no includes, so a broken scan and a clean one report the same shape and
the caller has no way to tell them apart.

include_closure and target_inputs now return None on any scan failure,
compile error or malformed -MM output alike, and the caller treats that
exactly like an unresolvable --changed-since ref: run the full mutation
set rather than guess. Verified directly against the function, a broken
compiler now returns None instead of {src}, and target_inputs returns
None for the whole map rather than hiding one broken target inside an
otherwise normal-looking result.

Also: include_closure hardcoded "cc" instead of using the configured
compiler, so it could disagree with what --cc was told to use. It now
takes cc through the same --cc flag and shlex tokenizing that
check-char-signedness.py already established, wired through
`make verify-mutants` via CC.
cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 2 commits August 7, 2026 20:35
include_closure() and compare() both called subprocess.run(cc + [...])
with no guard against cc itself being missing or non-executable.
subprocess.run raises OSError before a CompletedProcess exists in that
case, so the existing "if proc.returncode != 0" checks never execute
and the whole harness crashes with an unhandled traceback instead of
falling back the way every other scan failure here does. Found by agy
review, reproduced directly in Python before and after the fix (crash,
then a clean None / error tuple).

check-char-signedness.py's main() also printed the same "depends on
plain-char signedness" header whenever its failures list was
non-empty, even when every entry was an infra error (bad compiler,
otool missing) rather than a genuine signedness finding -- reproduced
with --cc pointed at a nonexistent binary across all 9 targets, which
correctly exits 1 but wrongly claimed a signedness dependency that was
never checked. Split the single list into char_failures/infra_failures
so the header matches what actually happened.

make verify: 9/9 PROVED. make verify-mutants: 40/40 caught.
make check: 70/71 (test-thread-churn is the known timeout flake under
load; reran it standalone twice, both pass).
Every CI run on this branch pays the full 40-mutation cost: 40-57 min,
against a 5-min outlier where MUTANT_SINCE actually got to scope the
run. Measured across 7 recent runs of this PR's "Prove the gate bites"
step. The reason is structural, not a regression: MUTANT_SINCE forces
the full set whenever the diff touches mk/analysis.mk or
scripts/check-mutants.py (the HARNESS_FILES fail-closed rule), and
every commit in this review-response round touches one or both, since
that is what the reviews are about.

Splits the mutation gate into its own job, `needs: verify` so a broken
baseline proof fails fast without spending nine runners mutating it,
matrixed one leg per proof target (cmsg, elf, fuse, gva, netlink, rsp,
sigframe, sockaddr, stack; fail-fast: false so one target's failure
does not hide the others). Each leg now runs `make verify-mutants
MUTANT_TARGET=<target>`, a new mk/analysis.mk variable that forwards
check-mutants.py's existing --target flag (already used for local
single-target runs, not previously exercised from CI). Wall-clock
drops from the sum of all 40 mutations to roughly the slowest single
target's share, at the cost of 9 parallel runners instead of 1.

--target filters MUTATIONS before --changed-since is evaluated
(scripts/check-mutants.py:672-674), so a shard's HARNESS_FILES
fallback still only re-runs that shard's own mutations, not the full
40 -- confirmed locally: `--target cmsg --changed-since HEAD~1` prints
"harness or proof config changed; running the full set" and lists
exactly cmsg's 4 mutations, not all 40.

Reviewed by Codex and agy before committing, since matrix behavior
can't be fully exercised locally:
- Codex flagged that splitting into per-target job names could drop
  the mutation gate from required status checks if branch protection
  names the old single job. Checked directly: `gh api
  repos/sysprog21/elfuse/branches/main/protection` returns "Branch not
  protected", and rulesets are empty, so nothing was enforced before
  this change either -- not a regression.
- agy flagged the new per-shard log artifacts lacked retention-days,
  unlike 5 of the other 6 upload-artifact steps in this file. Added
  retention-days: 7 to match.
- Both confirmed the opam cache populated by `verify` is reachable by
  the matrix legs (same cache key, needs: verify orders the save
  before the restores) and that MUTANT_SINCE + MUTANT_TARGET compose
  correctly.

YAML validated with a real yaml.safe_load and actionlint; the only
actionlint findings are pre-existing, in the untouched lint job.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name=".github/workflows/main.yml">

<violation number="1" location=".github/workflows/main.yml:330">
P1: Mutation failures can be merged without blocking the PR if branch protection still requires only `Frama-C WP proofs (make verify)`, because the mutation gate is now a separate set of checks. A stable aggregate check that needs all matrix legs, or branch-protection entries for every leg, should be made the required gate.</violation>

<violation number="2" location=".github/workflows/main.yml:418">
P2: A PR that changes only this workflow can report a green mutation gate without running any mutants, including changes that remove a matrix target or alter its invocation. The changed-since scope should force the full mutation set when the workflow changes, such as by treating `.github/workflows/main.yml` as a harness input.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

# The matrix list is VERIFY_<T>_SRC's targets from mk/analysis.mk, hand-kept
# in step: a target missing here silently drops its mutation coverage from
# CI with no error, so add new proof targets to both places.
verify-mutants:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Mutation failures can be merged without blocking the PR if branch protection still requires only Frama-C WP proofs (make verify), because the mutation gate is now a separate set of checks. A stable aggregate check that needs all matrix legs, or branch-protection entries for every leg, should be made the required gate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/main.yml, line 330:

<comment>Mutation failures can be merged without blocking the PR if branch protection still requires only `Frama-C WP proofs (make verify)`, because the mutation gate is now a separate set of checks. A stable aggregate check that needs all matrix legs, or branch-protection entries for every leg, should be made the required gate.</comment>

<file context>
@@ -305,21 +307,104 @@ jobs:
+  # The matrix list is VERIFY_<T>_SRC's targets from mk/analysis.mk, hand-kept
+  # in step: a target missing here silently drops its mutation coverage from
+  # CI with no error, so add new proof targets to both places.
+  verify-mutants:
+    name: Mutation gate (${{ matrix.target }})
+    needs: verify
</file context>

Comment thread scripts/check-char-signedness.py
# optimization.
base="${{ github.event.pull_request.base.sha }}"
if [ -n "$base" ] && git fetch --no-tags --depth=1 origin "$base"; then
make verify-mutants MUTANT_JOBS=4 MUTANT_TARGET=${{ matrix.target }} MUTANT_SINCE="$base"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A PR that changes only this workflow can report a green mutation gate without running any mutants, including changes that remove a matrix target or alter its invocation. The changed-since scope should force the full mutation set when the workflow changes, such as by treating .github/workflows/main.yml as a harness input.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/main.yml, line 418:

<comment>A PR that changes only this workflow can report a green mutation gate without running any mutants, including changes that remove a matrix target or alter its invocation. The changed-since scope should force the full mutation set when the workflow changes, such as by treating `.github/workflows/main.yml` as a harness input.</comment>

<file context>
@@ -330,20 +415,19 @@ jobs:
           base="${{ github.event.pull_request.base.sha }}"
           if [ -n "$base" ] && git fetch --no-tags --depth=1 origin "$base"; then
-            make verify-mutants MUTANT_JOBS=4 MUTANT_SINCE="$base"
+            make verify-mutants MUTANT_JOBS=4 MUTANT_TARGET=${{ matrix.target }} MUTANT_SINCE="$base"
           else
-            make verify-mutants MUTANT_JOBS=4
</file context>

jserv added 4 commits August 7, 2026 21:12
Two issues from cubic review 4883042273, both confirmed before fixing:

check-char-signedness.py's main() returned inside the infra_failures
block, so a target with a genuine plain-char signedness finding never
reached the char_failures block when another target also had an infra
error (bad compiler, otool missing) -- the real finding was reduced to
its bare per-target row with the diagnosis and cast advice silently
dropped. Reproduced by monkeypatching compare() to return a CHAR
finding for cmsg and an INFRA error for elf in one run: only the ERROR
header printed before the fix; both headers print after moving the
return to the end.

check-mutants.py's HARNESS_FILES did not include
.github/workflows/main.yml, even though that file now decides, per CI
matrix leg, which --target name reaches the script and whether
--changed-since runs at all. A PR that only edits the workflow (a
mistyped target, a dropped matrix entry, a MUTANT_TARGET that stops
being forwarded) would verify against whatever proof sources it
happens to touch, which is nothing when the PR is CI-only -- the exact
silent-narrowing failure mode this file exists to close for every
other harness input. Added it to HARNESS_FILES; reproduced with
--changed-since HEAD~1 --target cmsg against the commit that touched
the workflow, which now prints "harness or proof config changed;
running the full set" and lists cmsg's own 4 mutations instead of
silently reporting nothing to re-verify.

The third finding in this review (branch protection dropping the
mutation gate from required checks) was already checked and found
inapplicable when Codex raised the same concern last round: `gh api
repos/sysprog21/elfuse/branches/main/protection` returns "Branch not
protected" and rulesets are empty, re-confirmed just now. Nothing
enforced the old single job either, so the matrix split changes
nothing here.

make verify: 9/9 PROVED. make verify-mutants: 40/40 caught.
make check: 70/71 (test-thread-churn is the known timeout flake under
load; reran it standalone twice, both pass).
linux_errno() already returns the negative Linux errno (its own doc
comment says so, and 202 other call sites in the tree use it as
"return linux_errno();"). This function instead did
"return -linux_errno();", double-negating a failure back into a
positive value.

The one caller that checks the result, sys_mremap, does
"if (err < 0) { ...best-effort recovery...; return err; }" -- with the
double negation, that check was never true on this path's failure, so
hvf_remap_segments_best_effort's recovery was silently skipped and the
function fell through to hv_vm_map against a slab whose backing
restore may have failed.

Found during a project-wide analysis sweep; verified by reading both
the callee and the caller directly before fixing, and confirmed via
`make elfuse` that the one-line fix builds clean.
…rs table-driven

Three related path-handling cleanups from a project-wide analysis sweep:

path_tr_nofollow() (src/syscall/path.h) replaces the same
"(flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW : PATH_TR_NONE"
ternary copy-pasted at 8+ call sites across fs.c, fs-stat.c, and
fs-xattr.c (the last already had it extracted to a local `nofollow`
bool, so the same helper covers both call shapes).

sys_openat_path's no-sysroot fast path called raw openat(AT_FDCWD,
pathp, ...) with the guest path string instead of tx.host_path, even
though path_translate_at had already been called and populated tx
above it. Not exploitable today -- the branch is gated on
!proc_get_sysroot(), and path_translate_at's own resolvers hand back
a relative path untouched when there is no sysroot to redirect into
(verified by reading sysroot_seed_host_path, which returns the path
unchanged whenever !proc_sysroot_snapshot() or the path is relative)
-- but it was a latent trap if this branch's guard conditions ever
change. Now routes through tx.host_path consistently.

proc_virtual_dir_path was an 11-way else-if chain hand-coding
/proc/self/*, /proc/net, and duplicate /proc/<pid>/* forms of the same
paths. Replaced with: a single pid-to-self prefix normalization (so
"/proc/<mypid>/..." is rewritten to "/proc/self/..." once instead of
every table entry needing a numeric-pid twin), a small
{canonical-path} table walked in a loop for the static cases, and one
dynamic branch left for /proc/self/task/<tid> since that needs numeric
parsing rather than a literal match. Traced every branch by hand
against the original to confirm no behavior changed (in particular,
that pid-to-self normalization only feeds the same lookup table the
old code's specific suffix checks fed, so nothing new is now
recognized that the original code rejected).

Verified: make elfuse builds clean; test-proc, test-proc-fidelity, and
test-procfs pass; full make check is 70/71 (test-thread-churn is the
pre-existing timeout flake under load, confirmed by rerunning it
standalone).
core/bootstrap.h and core/sysroot.h each included syscall/internal.h
(492 lines of cross-module lock-ordering declarations and the FD
table) for the sole purpose of getting LINUX_PATH_MAX for buffer
sizing -- confirmed by reading both files in full; neither uses
anything else internal.h declares.

New src/syscall/linux-limits.h holds just that one constant.
internal.h now includes it instead of defining the macro directly, so
all 22 existing internal.h-reaching users keep working unchanged; the
two core headers include the small header directly instead of
dragging in syscall-layer coordination state they never touch.

Verified: touching internal.h forces a near-full rebuild (36
includers), which ran clean; make check is 70/71 (the pre-existing
test-thread-churn flake, not a regression).
jserv added 5 commits August 7, 2026 22:23
proc_slot_alloc doubled capacity on exhaustion but never shrank; the
three lookup helpers (find_by_host_pid, find_by_guest_pid,
find_by_reserved) always scanned proc_table_capacity, so a historical
burst of concurrent children permanently taxed every future wait4/kill
lookup even after all of them had exited and been reaped.

proc_find_free_entry() already scans the whole table once per fork
admission; changed it to stop short-circuiting on the first free slot
so the same pass also learns whether every entry is idle right now.
When it is, and capacity has grown past the initial static buffer,
the grown array is freed and the table resets to the small initial
buffer -- the same reset proc_init() does at startup. This is safe
without new locking: every caller already holds pid_lock, and the
file's own documented invariant ("no pointer into this array survives
unlocking") means nothing can be holding a stale pointer once nothing
in the table is active or reserved.

Not free-running: the scan was already unconditional overhead paid by
posix_spawn and the guest memory IPC transfer that surrounds every
fork, so folding the emptiness check into the same pass costs nothing
extra on the common case (finds a free slot immediately, keeps
scanning trailing entries it already had to touch anyway to answer
"how many active").

Verified with an ad hoc 3-round stress test (not part of the permanent
suite): each round forks 20 children concurrently, forcing table
growth well past the initial capacity of 8, then reaps all of them
before the next round starts, exercising the shrink path three times
in a row with correct exit-code propagation throughout. Also ran
test-fork-exec, test-mt-fork, test-clone3, test-clone-childtid,
test-process-lifecycle, and test-cow-fork directly, plus full make
check (70/71, the pre-existing test-thread-churn timeout flake under
load, not a regression -- confirmed standalone).
fuse_node_ref_hold_locked and fuse_node_ref_drop_locked track how many
outstanding FUSE_LOOKUP references the guest holds per nodeid, via a
linear scan over session->node_refs[FUSE_MAX_NODE_REFS=4096] -- twice
per hold (existing-entry search, then free-slot search) and once per
drop. A large directory walk (ls -R over an sshfs/ntfs-3g/AppImage
mount) degrades toward O(n^2) in outstanding refs, all under
session->lock, serializing every other FUSE op on that session.

Adds an open-addressing hash table (node_ref_hash[8192], linear
probing, EMPTY/TOMBSTONE sentinels) mapping nodeid -> node_refs[]
index, and a free-list stack (node_ref_free[4096]) for O(1) slot
allocation. All access stays under session->lock as before -- this is
a data-structure swap for the same single-threaded-at-a-time
semantics, not a concurrency change.

Two real bugs surfaced while stress-testing before this was trusted,
neither of which the existing test-fuse-alpine suite would have
caught (it doesn't exercise enough distinct nodeids or hold/drop churn
to hit either):

1. The first version's insert had a comment claiming an open slot
   always exists because live entries never exceed FUSE_MAX_NODE_REFS.
   Wrong: tombstones accumulate independently of live count under
   hold/drop churn and can fill the table even at low live occupancy.
   Fixed with fuse_node_ref_hash_rebuild(), a full compaction that
   clears every tombstone by re-inserting the live entries, triggered
   whenever live+tombstone occupancy would reach 3/4 of the table.

2. fuse_node_ref_drop_locked did `memset(&node_refs[idx], 0, ...)`
   before calling fuse_node_ref_hash_remove(session, nodeid) --  but
   hash_remove's probe identifies the right hash slot by comparing
   node_refs[idx].nodeid == nodeid, and the memset already zeroed that
   field, so the comparison silently failed and the hash slot was
   never tombstoned. The slot looked permanently "live" in the hash
   table pointing at freed, reused node_refs[] data. Fixed by
   reordering: hash_remove first, then memset.

Both were caught by a 2-million-operation randomized stress test
(hold/drop/lookup, comparing against a naive linear-scan oracle) run
standalone, not part of the tree -- it reliably aborted within a few
hundred thousand operations before fix 1, and produced silent oracle
mismatches before fix 2. Reran clean across 5 different seeds (10M
total ops, 0 mismatches) after both fixes.

Reviewed by Codex before committing given the two self-caught bugs
warranted a skeptical second pass rather than trusting one clean
stress run. It confirmed the reordering fix and the 3/4 threshold's
termination argument, and found one more real issue: the tombstone
counter wasn't decremented when an insert reused a tombstone slot,
overestimating occupancy (safe direction -- more frequent rebuilds
than strictly needed, not fewer). Fixed and reverified against the
same 5-seed stress run.

make elfuse builds clean; test-fuse-alpine passes; full make check is
82 passed, 0 failed, 2 skipped (of 84) -- test-thread-churn, the
pre-existing timeout flake under load noted in earlier commits this
session, did not trigger this run.
…hole call

sys_msync was dispatched via SC_LOCKED, holding the single global
mmap_lock (which also gates every thread's mmap/brk/munmap/mprotect)
for the entire call -- including a real fsync(2) and an
O(regions^2) diff-and-write pass comparing guest memory against the
backing file. A guest thread calling msync(MS_SYNC) on a large
file-backed shared mapping could stall every other thread's memory
syscalls for however long the disk flush took.

sys_msync now manages mmap_lock itself (dispatched via SC_FORWARD)
and only defers the plain fsync(2) calls until after the lock is
released, on duped fds -- matching the existing sc_sync_impl pattern
in this same file for plain sync(2). Everything that touches guest
memory (the coverage check, the diff-and-write pass in
sync_shared_aliases_range, the cross-region refresh pass in
refresh_shared_region_range) still runs entirely under the lock,
unchanged from before.

That scope came from a real critical bug in the first attempt, caught
by Codex before it was trusted: the initial version snapshotted
per-region host pointers (resolved once via host_ptr_for_gpa under the
lock) alongside duped fds, then ran the diff/refresh passes against
that snapshot AFTER releasing the lock. A duped fd keeps the
underlying FILE alive across a concurrent munmap of the original
mapping, but does nothing to keep the GUEST MEMORY at that host
address attributed to the same region -- a concurrent munmap+mmap
could hand the same host VA range to something else entirely while
the unlocked diff pass was still reading it, silently writing the new
mapping's bytes into the old file, or the old file's bytes into the
new mapping. My own concurrency stress test (unrelated regions
churning while msync ran) didn't exercise this, since it never
targeted the SAME region msync was operating on; Codex's review caught
it by reasoning through the ownership argument rather than needing a
test to reproduce it.

Two more issues surfaced across two further review rounds on the
corrected version, both fixed:
- The post-unlock fsync loop was skipping fsync entirely for
  already-queued regions if a LATER region's diff/refresh failed and
  broke the loop, silently downgrading durability the original
  inline-fsync-per-region code guaranteed for the earlier regions.
  Fixed: every queued fd is fsynced unconditionally; only the first
  failure (diff/refresh or fsync) becomes the reported error.
- The fsync fd-scratch array was allocated unconditionally, so a plain
  msync() or msync(MS_ASYNC) call -- which never fsyncs -- could newly
  fail with ENOMEM over an allocation it did not need. Fixed:
  allocated only when MS_SYNC is set.

Also fixed a bug caught before any review, while writing the first
version: the scratch array was sized from g->nregions read BEFORE
mmap_lock was acquired, letting a concurrent mapping grow nregions
before the lock was actually taken and undersizing the array relative
to the locked loop's own bound. Fixed by sizing to the fixed
GUEST_MAX_REGIONS capacity instead.

Verified: full make check is 82 passed, 0 failed, 2 skipped (of 84).
A targeted concurrency stress test (not part of the tree) -- one
thread looping msync(MS_SYNC) on a real file-backed MAP_SHARED region
while another does 4000 rounds of mmap/mprotect/munmap on unrelated
anonymous regions -- ran clean across 3 separate runs after this
version, verifying final content both via the mapping and via a fresh
pread bypassing it.
abi.h (819 lines) mixed two concerns that change at different rates
and are needed by different consumers: the SYS_* Linux syscall-number
dispatch table, and everything else -- errno values, open/mmap/AT_*
flag constants, wire-format structs (linux_stat_t, linux_statx_t,
etc.), and the FD table (fd_entry_t, FD_TABLE_SIZE, FD_* type tags).
36 files included it; a change to either half forced a rebuild of
every one of them regardless of which half a given file actually
used.

Checked before doing this (not assumed): grepped every includer for
literal SYS_* token usage, and separately confirmed which files expand
the generated SYSCALL_TABLE_ENTRIES(_) X-macro from build/dispatch.h
(that macro's body itself references SYS_* tokens as arguments, so a
file expanding it needs SYS_* even without spelling out any SYS_*
identifier itself). Result: only syscall.c (the dispatch switch) and
debug/syscall-hist.c (re-expands the same macro for a name table) ever
touch SYS_*. Every other includer wanted the other half only.

src/syscall/linux-wire.h now holds that other half verbatim (moved,
not rewritten). abi.h keeps just the SYS_* defines and the
syscall_init/syscall_dispatch entry-point declarations. Every file
that included abi.h for the wire half now includes linux-wire.h
instead; syscall.c includes both; syscall-hist.c is unchanged (SYS_*
only, confirmed it uses zero LINUX_*/linux_*_t/FD_* symbols).
core/bootstrap.c, runtime/fork-state.c, and syscall/proc.c each call
syscall_init()/syscall_dispatch() directly without otherwise needing
abi.h's dispatch numbers, so they now include both headers too.

A prior investigation pass into also splitting internal.h (locks/FD
table vs. misc cross-module helpers) found that split isn't worth
doing as scoped: the "misc helpers" are built directly on FD-table
primitives, so a 2-way cut would still force nearly every one of
internal.h's 33 includers to pull in both halves anyway, changing
nothing about anyone's real rebuild scope. Left internal.h alone;
if this is revisited it needs a genuinely different 3-way cut, scoped
on its own.

A header-only change like this fails at compile time, not at runtime,
if an include is missing -- so the safety net here was `make clean &&
make elfuse` and a full `make check` (which alone compiles ~80
additional test binaries against the same headers), not runtime
testing. Both are clean: make elfuse and make check build with zero
compiler warnings; make check is 82 passed, 0 failed, 2 skipped (of
84), same as every other clean run this session. clang-format and
cppcheck pass on every touched file (thread.h needed a
manual reformat after the abi.h -> linux-wire.h rename shifted an
aligned trailing comment column).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 44 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/syscall/mem.c">

<violation number="1" location="src/syscall/mem.c:1394">
P1: A failed slab restore is now reported as success, so overlay teardown can continue with the host VA still backed by the file and later guest writes can corrupt that file. Returning the negative Linux errno preserves the helper's documented contract and lets callers abort cleanup.</violation>
</file>

<file name="src/syscall/linux-limits.h">

<violation number="1" location="src/syscall/linux-limits.h:16">
P3: The comment claims core/guest.c uses a literal 4096, but guest.c sizes pages via the PAGE_SIZE/GUEST_PAGE_SIZE macros and has no literal 4096. Update the comment to reference only core/stack.c so it does not mislead a maintainer hunting for the literal.</violation>
</file>

<file name="src/syscall/abi.h">

<violation number="1" location="src/syscall/abi.h:14">
P3: The new header comment claims syscall.c and syscall-hist.c "are the only files needing both, and each already includes linux-wire.h separately for that half," but that is factually inaccurate: syscall-hist.c does not include linux-wire.h at all (it uses zero wire symbols), and proc.c, fork-state.c, and bootstrap.c also include abi.h (for the syscall_dispatch/syscall_init entry-point declarations) while needing the wire half in addition. Please correct the comment so it doesn't mislead a future refactor about which files depend on which half.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/syscall/mem.c
}
if (p == MAP_FAILED)
return -linux_errno();
return linux_errno();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A failed slab restore is now reported as success, so overlay teardown can continue with the host VA still backed by the file and later guest writes can corrupt that file. Returning the negative Linux errno preserves the helper's documented contract and lets callers abort cleanup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/mem.c, line 1394:

<comment>A failed slab restore is now reported as success, so overlay teardown can continue with the host VA still backed by the file and later guest writes can corrupt that file. Returning the negative Linux errno preserves the helper's documented contract and lets callers abort cleanup.</comment>

<file context>
@@ -1383,7 +1391,7 @@ static int hvf_restore_slab_backing(guest_t *g, uint64_t ipa, uint64_t len)
     }
     if (p == MAP_FAILED)
-        return -linux_errno();
+        return linux_errno();
     return 0;
 }
</file context>

Comment thread src/syscall/proc.c Outdated
Comment thread src/syscall/mem.c Outdated
#pragma once

/* Linux PATH_MAX (4096): used for path buffer sizing in syscall handlers.
* Literal 4096 in core/guest.c and core/stack.c means actual page size, not

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The comment claims core/guest.c uses a literal 4096, but guest.c sizes pages via the PAGE_SIZE/GUEST_PAGE_SIZE macros and has no literal 4096. Update the comment to reference only core/stack.c so it does not mislead a maintainer hunting for the literal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/linux-limits.h, line 16:

<comment>The comment claims core/guest.c uses a literal 4096, but guest.c sizes pages via the PAGE_SIZE/GUEST_PAGE_SIZE macros and has no literal 4096. Update the comment to reference only core/stack.c so it does not mislead a maintainer hunting for the literal.</comment>

<file context>
@@ -0,0 +1,19 @@
+#pragma once
+
+/* Linux PATH_MAX (4096): used for path buffer sizing in syscall handlers.
+ * Literal 4096 in core/guest.c and core/stack.c means actual page size, not
+ * this.
+ */
</file context>
Suggested change
* Literal 4096 in core/guest.c and core/stack.c means actual page size, not
* Literal 4096 in core/stack.c (AT_PAGESZ) means actual page size, not

Comment thread src/syscall/abi.h
* syscall implementation file needs that half and never touches SYS_* at
* all, so splitting them keeps a change to one from forcing a rebuild of
* files that only wanted the other. syscall.c and syscall-hist.c are the
* only files needing both, and each already includes linux-wire.h

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new header comment claims syscall.c and syscall-hist.c "are the only files needing both, and each already includes linux-wire.h separately for that half," but that is factually inaccurate: syscall-hist.c does not include linux-wire.h at all (it uses zero wire symbols), and proc.c, fork-state.c, and bootstrap.c also include abi.h (for the syscall_dispatch/syscall_init entry-point declarations) while needing the wire half in addition. Please correct the comment so it doesn't mislead a future refactor about which files depend on which half.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/abi.h, line 14:

<comment>The new header comment claims syscall.c and syscall-hist.c "are the only files needing both, and each already includes linux-wire.h separately for that half," but that is factually inaccurate: syscall-hist.c does not include linux-wire.h at all (it uses zero wire symbols), and proc.c, fork-state.c, and bootstrap.c also include abi.h (for the syscall_dispatch/syscall_init entry-point declarations) while needing the wire half in addition. Please correct the comment so it doesn't mislead a future refactor about which files depend on which half.</comment>

<file context>
@@ -4,15 +4,22 @@
+ * syscall implementation file needs that half and never touches SYS_* at
+ * all, so splitting them keeps a change to one from forcing a rebuild of
+ * files that only wanted the other. syscall.c and syscall-hist.c are the
+ * only files needing both, and each already includes linux-wire.h
+ * separately for that half.
  */
</file context>

jserv added 3 commits August 7, 2026 23:47
fork_ipc_recv_fd_table (src/runtime/fork-state.c): the loop installing
received fds into fd_table skipped an entry whose guest_fd failed
RANGE_CHECK via a bare `continue`, never closing the already-received
host_fds[i]. Every other rejection path in the same loop (synthetic
type drop, dup failure, fdopendir failure) closes the corresponding
host fd first; this was the one path that didn't, leaking an inherited
host fd for the life of the child process on a corrupted or malformed
fork IPC payload.

spawn_capture_stdout (src/core/sysroot.c): a read(2) failure other
than EINTR broke out of the capture loop but the function still fell
through to waitpid and returned 0 (success), silently treating a
genuine read error the same as clean EOF. The function's own contract
is <0 on error; a real I/O error now reports as one instead of
returning success with a possibly-truncated buffer.

Found by a fresh maintainability sweep of areas the prior analysis
pass this session hadn't covered (src/runtime/, src/core/rosetta.c
and sysroot.c, src/syscall/net*.c, src/debug/); independently verified
by reading both functions directly before fixing, not taken on the
sweep's word.

make elfuse builds clean; make check is 70/71 (test-thread-churn, the
pre-existing timeout flake under load, not a regression); ran
test-mt-fork (3x) and test-fork-lowbase directly to exercise
fork_ipc_recv_fd_table, and test-fuse-alpine (which drives
sysroot.c's mount/case-sensitivity probing path) -- all pass.
…arden msync's deferred fsync

An adversarial self-review of this session's own recent commits (run
after /sc:analyze's fixes were already committed, specifically to
catch anything that survived into the final diffs) found a HIGH
severity memory-safety regression introduced by the proc_table
shrink-on-idle change:

proc_deactivate_slot_if_matches(int slot, pid_t host_pid) indexed
proc_table[slot] with no bounds check. Its callers capture a raw
table index while holding pid_lock, release the lock, call a wait4()
that can genuinely block (proc_autoreap_exited_children's wait4(...,
0, ...) has no WNOHANG), then call this function with the stale
index on return. If every entry in the table happens to go idle
during that unlocked window (another reaper finishes this exact
slot, or the child was already reaped concurrently and the ECHILD
path fires) and a concurrent fork triggers the shrink,
proc_find_free_entry frees proc_table and repoints it at the small
8-entry proc_table_initial static buffer -- making the stale index
(which could be well above 8 for a table that had grown from a
historical burst) out of bounds for the array proc_table now points
to. Reading it is UB into whatever static storage follows; if the
garbage there happens to satisfy the host_pid re-check, it's an
out-of-bounds write of false to .active.

Fixed by dropping the slot parameter entirely: the function now
looks up the entry by host_pid via the existing proc_find_host_entry
helper (already bounded to [0, proc_table_capacity) and already
checking .active), which is immune to the array being resized
regardless of how long the caller held the raw index. Updated all 8
call sites and removed the two now-dead `int slot = (int) i;` locals.

A second, independent review round (after this fix, given the
severity, before trusting it) found a sibling of the exact same
pattern in sys_waitid that the first pass missed entirely: its
else-arm does the identical unlock/wait4(WNOHANG)/relock dance, then
used proc_table[i] directly at two points after the relock. Fixed
the same way, via proc_find_host_entry(host_pid) and
proc_find_host_entry(ret) instead of the stale i. A third review
pass specifically grepped the file for every remaining
"unlock pid_lock ... later index proc_table[i]" pattern and found no
further instance (proc_deferred_reap_poll's post-relock proc_table[i]
use already re-validates "i < proc_table_capacity" first, which is
the correct defensive pattern this fix brings the other two call
sites up to).

Also from the same shrink logic: proc_find_free_entry's any_live
check only tested .active and .reserved, missing .host_reap_pending
-- an entry can have active=false (already consumed by wait4) while
still needing a deferred host wait4(WNOHANG) reap via
proc_deferred_reap_poll, which scans by host_reap_pending alone.
Shrinking past such an entry would strand a real host zombie process
until elfuse itself exits. Fixed by adding host_reap_pending to the
condition.

Smaller findings from the same review rounds, fixed alongside:
- sys_msync's deferred-fsync fds were opened via plain dup(), not
  O_CLOEXEC, so a concurrent execve on another vCPU thread in the
  dup-to-close window could inherit the backing file into the new
  image. Switched to fcntl(fd, F_DUPFD_CLOEXEC, 0).
- dup/fcntl failure was reported as the syscall's own error (an
  -EMFILE/-ENFILE msync(2) never defines), even though mmap_lock was
  still held and the region still valid at that point. Added a
  fallback to a direct, non-deferred fsync() on dup failure, matching
  what the pre-optimization code did for that region.
- Multiple regions sharing one backing file each got their own dup +
  fsync. Added a dedup check via the existing same_backing_file()
  helper so one file gets fsynced once regardless of how many regions
  in range back onto it.
- The fsync-fds scratch array was sized to the fixed GUEST_MAX_REGIONS
  (4096) and allocated before mmap_lock, to sidestep a pre-lock
  g->nregions read racing a concurrent grow. Moved the allocation to
  after the lock is held and the coverage check passes, sized to the
  now-stable g->nregions -- smaller allocation on the common case,
  still correct.
- proc.c used ~80 LINUX_*/linux_*_t tokens while only getting them
  transitively through internal.h's own include of linux-wire.h.
  Added the direct include.

This is the fourth review pass across this session's proc_table/msync
work (first pass when each was originally written, a Codex round on
msync specifically that caught an earlier critical bug, this
adversarial pass, and a confirmation pass on the sys_waitid fix) --
each one found something the previous had missed, which is the
concrete reason this file keeps citing "an independent review caught
this" rather than trusting a single pass on lock-adjacent code.

make elfuse and make check build with zero compiler warnings;
clang-format passes; make check is 82 passed, 0 failed, 2 skipped (of
84), the same clean baseline as every other run this session.
test-process-lifecycle (20/20, zombie/orphan/subreaper/wait
scenarios) and two ad hoc stress tests not in the tree -- a
fork/reap-all/repeat proc_table grow-shrink cycle, and a
waitid(P_ALL, WNOHANG) busy-poll specifically built to maximize
overlap with concurrent table shrink/regrow -- all pass clean across
multiple runs.
Two findings from a high-effort code-review workflow pass over this
branch's diff:

Six comments across gva-math.h, cmsg-math.h, fuse-math.h, and
test-gva-contracts.c wrapped make targets in backticks (`make
verify-gva`, `make check-contracts`, etc.), violating this project's
documented rule: source comments are ASCII only, no inline backticks,
code/path/symbol references written as plain text. Stripped the
backticks; the target names read fine without them.

.github/workflows/main.yml's verify-mutants job hand-lists the same
nine proof target names mk/analysis.mk's VERIFY_<T>_SRC entries
define, one per matrix shard. The job's own comment already admitted
the failure mode: a target present in one list but not the other
silently drops that target's mutation coverage from CI, with no error
-- the run still reports green, having simply never mutated it. Ten
targets exist today (VERIFY_ELF_SRC, GVA, RSP, CMSG, FUSE, STACK,
SOCKADDR, NETLINK, SIGFRAME -- nine, matching the matrix -- plus the
non-target VERIFY_UTILS_FCTS, correctly excluded since it has no
_SRC), but nothing checked the two lists actually agreed.

Added scripts/check-mutant-matrix-sync.py: parses mk/analysis.mk's
VERIFY_<T>_SRC entries and main.yml's verify-mutants matrix.target
list (regex, not PyYAML, matching every other check script in this
tree that reads these two files as text), diffs the two sets, and
fails with the specific target name(s) on either side of a mismatch.
Wired into the existing "Lint (Linux)" job as a new step alongside the
structurally identical "Syscall dispatch table consistency" check,
which validates a different generated-vs-hand-written pair the same
way.

Verified the script both ways before wiring it in: confirmed a clean
pass against the current, in-sync state, then temporarily deleted one
matrix entry (stack) to confirm the script reports exactly that
target name and exits non-zero, before restoring the file.

make check is 82 passed, 0 failed, 2 skipped (of 84), the same clean
baseline as every other run this session. actionlint and a real
yaml.safe_load pass on the modified workflow file; black and a syntax
check pass on the new script.
cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai's PR review flagged a real gap in the guest_pid-agnostic
fix from the previous round: proc_deactivate_slot_if_matches and
sys_waitid's preservation/consumption paths looked up the process
table entry by host_pid alone after re-acquiring pid_lock following an
unlocked wait4. host_pid is a host OS pid number, and the OS can reuse
it the instant wait4 reaps the original child -- if a second guest
fork gets admitted on another thread during that same unlocked window
and happens to land the just-freed host_pid, the lookup can match that
unrelated, brand new child instead of correctly recognizing "the
original entry is already gone."

Fixed by pairing every post-unlock host_pid lookup with a guest_pid
captured from the same original entry before the unlock, and requiring
both to match:

- proc_deactivate_slot_if_matches gained an expect_guest_pid parameter;
  updated at all 8 call sites, each of which already had the matching
  guest_pid in scope under a different local name (guest_pid, gpid, or
  entry_gpid).
- sys_wait4's targeted-pid polling loop and sys_waitid's reap-
  preservation/WNOWAIT-consumption blocks: the three remaining direct
  proc_find_host_entry(...) calls after an unlock gained the same
  `&& entry->guest_pid == X` condition, using guest_pid/entry_gpid
  already captured in scope.

A second review round (given the sensitivity of this code, now on its
third external pass this session) found one more sibling:
proc_deferred_reap_poll clears host_reap_pending after its own
unlock/wait4(WNOHANG)/relock using an index-plus-host_pid check that
was already bounds-guarded (not the earlier OOB class) but still
vulnerable to the same host_pid-reuse wrong-entry match, which could
clear an unrelated child's pending-reap flag and strand the real
zombie. Fixed the same way: capture guest_pid before the unlock,
require it on relock alongside the existing capacity and host_pid
checks.

Checked and confirmed NOT needing this fix: proc_mark_child_exited
already re-validates guest_pid after its own unlock/relock (pre-
existing correct code), and proc_host_to_guest_pid is a single atomic
lock/lookup/unlock with no intervening unlock window for reuse to
occur in.

Also from the same review: scripts/check-mutant-matrix-sync.py
converted the parsed matrix.target list straight to a set, so two
identical entries would silently compare as in-sync with
mk/analysis.mk's one entry, hiding a hand-edit that doubles that
target's CI job. Fixed by collecting into a list first, checking for
duplicates, and only converting to a set once none are found;
reproduced by temporarily duplicating one matrix entry and confirming
the script reports exactly that duplicate and exits non-zero, before
reverting.

This is the fifth review pass across this session's proc_table/msync
work; each of the last three found something the previous had missed
(a stale-index OOB, a sys_waitid sibling of it, and now this
host_pid-reuse class across four call sites). Two independent
confirmation passes after this round's fixes found no further gap.

make elfuse builds clean with zero warnings; make check is 82 passed,
0 failed, 2 skipped (of 84), the same clean baseline as every run this
session. Re-ran the fork/reap grow-shrink stress test and the
waitid(P_ALL) busy-poll stress test from the previous round against
this version -- both clean across multiple runs.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/syscall/proc.c">

<violation number="1" location="src/syscall/proc.c:2012">
P1: Host-PID reuse can leave the original child active and make a later wait report the new child under the old guest PID. Because this condition checks only the first host-PID match, it should search for the `(host_pid, guest_pid)` pair in one scan, including the duplicated autoreap and `waitid` paths.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/syscall/proc.c
guest_pid = proc_table[slot].guest_pid;
proc_table[slot].active = false;
proc_entry_t *entry = proc_find_host_entry(host_pid);
if (entry && entry->guest_pid == expect_guest_pid) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Host-PID reuse can leave the original child active and make a later wait report the new child under the old guest PID. Because this condition checks only the first host-PID match, it should search for the (host_pid, guest_pid) pair in one scan, including the duplicated autoreap and waitid paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/proc.c, line 2012:

<comment>Host-PID reuse can leave the original child active and make a later wait report the new child under the old guest PID. Because this condition checks only the first host-PID match, it should search for the `(host_pid, guest_pid)` pair in one scan, including the duplicated autoreap and `waitid` paths.</comment>

<file context>
@@ -1983,22 +1983,33 @@ int write_rusage_to_guest(guest_t *g, uint64_t gva, const struct rusage *ru)
     pthread_mutex_lock(&pid_lock);
     proc_entry_t *entry = proc_find_host_entry(host_pid);
-    if (entry) {
+    if (entry && entry->guest_pid == expect_guest_pid) {
         guest_pid = entry->guest_pid;
         entry->active = false;
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant