Skip to content

fix(test): guard both CPU samples symmetrically, so a budget-exhausted second walk skips instead of failing - #1186

Merged
wshallwshall merged 3 commits into
mainfrom
claude/connscale-cpu-probe-symmetric-guard
Sep 16, 2026
Merged

wshallwshall merged 3 commits into
mainfrom
claude/connscale-cpu-probe-symmetric-guard

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

The defect: one test guarded its two CPU samples in opposite directions

tests/test_connscale_cpu_probe.py::test_sampler_measures_a_descendant_that_actually_burns_cpu
took two CPU readings and treated a gap differently each time:

  • the FIRST reading: if first.cpu_seconds is None: pytest.skip("OS CPU probe unavailable on this runner")
  • the SECOND reading: assert after.cpu_seconds is not None

The second walk is the likelier of the two to time out. It runs later, on a host that has had longer
to get busy. So the unguarded side was the one that fired, and it reds test (windows-2025, py3.14),
a live required context.

The asymmetry is visible in the source and does not depend on any runtime measurement. That alone
justifies this change.

The fix: extend the treatment the sibling test already implements

test_subtree_re_resolution_picks_up_a_late_spawned_child, in the same file, already handles this
condition correctly under BACKLOG #1290 (landed as ad6c1b9d7, PR 598): when a walk produces no
usable result it retries with a LONGER budget, and only then skips, with a message carrying an
explicit verdict. That treatment was never extended to the burn test. This extends it.

Both readings now go through one helper, _cpu_seconds_or_verdict:

  1. take the reading;
  2. if it gapped AND the gap spent its budget, retry with _PROBE_TIMEOUT_S raised to
    _STALLED_WALK_TIMEOUT_S, bounded by _granted_extension_walks;
  3. reach a verdict.

A fast failure still FAILS, and that is the point

A gap whose cause did NOT spend its budget is reported as a failure and is never downgraded to a
skip. #1290's own comment states the rule and it is the whole design:

A walk that returns None WITHOUT spending its budget is a broken enumerator, and downgrading that
to a skip is how a skip-on-load hides a probe regression forever.

An exhausted budget measures the runner, so the tree under test is not implicated and a skip is
honest. Anything faster means the probe RAN and produced nothing usable, which is a probe defect: if
that became a skip, the test would be disarmed forever and nothing would report it. The rule also
holds for a fast failure that appears partway THROUGH the extension, so a single load-induced timeout
cannot open a door that a defect then walks through.

The discriminator is ProbeDegraded.is_budget_exhausted, the split the probe's own vocabulary already
states once and tells consumers to stay on. Not a second copy of the member list.

The one subtle arm: degraded is set only on a FULL gap, so an absent cpu_seconds carrying NO cause
is the POSIX partial read (handles read, CPU did not). That read ran and produced no CPU, so it counts
as a fast failure. Reading the missing cause as "nothing went wrong" would invent the timeout the
probe was careful not to claim.

This also TIGHTENS the first reading, and you should know that

Symmetry cuts both ways here, and only one of the two directions is risk-free:

  • the SECOND reading goes from "always fail on a gap" to "fail unless the budget was exhausted".
    Strictly looser than today, so it cannot introduce a new red.
  • the FIRST reading goes from "always skip on a gap" to the same rule. Strictly TIGHTER than today.

So a runner whose CPU probe gaps for a non-timeout reason (WALK_ERROR, WALK_EMPTY, WALK_NO_ROOT,
READ_ERROR, READ_EMPTY) now fails where it previously skipped with the unattributable message "OS
CPU probe unavailable on this runner". That is #1290's rule applied in the direction it points, and it
is what the ProbeDegraded docstring already says a consumer must do. I am naming it because it is a
behavior change beyond the literal brief, not a silent side effect. If you want the first reading left
as a blanket skip, say so and I will cut it back, but I think the tighter form is right: a blanket skip
on the first reading is the same defect #1290 fixed on the second.

A liveness guard the extension makes necessary

_BURN is a BOUNDED loop (roughly 12 s, then it exits on its own) and the sampler re-walks every tick,
so a departed PID drops straight out of the summed set. A second reading taken after the burner exits
is summed over a SMALLER subtree than the first, and after - first can come out at or below zero with
nothing wrong.

That is unreachable on the healthy path, where both readings land within about 3 s of the spawn. It
becomes reachable once a bounded extension has spent tens of seconds first. This is the same latent
edge the extension turned into a designed path in the sibling test, and it is closed here the same way:
if the burner did not outlive both readings, the test reports COULD NOT MEASURE rather than accusing
the probe of a flat counter.

One distinction inside that guard: a burner that exited 0 completed normally and earns the skip; a
burner that exited NON-ZERO never burned at all, which is a broken rig, so it FAILS as a positive-
control failure. Skipping that would hide a rig defect the same way a blanket skip hides a probe one.

Four new tests, because nothing else can reach this code

The verdict arms are reachable only on a host too starved to enumerate, which no healthy runner
reproduces on demand. Without a scripted sampler, the fast-failure-still-fails rule would ship with
nothing able to exercise it on any runner, ever. That is precisely the shape #1290 is about, so the
guard it motivates must not be built that way. _canned_sampler serves a scripted sequence of
ProcSamples, and the four tests pin:

  • a fast gap FAILS;
  • a budget-exhausted gap SKIPS;
  • a fast gap DURING the extension still FAILS (the extension cannot launder a defect);
  • a reading recovered by the extension returns the walks it did not spend.

They touch no OS state and run on every platform, deterministically, under any load.

Reuse versus duplication: reused what was already shared, added one helper called twice

Deliberate, and recorded as asked.

REUSED as-is: _granted_extension_walks, _STALLED_WALK_TIMEOUT_S, _STALL_EXTENSION_WALKS,
_WATCHDOG_SHARE, and ProbeDegraded.is_budget_exhausted. No new copy of any of them.

ADDED: one helper called twice, which is itself the anti-duplication move. The alternative was writing
the same guard block out for first and for after, which is how the two drifted apart in the first
place.

NOT UNIFIED with the sibling's bounded-extension block, deliberately. The sibling's retry unit is a
WALK with nonlocal bookkeeping (walks, walked_ok, child_alive_at_success, failed); mine is a
READING that returns a value. It also classifies differently and correctly so: the sibling compares
seconds SPENT against seconds ALLOWED because a walk reports only success or failure, while a reading
carries its own degraded cause and can be asked directly. The probe's vocabulary says these are one
line drawn with the evidence each side has, and I kept them one vocabulary rather than one function.
Fusing them would have obscured both and would have meant editing the test I am supposed to prove I
did not break.

One more twin I found and deliberately did NOT import: tests/test_connscale_smoke.py::_is_budget_exhausted.
It classifies a cause RECORDED AS A STRING and treats an unrecognised one as not tolerable; mine
classifies a typed cause taken live off a ProcSample, and its None arm carries the POSIX-partial-read
meaning above, which over there is only an incidental ValueError. Both delegate to the same production
property, so the member set cannot drift between them — only the two adapters exist to converge, and
converging them would mean editing a second test module whose ownership by other sessions was not swept
for this brief. I put a pointer to the twin in each docstring so a reader finds both. Converging the
two adapters is a reasonable follow-up if you want it filed.

One shared-grant note: _granted_extension_walks subtracts _RESOLUTION_DEADLINE_S, a 30 s poll the
burn test does not run, so the number it hands the burn test is conservative by roughly 27 s. That is
the safe direction. The burn test also carries ONE allowance across both readings rather than taking a
fresh grant for each, so a doubly-stalled run cannot outrun the watchdog the grant is trimmed to fit.
Being killed by the watchdog reports no verdict at all, which is strictly worse than the failure this
PR is fixing.

Evidence: n=1, stated as n=1

No base rate was measured, and I am not claiming one. No failure rate, no frequency, no percentage.
What is known, as given to me by the Lander that cut this brief:

  • ONE observed kill of PR 1157's merge-queue entry at 16:07Z, with degraded=ProbeDegraded.WALK_TIMEOUT.
  • A CONTROL: the same leg PASSED on PR 1157's own head e8c71d9cf.
  • The leg is NOT persistently red on main: test (windows-2025, py3.14) measured SUCCESS on main at
    d33237658.
  • The asymmetry itself, visible in the source.

The asymmetry is what justifies the fix. It does not need a rate, and a wrong one would poison the
record.

A peer measurement on the sibling module, attributed and NOT mine

Relayed to me by the Manager, sourced to the last comment on merged PR 1148 (merge commit 6b8ce58d5,
an ancestor of origin/main). I did not run this and I did not verify it. Same commit, same
machine, only competing load changed:

tests/test_connscale_smoke.py,  6 concurrent pytest processes:  12 passed in 26.57s
tests/test_connscale_smoke.py, 10 concurrent pytest processes:  one test did not finish in 600s

At least a 22x swing from host load alone, on the SIBLING module. I did not measure my own module's
load sensitivity and I am making no claim about it.
CI was green on all three platforms for that
commit, and the peer reads it as a shared-developer-box effect rather than an engine defect.

It does not change this fix, and it does not license a blanket skip. If anything it sharpens the #1290
rule: a host starved enough to exhaust a walk's budget is exactly the case that may skip, and a walk
failing FAST is exactly the case that must still fail. Load sensitivity is the argument FOR a bounded
extension, not for a wider skip.

Checks run

All in the foreground, in this worktree's own .venv.

Check Result
ruff check . All checks passed
ruff format --check . 1301 files already formatted
mypy messagefoundry (strict) Success: no issues found in 274 source files
pytest tests/test_connscale_cpu_probe.py 28 passed, five consecutive runs (20.25 / 18.17 / 17.67 before the simplify edits, 18.74 / 17.16 after)

The file held 24 tests before; the four scripted-sampler tests bring it to 28. Ruff and the whole file
were re-run after the simplify edits, not only before.

Local runs report INCOMPLETE RUN -- coverage was NOT collected because the vault extra is absent
from this interpreter. That is expected here and is not a failure; it means modules gated on that extra
removed themselves at collection time, so these counts do not establish that the full suite is green.

Read those local pytest numbers with the load beside them. At the time of all three runs this box
carried 61 claude, 27 node and 10 python processes. The peer's degradation point above was 10
concurrent pytest processes. So a local green here is NOT evidence the guard is right, and a local red
would not have been evidence I broke the sibling. Nothing was tuned to make a local run pass: no added
retry, no added sleep, no widened timeout. CI is the verdict.

What a local run CANNOT reach: the gap branches. A healthy probe never gaps, so the extension and
both verdict arms are unreachable in a real local run. That is exactly why the four scripted-sampler
tests exist; they are what actually exercises the new code, and they are deterministic.

mypy does not cover tests/ (CI runs it on messagefoundry and messagefoundry_webconsole only).
Everything new is type-hinted anyway.

CI legs to read after I exit

My process is gone when this PR opens. Somebody needs to read:

  1. test (windows-2025, py3.14) — the required context this PR is about.
  2. The other test matrix legs, ubuntu included. On a 60 s watchdog _granted_extension_walks trims
    to ZERO walks, so the burn test degrades to a single reading then a verdict. That is the correct
    answer there, not a degradation, but it is a path the Windows legs will not show you.
  3. lint / mypy legs, to confirm the hosted result matches the local one.

What the /simplify pass changed, and one thing it caught in my own prose

Four cleanup agents (reuse, simplification, efficiency, altitude). Three findings applied:

  1. A redundant eligibility guard. The retry block was gated by an if that tested the same two
    facts the loop's own condition tests on its first pass. Folded the initial reading into the loop:
    nesting drops from four levels to three, causes.append collapses to one site, and the "when may a
    retry happen" rule is stated once instead of twice. I traced all four scripted-sampler tests through
    the new shape and the causes / used / return values are identical.
  2. A false sentence in my own docstring. I had written that the sibling classifies by elapsed time
    "because a walk reports only success or failure". That is wrong. FdSampler._resolve_degraded
    carries the cause for a walk too; _walk_succeeded reads it and discards the cause, keeping the
    boolean. So the two mechanisms differ in the evidence each test KEEPS, not in what is available.
    Corrected in place. An unchecked assertion in a comment becomes a permanent record, and this one
    would have justified the split on a premise that does not hold.
  3. A discoverability cost on the _is_budget_exhausted twin, handled with docstring pointers as
    described above.

The efficiency angle came back clean with arithmetic worth recording, since it is the hazard the
extension machinery exists to respect:

Leg Extension walks granted Worst-case test wall time Watchdog Margin
ubuntu 0 23 s 60 s 37 s
windows 2 83 s 120 s 37 s

That assumes an attempt can spend BOTH a walk timeout and a per-PID read timeout, so 2x the budget per
attempt. The sibling test that established this pattern already runs at roughly 100 s against the same
120 s Windows watchdog, so the new test's margin is wider than the shipped one it copies, not tighter.
The compounding is Windows-only: the POSIX walk is os.listdir / read_text with no subprocess
timeout to exhaust.

Four more instances of this pattern in the same file, NOT fixed here

The altitude reviewer found the same asymmetric-guard shape on the file's other two acceptance tests.
I verified each by grep and bound it to its enclosing test rather than trusting a line number:

Test Guard
test_the_reported_peak_excludes_a_stale_ppid_adopted_subtree pytest.skip("process-table enumeration unavailable on this runner")
test_the_reported_peak_excludes_a_stale_ppid_adopted_subtree pytest.skip("per-PID handle read unavailable on this runner")
test_the_probe_covers_workers_spawned_after_it_resolved_within_one_steps_tick_budget pytest.skip(f"OS handle probe unavailable on this runner ({first.degraded})")
test_the_probe_covers_workers_spawned_after_it_resolved_within_one_steps_tick_budget pytest.skip("process-table enumeration unavailable on this runner")

Every one skips on a gap without classifying the cause. The third is the sharpest: it prints
first.degraded in the message and then never branches on it, so a WALK_ERROR is named and excused in
the same line. Two of the four need more than a one-line change — the helper that produces the value
discards ProcSample.degraded before the guard ever sees it, so the cause would have to be surfaced
first.

I did not build these. They are outside a brief scoped to one test, they touch two currently-passing
acceptance tests, and the reviewer agrees they belong in a separate narrow fix. Flagging them for the
Lander as the clearest candidate for the next item. A shared
_assert_measured_or_verdict(cause, which) used by all three tests is the shape that would close them
together.

Proposed ledger text, for the Lander

I did NOT edit docs/BACKLOG.md — the engine copy is a public stub and the real ledger is vault-only.
No new number is cited anywhere in this PR; #1290 is the existing row whose treatment is extended.

Suggested amendment to #1290, if you want one:

Amendment (2026-09-15): the bounded extension was extended from the subtree-re-resolution test to
test_sampler_measures_a_descendant_that_actually_burns_cpu, whose two CPU readings were guarded in
opposite directions — the first skipped on any gap, the second was a bare assert ... is not None
that reddened test (windows-2025, py3.14) when a loaded runner's walk timed out. Both readings now
share one guard: a budget-exhausted gap skips, a fast gap still fails. The first reading's guard is
TIGHTENED by this, from a blanket skip to the same rule. Four scripted-sampler tests were added
because the verdict arms are unreachable on a healthy runner.

Nothing here contradicts the brief

Both line references verified by symbol against the working tree before I touched anything: the
guarded first reading and the bare second assert were where the brief said. No product code was
touched; messagefoundry/ is untouched and this is entirely test-side. I found no product defect to
report.

wshallwshall added 2 commits September 15, 2026 11:37
…e test

test_sampler_measures_a_descendant_that_actually_burns_cpu guarded its two CPU
readings in opposite directions. The first skipped on any gap at all; the second
was a bare `assert after.cpu_seconds is not None`. The second walk is the
likelier of the two to time out, since it runs later on a host that has had
longer to get busy, so the unguarded side was the one that fired and it reds
`test (windows-2025, py3.14)`.

Extend the bounded-extension treatment the sibling test already implements
(BACKLOG #1290, landed as ad6c1b9) to both readings. A gap whose cause SPENT
its budget earns a retry at a longer budget and then a skip; a gap from any
other cause is reported as a failure and is never downgraded, including one that
arrives partway through the extension. A probe that ran and produced nothing is
a probe defect, and a skip that swallows it disarms the test forever.

The discriminator is ProbeDegraded.is_budget_exhausted, the split the probe's
own vocabulary states once. An absent cpu_seconds carrying no cause is the POSIX
partial read, which counts as a fast failure rather than as a missing cause.

Note this TIGHTENS the first reading, from a blanket skip to the same rule.

Add a liveness guard the extension makes necessary: `_BURN` is a bounded loop,
so an extension can outlast it, and a reading taken after the burner exits is
summed over a smaller subtree. That reports could-not-measure rather than
accusing the probe of a flat counter.

Add four scripted-sampler tests. The verdict arms are reachable only on a host
too starved to enumerate, so without them the fast-failure-still-fails rule
would ship with nothing able to exercise it on any runner.

Test-side only; messagefoundry/ is untouched.
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 15, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Why both halves ship, including the tightening

This PR makes two guards symmetric, and the two directions are not equally risky. Recording the reasoning here rather than in a review thread, because the tightening is a deliberate behaviour change and the next reader deserves the argument rather than an assertion that two seats agreed.

The loosening is free. The second reading moves from "always fail when cpu_seconds is None" to "fail unless the walk exhausted its budget". Strictly looser. No new red is possible from it.

The tightening is the part worth arguing. The first reading moves from "always skip when None" to the same rule, so a non-timeout gap there now fails where it used to skip.

It is structurally inert in the case that worries us. The tightening fires only when a walk returns None without spending its budget. Host load produces the opposite failure: walks that spend their whole budget and time out. Those route to the bounded extension and then to a skip, so the discriminator sends a load-induced failure down the skip path and the tightening never sees it.

That matters because this module's family is measured as severely host-load sensitive on a shared developer box -- same commit, 6 concurrent pytest processes gave 12 passed in 26.57s, 10 concurrent gave one test unfinished at 600s, at least a 22x swing, with CI green on all three platforms for that commit (detail on merged PR #1148, measured by another session, not by this PR). That measurement is not an argument against the tightening, because load produces budget exhaustion and budget exhaustion skips.

What the tightening does catch is a walk that fails fast, which is a broken enumerator. BACKLOG #1290's own comment already states the rule: downgrading that is "how a skip-on-load hides a probe regression forever". Shipping only the loosening would leave the strict half unexercised, which is precisely the condition #1290 was written about.

The caveat, stated so it is not discovered later

Nobody has measured how often a fast-fail gap occurs on the first reading. The claim above is about which code path each cause takes, not about how often either cause occurs. No rate, frequency or percentage is claimed anywhere for the original WALK_TIMEOUT either -- the evidence for that was one observed queue-entry kill with degraded=ProbeDegraded.WALK_TIMEOUT, plus a passing control on e8c71d9cf, plus an asymmetry visible in the source.

If a fast-fail red starts appearing after this lands, that is the new guard working as designed. The right response is to read the enumerator, not to widen the skip back out.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander review

Disclosure first: I raised the finding this PR was briefed from, so I reviewed it as a reader looking for what my own brief got wrong rather than for confirmation. Two things here are better than what I asked for, and one thing I asked for is correctly absent.

Ship the tightening. It is not the risk it looks like

Making the two readings symmetric loosens the second and tightens the first, and only the tightening can create a new red. It is still right, because the tightening is structurally inert in the case that would hurt: it fires only when a walk returns None without spending its budget. Host load produces the opposite — walks that spend the whole timeout — and those route to the extension and then to a skip. So the peer's 22x load-sensitivity measurement is not an argument against it; under load the discriminator sends the failure down the skip path and the tightening never sees it.

What it does catch is a walk that failed fast, which means the probe ran and produced nothing. That is a finding, and #1290's own text says downgrading it is how a skip-on-load hides a probe regression forever.

The part I did not ask for and would not have thought of

The new arms are unreachable on a healthy runner. Without _canned_sampler the fast-failure rule would have shipped with nothing able to exercise it, on any runner, ever — which is the exact shape #1290 is about, so building the guard that way would have reproduced the defect it fixes. Four scripted tests close that, and the one that earns its place most is test_the_extension_cannot_launder_a_fast_failure_into_a_skip: a timeout opens the extension, an extension walk then fails fast, and the verdict must follow the fast failure. Without it a single load-induced timeout would be enough to hide every probe defect behind it.

The edge the extension itself created, found and closed

_BURN is a bounded loop of roughly 12 seconds and the sampler re-walks every tick, so a reading taken after the burner exits is summed over a smaller subtree and can come out at or below the first with nothing wrong. An extension spending tens of seconds makes that reachable where it was not before. burner_rc == 0 now reports could-not-measure, and burner_rc is None is asserted otherwise with a message that separates a broken rig from a slow host. Introducing a longer wait and then closing the liveness hole it opens is the part a careless version of this change would have missed.

Checked rather than read

The probe API it leans on exists and draws the line in one place. ProbeDegraded.is_budget_exhausted returns True for exactly WALK_TIMEOUT and READ_TIMEOUT; every other member is a finding. Delegating rather than re-listing members is why this cannot drift.

The None arm is right and is the easiest thing here to get wrong. degraded is set only on a full gap, so an absent cpu_seconds carrying no cause is a POSIX partial read — measured-and-broken, not could-not-measure. Treating None as "nothing went wrong" would invent a timeout the probe was careful not to claim.

Scope. One file, tests/test_connscale_cpu_probe.py. Nothing under messagefoundry/, and docs/BACKLOG.md untouched — correct, since the engine ledger is a stub and the real one is vault-only since 2026-09-13.

No glyphs. 210 added lines, zero non-ASCII characters. Positive control in the same pass: the removed lines contain one non-ASCII character, an em dash this PR replaces with --, so the zero is a real absence and not a broken search.

What it correctly did not do

The twin _is_budget_exhausted in tests/test_connscale_smoke.py is left alone with pointers both ways, and the sibling's different mechanism (_BUDGET_CONSUMED_FRACTION against the typed cause) is named rather than quietly converged. Both would mean editing a second test module whose ownership nobody swept. The four further instances on the #1210 and #1357 tests are tabled and bound to enclosing test names rather than line numbers, which is the right anchor — I have seen anchors in this repo drift by 50 to 593 lines in a day.

Stated no larger than it is

No rate, frequency or percentage is claimed anywhere for the original WALK_TIMEOUT, and none should be: the evidence is one occurrence plus a control, not a measured rate. If ubuntu starts reporting a fast-failure red after this lands, that is the new guard working and the response is to read the enumerator, not to widen the skip back out.

The local green is worth little on its own and the PR says so. The leg to read is test (windows-2025, py3.14), then the ubuntu legs, where the grant trims to zero walks and exercises a path Windows will not show you.

Verdict: merge.

@wshallwshall
wshallwshall added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit b0e0a6c Sep 16, 2026
41 checks passed
@wshallwshall
wshallwshall deleted the claude/connscale-cpu-probe-symmetric-guard branch September 16, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant