Skip to content

test(stella-cli): close two unguarded races in the stop-escalation probe (#1721) - #1861

Open
macanderson wants to merge 3 commits into
mainfrom
fix/1721-stop-probe-races
Open

test(stella-cli): close two unguarded races in the stop-escalation probe (#1721)#1861
macanderson wants to merge 3 commits into
mainfrom
fix/1721-stop-probe-races

Conversation

@macanderson

@macanderson macanderson commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Which wait expired

Neither. There was no bound in either place — both of this test's waits on asynchronous state were missing entirely. The issue rightly warns against widening a budget blindly; no budget was widened, and STOP_GRACE is untouched.

1. The precondition — the one that explains the reported symptom

spawn returns once the parent has forked. The liveness lock is taken by the child, after its exec. So there is a window in which the run is registered and holds no lock — and stop reads exactly that lock to decide whether there is anything to stop:

if lock_is_held(&sidecar) != Some(true) {
    mark_stopped(registry, &record.id);
    println!("{} {} was already finished", "▸".dimmed(), record.id);
    return Ok(());
}

Entering that branch returns in microseconds without signalling anything, and the very next assertion — took >= STOP_GRACE — then fails against a run that was never asked to stop. Under ~1400 concurrent tests, several of which spawn their own children and signal process groups, child startup is precisely what slips.

The test now waits for the child to hold its lock before calling stop. That is the precondition the assertion always assumed and never established.

2. The observation

stop returns as soon as it has issued the SIGKILL:

signal_group(supervisor.pgid, SIGKILL);
mark_stopped(registry, &record.id);
println!("{} killed {}", "✓".green(), record.id);
Ok(())

— unlike Supervisor::interrupt_and_drain, which reaps with let _ = self.child.wait(); before returning. The kernel still has to schedule the target, terminate it, and release its flock, so "the lock is free" is not true at the instant stop returns and was never promised to be.

That assertion had no wait at all, making it the only lock-released-by-a-dying-process check in the file without an eventually around it — its two siblings (line 166 and the stopping_a_finished_run case) both have one, with the same 10s budget now used here.

What was and was not reproduced

The flake did not reproduce on this machine, which is not loaded enough. Saying so rather than implying otherwise. Measured by instrumenting the test and running the full suite at --test-threads=16:

lock_at_spawn=Some(true)   stop_took=8.047s       # the child won the first race
at_return=Some(false)      freed_after=13.959µs   # the kernel won the second

Both windows were open; both happened to close in time here. That is evidence about this machine, not about the guard — which is why the measurements are not offered as the fix's justification. The code path is: two waits on asynchronous state with no wait around them, one of which explains the reported failure exactly. Closing both costs nothing on a machine where they were already closed.

What is preserved

Verification

$ cargo test -p stella-cli --bin stella
test result: ok. 1416 passed; 0 failed        # x2

$ cargo test -p stella-cli --bin stella -- --test-threads=16
test result: ok. 1416 passed; 0 failed        # x2

Four consecutive full-suite runs, two under raised thread pressure.

Closes #1721

Summary by Sourcery

Tighten the stop-escalation test to wait for the child process' liveness lock and for lock release, and add a module reachability guard with accompanying scripts and CI/Makefile wiring to ensure all Rust source files under src/ are reachable from crate roots.

Bug Fixes:

  • Stabilize the stop-escalation probe by synchronizing on the child process acquiring and later releasing its liveness lock instead of asserting on racy instantaneous state.

Enhancements:

  • Introduce a repository-wide module reachability checker to detect Rust source files under src/ that are no longer referenced by any mod declaration and would otherwise be silently skipped.
  • Add a hermetic test harness for the module reachability checker that exercises both positive cases and known failure modes, including prior regressions.

Build:

  • Wire the module reachability guard into the Makefile gate targets and expose a dedicated test target for its self-tests.

CI:

  • Run the module reachability checker in CI as part of the guards-fast gate to catch unreachable Rust modules without requiring the Rust toolchain.

Documentation:

  • Document the new module reachability gate and update AGENTS and CONTRIBUTING to reflect the expanded gate step list and usage of the new checker.

Tests:

  • Add end-to-end tests for the module reachability checker using temporary git repositories to validate detection of orphans, legitimate shapes, regressions, and #[path] usage.

Stella Test added 2 commits August 6, 2026 03:43
Deleting a `mod` declaration silently disables the file it pointed at, and
nothing in `make gate` noticed. rustc, rustfmt and clippy all walk `mod`
declarations from the crate root, so an orphaned file is invisible to the
compiler, the formatter and the linter at the same moment — and `cargo test`
still reports green.

Measured on the real regression, by removing #1739's restored line again:

  cargo test -p stella-graph --lib -- --list | rg -c '^store::tests::'
    0     with the declaration deleted — and the suite reports ok
    19    with it restored

19 tests vanished and three gates stayed green. Only a reviewer reading the
diff caught it the first time.

Adds `module-reachability` to GATE_GUARDS_FAST: a text-level walk of `mod`
declarations from each crate's roots (src/lib.rs, src/main.rs, every [[bin]]
path, and Cargo's auto-discovered src/bin/*.rs), reporting any tracked .rs
under src/ that nothing reaches. Toolchain-free, so it rides the cheapest
rung and the author's own pre-push hook. Never scoped by CARGO_SCOPE — this
is a fact about the repository, not about a crate.

`#[path = "…"]` relocates a module's file and this walker does not model it.
There are none in the tree (#1393 removed the last), so rather than guess it
fails loudly and says so. A silent wrong answer from a reachability check is
worse than no check.

The first draft was exactly that wrong answer, and the fixture that catches it
is in the suite. It skipped string literals on the theory that a `mod x;`
inside one is rare and the failure would be benign. Both halves were wrong:
the risk is not a `mod` inside a string, it is a `/*` inside one. This tree
has `"guard-deny-path: protected/**"` in stella-cli/src/agent/tests.rs, whose
`/*` opened a block comment that never closed — blanking 450 lines, hiding
three real declarations, and reporting three perfectly reachable files as
orphans. The stripper now tracks strings, escapes and raw strings.

scripts/test-module-reachability.sh (`make module-reachability-test`,
hermetic) covers both directions, because they fail differently and a suite
checking one is blind to the other. Misses: an orphan file, and a store.rs
missing its `mod tests;` — the #1739 shape exactly. Fabricates: `/*` inside a
plain and a raw string literal, and the inverse, that a `mod` named only in a
comment still does NOT count. Plus the shapes that must stay quiet — flat,
nested, mod.rs, `#[cfg(test)]` on its own line and inline, `pub(crate) mod`,
and src/bin auto-discovery, which a naive walker reports as an orphan.

Gate wiring: GATE_STEPS in the Makefile, the block in AGENTS.md, the command
list in CONTRIBUTING.md (all three, which is what gate-parity enforces —
twenty-three steps becomes twenty-four), a step in ci.yml beside the other
toolchain-free guards, and an alias in check-gate-parity.sh because this is
the second Python guard and the default expects `check-<step>.sh`.

Closes #1750
`a_run_that_ignores_term_is_killed_after_the_grace_period` fails
intermittently under a full-suite run and passes alone. Both of its waits on
asynchronous state were missing, and the issue asks which one expired. The
honest answer is that neither was a bound that proved too small — there was no
bound in either place.

## The precondition, which is the one that explains the symptom

`spawn` returns once the PARENT has forked. The liveness lock is taken by the
CHILD, after its `exec`. So there is a window in which the run is registered
and holds no lock — and `stop` reads exactly that lock to decide whether there
is anything to stop:

    if lock_is_held(&sidecar) != Some(true) { … "was already finished"; return }

Entering that branch returns in microseconds without signalling anything, and
`took >= STOP_GRACE` then fails against a run that was never asked to stop.
Under ~1400 concurrent tests, several of which spawn their own children, child
startup is exactly what slips. The test now waits for the child to hold its
lock before calling `stop`, which is the precondition the assertion always
assumed and never established.

## The observation

`stop` returns as soon as it has ISSUED the SIGKILL — unlike
`Supervisor::interrupt_and_drain`, which reaps with `child.wait()` first. The
kernel still has to schedule the target, terminate it, and release its flock,
so "the lock is free" is not true at the instant `stop` returns and was never
promised to be. That assertion had no wait at all, making it the only
lock-released-by-a-dying-process check in the file without an `eventually`
around it (the two siblings, at the `_ends` and `stopping_a_finished_run`
cases, both have one). It now has the same 10s budget they use.

## What was and was not reproduced

Neither race reproduced on this machine, which is not loaded enough. Measured
by instrumenting the test and running the full suite at --test-threads=16:

  lock_at_spawn=Some(true)   stop_took=8.047s        the child won the race
  at_return=Some(false)      freed_after=13.959µs    the kernel won the other

Both windows were open and both happened to close in time here. That is
evidence about this machine, not about the guard, and it is why neither
measurement is offered as the fix's justification — the code path is. Stating
it plainly rather than claiming a reproduction: the two waits are unguarded by
inspection, one of them explains the reported failure exactly, and closing
both costs nothing when they are already closed.

Neither constant moved. STOP_GRACE is untouched, the property is still
asserted against a real child process that ignores TERM, and nothing is
quarantined — #1610 quarantined this probe once and #1632 un-quarantined it,
and dropping the SIGKILL-escalation rung again would lose coverage the module
docs name explicitly.

`cargo test -p stella-cli --bin stella` — 1416 passed, 0 failed, across four
consecutive runs including two at --test-threads=16.

Closes #1721
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stella-cli-docs Ready Ready Preview Aug 6, 2026 11:02am

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds proper synchronization around the stop-escalation test to remove two unguarded races, and introduces a new CI guard that ensures every Rust source file under a crate’s src/ is reachable from its crate root, integrating it into the Makefile, CI workflow, and gate documentation along with a dedicated test harness for the guard.

Sequence diagram for stop-escalation test synchronization

sequenceDiagram
  actor Test
  participant Supervisor
  participant ChildProcess
  participant LockFile
  participant Kernel

  Test->>Supervisor: spawn
  note over Supervisor,ChildProcess: Parent returns before child holds lock
  Test->>LockFile: lock_is_held
  alt [lock not yet held]
    Test->>Test: wait_until_lock_held
    Test->>LockFile: lock_is_held
  end
  Test->>Supervisor: stop
  Supervisor->>ChildProcess: signal_group SIGKILL
  Supervisor->>Supervisor: mark_stopped
  Supervisor-->>Test: return
  Test->>LockFile: eventually_check_lock_released
  LockFile-->>Test: lock_is_held == false
Loading

Flow diagram for the new module-reachability guard

flowchart TD
  A[Start check-module-reachability.py] --> B[Discover crates under ROOT]
  B --> C[For each crate: crate_roots]
  C --> D[reachable_from roots]
  D --> E[tracked_sources from git ls-files]
  E --> F[Compare reachable vs tracked]
  F --> G{problems?}
  G -- yes --> H[Print problems\nexit 1]
  G -- no --> I{orphans?}
  I -- yes --> J[Print orphan files\nexit 1]
  I -- no --> K[Print OK summary\nexit 0]
Loading

File-Level Changes

Change Details Files
Harden the stop-escalation test so it waits for the child process to acquire and later release its liveness lock using bounded eventually waits instead of assuming synchronous behavior.
  • Insert an eventually-based wait (10s) before invoking stop() to ensure the child has acquired its liveness lock, preventing the test from exiting via the 'already finished' branch without signalling the child.
  • Replace the immediate post-stop lock_is_held assertion with an eventually-based wait (10s) that polls for the lock to become free, aligning this check with other lock-release-by-process-death assertions in the file.
  • Augment test comments to clearly document the races previously present, the contract differences between stop() and Supervisor::interrupt_and_drain, and why the bounds represent guarding asynchronous behavior rather than widening STOP_GRACE.
crates/stella-cli/src/daemon/tests.rs
Introduce a module-reachability guard that checks every tracked Rust source under src/ is reachable from a crate root via mod declarations, and wire it into the gate and CI pipeline.
  • Add module-reachability to the fast guard set and gate target in the Makefile, plus a dedicated module-reachability-test target for hermetic validation of the walker.
  • Extend the CI workflow to run the module-reachability Python script as a toolchain-free check, with comments explaining its motivation and how it catches files made unreachable by deleted mod declarations.
  • Update AGENTS.md and CONTRIBUTING.md to describe the new gate step, keep the documented gate-step count and lists in sync with GATE_STEPS, and ensure gate-parity will catch drift.
  • Teach scripts/check-gate-parity.sh how to map the new module-reachability guard name to the correct CONTRIBUTING.md command alias.
  • Add scripts/check-module-reachability.py to implement the reachability guard, including comment-stripping, module-graph walking from crate roots, git-based file discovery, and explicit failure on unsupported #[path] attributes.
  • Add scripts/test-module-reachability.sh, a bash test harness that synthesizes temporary git repositories covering positive, negative, regression, and real-workspace cases to verify the reachability guard’s behavior.
Makefile
.github/workflows/ci.yml
AGENTS.md
CONTRIBUTING.md
scripts/check-gate-parity.sh
scripts/check-module-reachability.py
scripts/test-module-reachability.sh

Assessment against linked issues

Issue Objective Addressed Explanation
#1721 Make daemon::tests::a_run_that_ignores_term_is_killed_after_the_grace_period pass reliably under full-suite load by fixing the races in its waits, without weakening the SIGKILL-escalation property or replacing the real child process.
#1721 Identify and document in the PR which wait(s) caused the flake (or what was missing), and justify why the new waiting logic is sufficient rather than being a blind timeout increase.

Possibly linked issues

  • #unknown: PR directly fixes the flaky stop-escalation test by adding required waits; extra CI guard changes are incidental.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

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.

Flaky: a_run_that_ignores_term_is_killed_after_the_grace_period fails under full-suite load, passes alone

1 participant