Skip to content

Screen concurrent C for race candidates - #33

Merged
jserv merged 13 commits into
mainfrom
concurrency
Sep 21, 2026
Merged

jserv merged 13 commits into
mainfrom
concurrency

Conversation

@jserv

@jserv jserv commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Adds analyze_concurrency, a Level-0 screen over the loaded project's C sources that reports concurrent events, lexical lock order, and pairs of conflicting accesses it cannot order. Everything it claims is syntax, so it reports candidates and never verdicts: there is deliberately no status meaning "not a race", because the strongest thing a lexical lockset supports is that two accesses name one lock, which is evidence rather than protection. The series also carries the supporting changes that came out of building it, each revertible on its own: the verdict predicates move out of analysis.rs (6,181 lines to 5,660), the stdio suite's thread count is pinned where it actually runs, dead_code is denied rather than warned, and a Frama-C that dies mid-session now reports its pid, liveness and log tails instead of only "connection closed".

Verified with scripts/run-gates.sh at the tip: 18 of 18 gates, RUNNER_EXIT=0, including 648 unit tests, 157 stdio tests at the pinned four threads, and the tutorial corpus at 28/28 under -wp-cache none, so those proofs are computed rather than replayed. Every commit builds under cargo check --all-targets, checked by checking each one out, so the series bisects. Three of the fixes were mutation-tested rather than trusted: reverting the trylock handling, the lockset intersection on block exit, and reading threads_detected off spawn tokens each makes its own regression fail, and the file was restored byte-identical afterwards.

Deliberately left out. docs/design/concurrency-ast/ specifies re-founding this tool on the Frama-C AST, which answers by construction what the scanner answers by heuristic, but that is a design only and none of it is implemented here. Two measured cleanups are also deferred to it: interning the strings an Event repeats, and memoizing thread attribution per function. One maintenance hazard is recorded and not fixed: the "a transport went" predicate is spelled twice in server.rs, once negated.


Summary by cubic

Adds analyze_concurrency, a Level-0 screen over the loaded project's C sources that reports concurrent events, lexical lock order, and pairs of conflicting accesses it cannot order. Everything it claims is syntax, so it reports candidates and never verdicts: there is deliberately no status meaning "not a race", because the strongest thing a lexical lockset supports is that two accesses name one lock, which is evidence rather than protection.

Screening behavior

  • Comments and literals are blanked with columns kept, so actions on a line order by position; accesses are per token occurrence, and braces are positioned actions in the same stream, so } else { falls out rather than being special-cased.
  • Leaving a block intersects locksets rather than restoring the enclosing one, so a lock released inside a block stays released; thread attribution walks a call graph from each entry.
  • Whether the program is concurrent is read off pthread_create tokens rather than resolved entries, since a wrapped argument list resolves to nothing.
  • The scan checks its own deadline in both the emission and pairing phase; an early stop is reported and every count it emits is a floor.
  • Three shapes previously screened raceless now name their regression: a call inside an initializer, a loop body beside its head, and a non-regular input file that consumed the whole budget while reading.

Supporting changes

  • Verdict predicates move out of analysis.rs into verdicts.rs; the stdio suite's thread count is pinned to four, with a guard that the workflow and runner agree; dead_code is denied rather than warned.
  • A Frama-C that dies mid-session now reports its pid, liveness, and log tails instead of only "connection closed", and a reload that moved its sources no longer announces a lost process.
  • The concurrency limits' defaults and clamps are documented, and the stdio pin guard reads across wrapped commands.

Verified at the tip: 18 of 18 gates pass, including 648 unit tests, 157 stdio tests, and the tutorial corpus at 28/28 under -wp-cache none, so those proofs are computed rather than replayed.

Written for commit c585f53. Summary will update on new commits.

Review in cubic

PARSE_PROBE_BUDGET was inserted between the first and second lines of
EXTERNAL_COMMAND_BUDGET's doc comment, so the sentence about an external
command that does real work described the parse probe instead, and the
constant it belonged to was left with a dangling fragment.

Both now carry their own text, and PARSE_PROBE_BUDGET uses the Duration
alias the rest of the module does.
analysis.rs had reached 6,181 lines, past the 5,664 that motivated the
checkgaps extraction, and its tool_router impl block pins the tool
methods in one place, so the free functions above it are the only part
that can move.

verdicts.rs takes the band that reads a property or a goal and answers
one question about what its recorded status means. That is distinct from
status.rs next door, which normalizes how a row spells a verdict: this
module reads the spelling status.rs produces. The summarizers that shape
the check payload stayed behind, so the new module depends on nothing
above it and no constant had to be published to feed it.

analysis.rs is 5,660 lines. Nine items are pub(crate) because callers in
analysis.rs and checkgaps.rs reach them; the rest stayed private.
The transport owns a socket and not a child, so it can only say
"connection closed": it cannot tell an out-of-memory kill from a kernel
fatal from a prover that took the process down with it. A mid-session
death reached the log as an unexplained disconnect, and the one flake
this suite has seen could only be called an infrastructure symptom.

The respawn path has the session state, so it reports the pid, whether
the process is still there, and the tail of both logs, the way the spawn
path already does through startup_failure_tail. Liveness is read with
kill(pid, 0) rather than waited for, because the child is owned
elsewhere.
The pin was measured and written down on 2026-09-04 and applied in
neither the workflow nor the runner, while the comment above each
command argued for the default it had rejected, citing a measurement
taken when the suite held 89 tests. It holds 157 now. Seventeen days of
runs took the risk the pin exists to remove.

Both commands pass --test-threads=4 and both comments say why. A guard
reads the count out of each file and fails when they disagree or when
either drops the flag, because the repository's answer to a fact that
must agree in two places is a test, not a third document.
The lint fired the day a guard was separated from its test attribute and
became an ordinary function: the suite went on passing, the test count
went up, and the only signal was a warning nothing fails on.

As a deny it covers every orphan shape, an attribute that moved, a
helper whose last caller went, a test renamed out of a path module. What
it cannot cover is a doc comment that attaches to the wrong item,
because both items still exist, and that stays a review question.
analyze_concurrency reads the loaded project's sources as text and
reports concurrent events, lexical lock order, and pairs of conflicting
accesses it cannot order. Level 0: every claim is syntax, and there is
no status meaning "not a race", because the strongest thing a lexical
lockset supports is that two accesses name one lock, which is evidence
rather than protection.

The defects this went through were one cause, not twenty. The unit of C
is a statement and the unit of a line scanner is a line, so comments and
literals are blanked before anything reads the text, with columns kept
so the actions on a line still order by position; accesses are per token
occurrence rather than per line; braces are positioned actions in the
same ordered stream as locks, which is what makes "} else {" fall out
rather than be special-cased; and leaving a block intersects locksets
rather than restoring the enclosing one, so a lock released inside a
block stays released.

Thread attribution walks a call graph from each entry, so a global
touched in a helper belongs to the threads that reach it. Whether the
program is concurrent at all is read off pthread_create tokens rather
than resolved entries, because an argument list that wraps resolves to
nothing and calling that program single threaded is the same false
verdict in a new place.

The scan carries an absolute deadline it checks itself, in both the
emission and the pairing phase, because a blocking task cannot be
cancelled and the timeout around it bounds only the caller's wait. A
scan that stops early says so, and every count it reports is a floor.

Fifty-four tests pin this, each named for the wrong answer it refuses.
The screener re-derives from text what Frama-C has already computed, and
its default mode is the one where the AST is parsed and resident. The
research report measures what the plug-in serializes today, and names
the five things it does not: CFG edges, a resolved thread entry, a
resolved mutex, loop ancestry, and the access base, which is the key the
pairing groups zones by.

The architecture document specifies one whole-program request and a
must-lockset computed in Rust as a greatest fixpoint, and it refuses to
inherit the keep-list: every defect found in this module lives in code
the move would otherwise carry across untouched.
cubic-dev-ai[bot]

This comment was marked as resolved.

All three reported a program with a race in it as clean, which is the
answer this pass exists not to give, and all three came out of one
review.

An initializer may hold a call. is_declaration tested the whole
statement for a parenthesis, to keep prototypes out of the global set,
so "int limit = SEC(5);" declared nothing and every access to limit was
invisible. The test now reads the declarator.

A loop may carry its body beside its head. The in-loop mark was hung on
the following line alone, so "for (...) pthread_create(...);" spawned a
pool the pass called a thread that runs once, and four threads writing
one global produced no candidate.

The budget did not cover reading. It started at the emission pass, so a
caller naming a fifo or a character device blocked in read_to_string
forever, and a blocking task cannot be cancelled. Reading is now inside
the deadline and refuses anything that is not a regular file, which is
reported rather than waited on.
report_lost_process read the session's poisoned flag as well as the
transport's. A reload whose sources moved underneath it sets the first
with the connection intact, and so does a reload that returned an error,
so an ordinary failure announced a lost Frama-C, reported a live pid,
and attached log tails that explain nothing.

Only the transport's own flag means the stream died, so only that is
read.
max_events and max_candidates are clamped to 100,000 and 20,000, and
default to 10,000 and 2,000, none of which the parameter documentation
said. A caller reading the schema sees an unbounded integer, passes
200,000, and silently gets the cap.

The playbook listed three reasons an enumeration can be incomplete and
there are four: the scan's own time budget also stops it, mid-file or
mid-pairing, and scan_complete reports that one on its own.
KEYWORDS holds 25 entries and the research report called it a 24-word
list, twice. The getContractFrontier registration is at line 148, not
158, which is inside getContractContext. The architecture document
promised five resolutions above a table of six, the sixth being the
entry sid the fixpoint starts from.

A document arguing that counts must be measured rather than asserted is
the worst place to assert one.
The comment claimed the deny covers every orphan shape. It covers a
private item nothing reaches. A pub item in this library is never
reported, because the unit tests link the crate from outside and
everything they reach is published, and a doc comment attached to the
wrong item is invisible to it either way.
The guard matched one line at a time, so a command rewrapped over two,
by a trailing backslash or inside a YAML block, put the flag on a line
that no longer named the suite. The count fell to one and a gate that
promises to refuse only a disagreement went red over layout.

Continuations are joined first and the flag is looked for across the
lines a command spans. Measured against a rewrapped workflow, which now
reads 4, and against a drifted one, which still fails.
@jserv
jserv merged commit ae2f0d9 into main Sep 21, 2026
9 checks passed
@jserv
jserv deleted the concurrency branch September 21, 2026 02:47
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