Skip to content

fix(guard): pre-yield permit check missed line-adjacent leaks and partial multi-permit releases - #863

Merged
Kyzcreig merged 1 commit into
mainfrom
fix/preyield-guard-hardening
Sep 22, 2026
Merged

Kyzcreig merged 1 commit into
mainfrom
fix/preyield-guard-hardening

Conversation

@Kyzcreig

@Kyzcreig Kyzcreig commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #860 (merged as 6319e227); rebased onto main after that PR landed mid-run. Closes the FleetReview coverage gaps on that PR's new class guard scripts/check_preyield_permit_release.py, split out per house rule rather than widening a verified branch.

Reproduced before fixing

A 34-case matrix was run against the landed guard (a9dd9bae) first. 7 cases RED pre-fix, 34/34 green after.

# Finding Status
1 "Yield expression skipped" (P1) Does not reproduce as filed - see below
2 Guard accepts a release of ANY object (P1) Reproduced, fixed
3 Sibling except arm skipped (P2) Reproduced, fixed
4 Zero-sites exit-2 breaks [paths...] (P2) Reproduced, fixed
5 Aborted window leaves stats incremented (P2) Reproduced, fixed
6 Sync config I/O on the event loop (P2) Reproduced, fixed

1. The real gap is line granularity, not yield form

The filed P1 says expression-form yields are skipped. Measured: x = yield, acc += yield, handle((yield)), await handle((yield)), pair = ((yield), 1) and the ifexp form all already fired on the landed guard.

The actual hole was the window bound acquire.lineno < n.lineno < yield.lineno - a semicolon-joined leak on either boundary line escaped:

await sem.acquire(); _LOG.info("admitted %s", surface)   # skipped
_LOG.info("admitted %s", surface); yield                 # skipped

Now positional (lineno, col_offset), with any node that contains the yield excluded - the yield runs first, so an expression-yield's wrapper is not an offender. Both directions are parametrized over 7 yield forms.

2. Partial multi-permit release - verified on real source

Release was matched by method name, so at the two-semaphore site gateway/turn_admission.py (self.total + self.internal) an except arm releasing only one satisfied the guard. Now matched by receiver, and every object acquired before the yield must come back.

Independent oracle, real source not a fixture - delete only the self.internal release from TurnAdmission.slot:

new guard:    exit 1  (FIRES)
landed guard: exit 0  (PASSES)   <- the escape

This also closes the wrong-semaphore false negative disclosed as ISSUE-2 on #860. The docstring's DOES NOT COVER section now states measured behaviour rather than repeating the old claim.

3. Sibling except arm

Any single releasing except BaseException satisfied the try, so a narrower sibling that re-raised bare leaked whenever it matched. Every exiting arm must now release. An arm that swallows falls through to the yield still legitimately holding the permit and is correctly not asked to release (demanding it would be a double release).

4. Per-file invocation

Zero sites is exit 2 only for a directory sweep, where it genuinely means discovery broke (that test still pins it). A named file with no sites is exit 0; a named file that violates is still exit 1.

5-6. Gate module

_record_stats ran first in the pre-yield window, so an abort after it counted a slot the caller never received - overstating served load exactly when the gate was faulting. Moved last inside the guarded window. With an injected fault: acquired_count 1 -> 0 on abort, still 1 on a granted admission, permit 2/2 throughout.

The per-admission cap read went through load_config() (full expansion + copy.deepcopy) on the event loop. Swapped to the repo's existing read-only idiom read_raw_config_readonly() - same (mtime_ns, size) freshness key, so a cost change not a behaviour change:

load_config():              445.4 us/call
read_raw_config_readonly():   7.5 us/call    (live 22 KB config.yaml, 2000-call loop, py3.11.15)

Cap value unchanged at 2. Stated trade-off: the raw read skips ${VAR} expansion; this is an int cap, no live config spells it that way, and a non-int already falls back via _coerce_positive_int.

Verification

  • ruff check clean on all 3 files
  • 76 passed - this suite + tests/gateway/test_turn_concurrency.py + tests/test_web_server_sessiondb_eventloop.py
  • Tree sweep exit 0, 2 sites enumerated (no real site fires)
  • Every new test mutation-proven RED: guard reverted to a9dd9bae -> 9 failures; _record_stats moved back to first -> 1 failure; load_config restored -> 1 failure; control 31 passed with both files cmp-verified restored

Reviewer note: the load_config absence assertion is AST-based (imports + calls), not a text grep - the docstring legitimately names load_config to explain the choice.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…tial multi-permit releases

FleetReview coverage gaps on PR #860's new class guard
scripts/check_preyield_permit_release.py. The landed fix is correct; these
are holes in the enforcement mechanism, split out rather than widening a
verified branch.

Reproduced against the landed guard before changing it (34-case matrix; 7
cases RED pre-fix, 34/34 green after):

1. The window was LINE-based (`acquire.lineno < n.lineno < yield.lineno`),
   so `await sem.acquire(); _LOG.info(...)` and `_LOG.info(...); yield`
   both fell outside it and passed clean. Now positional
   (lineno, col_offset), with a node that CONTAINS the yield excluded --
   the yield runs first, so an expression-yield's wrapper is not an
   offender. NOTE: the finding as filed ("yield expression skipped") does
   not reproduce; `x = yield`, `await handle((yield))`, tuple/ifexp forms
   all already fired. Line granularity was the actual gap.

2. A release was matched by METHOD NAME, so at the two-semaphore site
   gateway/turn_admission.py (total + internal) an except arm releasing
   only one of them satisfied the check. Release is now matched by
   RECEIVER and every object acquired before the yield must come back.
   Independent oracle on real source: deleting only the `self.internal`
   release from TurnAdmission.slot makes the new guard FIRE (exit 1) while
   the landed one PASSES (exit 0) -- the escape, reproduced.
   This also closes the wrong-semaphore false negative disclosed as
   ISSUE-2 on #860; the docstring boundary section is updated to match
   measurement rather than restating the old claim.

3. Any single `except BaseException` arm that released satisfied the try,
   so a sibling `except ValueError:` that re-raised bare leaked whenever
   it matched. Now every EXITING arm must release; an arm that swallows
   falls through to the yield still holding the permit and is correctly
   not asked to.

4. Zero-sites exit 2 fired on per-FILE invocations, making the documented
   `[paths...]` form unusable from pre-commit (most files hold no sites).
   Vacuity is now exit 2 only for a directory SWEEP, where it does mean
   the discovery shape broke; the sweep test still pins that.

Gate module (hermes_cli/session_db_heavy_gate.py), same review:

5. `_record_stats` ran FIRST in the pre-yield window, so an abort after it
   left acquired_count/queued_count/queue_wait_seconds_total counting a
   slot the caller never received -- overstating served load exactly when
   the gate was faulting. Moved LAST inside the guarded window. Measured
   with an injected fault: acquired_count 1 -> 0 on abort, still 1 on a
   granted admission, permit 2/2 throughout.

6. The per-admission cap read went through `load_config()` (full expansion
   + copy.deepcopy) on the event loop. Swapped to the repo's existing
   read-only idiom `read_raw_config_readonly()`, same (mtime_ns, size)
   freshness key: 445.4 us/call -> 7.5 us/call against the live 22 KB
   config.yaml (2000-call loop, py3.11.15), value unchanged at 2.

Verified: ruff clean on all 3 files; 76 passed (this suite +
test_turn_concurrency + test_web_server_sessiondb_eventloop); tree sweep
exit 0, 2 sites enumerated. Every new test mutation-proven RED -- guard
reverted to a9dd9ba = 9 failures; _record_stats moved back = 1 failure;
load_config restored = 1 failure; control 31 passed with both files
cmp-verified restored.
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

PARTIAL — ensemble escalated: judge transient failure

This review did not reach a trusted verdict, so it is not a gate pass and the findings below may be incomplete. They are posted so they can be read rather than lost in a terminal record.

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 1/5

Findings

  • P1 hermes_cli/session_db_heavy_gate.py:63 — Cap read swapped to read_raw_config_readonly(), which always takes _CONFIG_LOCK on the event loop (load_config had a lock-free fast path)
  • P3 hermes_cli/session_db_heavy_gate.py:178 — Moving _record_stats after the log is correct; noting the intentional loss of acquired_count on a pre-yield abort
  • P1 scripts/check_preyield_permit_release.py:442 — Staged-acquire required set
  • P1 scripts/check_preyield_permit_release.py:253 — Multi-permit data-flow
  • P1 scripts/check_preyield_permit_release.py:174 — _released_in counts a release() that only appears inside a nested closure in the handler/finally, which can green-light a real leak
  • P2 scripts/check_preyield_permit_release.py:508 — sweeping derives from is_file(), so a path that does not exist is treated as a directory sweep and misreports as "discovery shape is broken"
  • P2 scripts/check_preyield_permit_release.py:295 — _in_release_machinery exempts the whole handler/finally subtree, not just the release call — the new swallowing-arm test cements a real miss
  • P3 tests/scripts/test_preyield_permit_release.py:52 — Stale docstring: _record_stats is no longer "the window's first statement"
  • P2 tests/scripts/test_preyield_permit_release.py:754 — test_max_concurrency_read_does_not_deepcopy_config_per_admission claims coverage it does not provide; its one behavioural assertion cannot fail
  • P1 scripts/check_preyield_permit_release.py:468 — Yield-value window skip
  • P1 scripts/check_preyield_permit_release.py:196 — Handler-exit heuristic

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6, G=grok-4.6 · cost: $17.21 · duration: 57m 15s · rounds: 2 · files examined: 3

Kyzcreig added a commit that referenced this pull request Sep 22, 2026
…ere"

Review found 4 MEASURED permit-leak regressions vs the merged #860 guard.
`_handler_may_exit` excused an arm when it found no `ast.Raise`/`ast.Return`
node in it -- but an arm also leaves via a call that raises, an `assert`,
arithmetic, or a plain logging call given a bad format argument. Each is a
real 1-permit loss that #860 caught and this branch let through.

"This arm swallows" is not AST-decidable: the SAME arm text leaks 0 with a
benign logger and leaks 1 with a bad format arg. So the question is inverted
and answered on a whitelist -- an arm is excused only when every statement in
it is provably inert (pass, a bare constant, an assignment between
names/constants, a nested def). Anything else owes the permit back. The arm
check now asks the same question the window check always has.

The branch's own fixture at tests/.../:636 was the counterexample (its arm
held a logging call), so it is narrowed to statements that cannot leave.

MEASURED on the runtime oracle (real asyncio.Semaphore, synchronous fault at
the pre-yield log, permit delta; HEALTHY arm clean on all 26 shapes):
  regressions vs #860      4 -> 0
  false negatives          7 -> 2  (X4/X9 only, both pre-existing on #860/#863
                                    and now named in the DOES NOT COVER block)
  false positives          0 -> 0
  every prior win kept     I1-I7, F1-F5, X5, X6 still FIRE; H1/H2 still clean
  X10 closes for free

Mutation battery 7/7 RED, control 61 green at both ends:
  M8  exemption -> "contains ast.Raise"        7 failed / 54 passed
  M9  _inert_expr always True                  6 failed / 55 passed
  M10 _inert_stmt always True                 23 failed / 38 passed
  M11 _inert_stmt always False                 8 failed / 53 passed
  M1  reachability -> #860 bare-trailing rule 13 failed / 48 passed
  M2  _released_in -> ast.walk                 6 failed / 55 passed
  M3  BaseException coverage -> exiting only   2 failed / 59 passed

Floors held: repo sweep exit 0 over the same 2 sites, per-file exit 0,
empty-dir sweep exit 2, ruff clean.
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

🤖 merged-by: apollo · lane: t_892f48ad · gate: BYPASS: FR router degraded (t_8d3c4eeb); argus artifact review stands in · why: argus r1 APPROVED (artifact lens): guard hardening for the 6 FleetReview findings on #860 (expression-form yield, partial multi-permit release, sibling except arm, zero-sites exit-2, pre-yield counter inflation, sync config I/O). CI green, MERGEABLE/CLEAN.

@Kyzcreig
Kyzcreig added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit a3d0b75 Sep 22, 2026
54 checks passed
@Kyzcreig
Kyzcreig deleted the fix/preyield-guard-hardening branch September 22, 2026 12:17
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
… and credited unreachable releases

Pre-existing coverage gaps in scripts/check_preyield_permit_release.py, found
by Argus's independent RUNTIME oracle during review of #863 and measured
present on BOTH the merged #860 guard and the #863 candidate -- so this is not
a regression from either, and is filed separately rather than widening a
verified branch.

FINDING 1 -- `_handler_exits()` asked "is the LAST statement a bare
raise/return?", scanning reversed(handler.body) and breaking on any other
statement type. An except arm that exits through a compound statement was
therefore classified as swallowing and excused from releasing. It escapes only
in the SIBLING shape: a correct `except BaseException:` arm makes the chain
look satisfied while the misclassified narrow arm is the one that runs.
Replaced by `_handler_may_exit()`, which asks whether ANY path through the arm
can leave via raise/return (nested callables excluded -- own lifecycle).
Conservative by design: a raise a nested handler swallows still counts as a
possible exit, so such an arm is asked to release. False positive at worst.

FINDING 2 -- `_released_in()` used ast.walk, so a textually-present release
that can never run still satisfied the check. Now only statically-reachable
statements are credited: nested def/lambda bodies, `if False:` branches, loops
over an empty literal, and everything after an unconditional raise/return are
skipped. Anything the compiler cannot settle still counts, so a release under
a runtime condition, in a non-empty loop, or in a `with` body is unaffected.

FINDING 3 -- `except BaseException: pass` as the only arm was FLAGGED, though
it does not leak: the arm swallows and control reaches the yield still
legitimately holding the permit, which is the guard's own stated rationale for
not demanding a release from a swallowing arm. Cause was asking the
BaseException-coverage question of the EXITING arms only, so an all-swallowing
chain produced an empty set and read as unprotected. Coverage is now asked of
the whole chain; the release demand only of arms that can exit.

VERIFIED
Runtime oracle (real asyncio.Semaphore, synchronous fault injected at the
pre-yield log, measured permit delta; HEALTHY arm clean on all 11 shapes so no
degenerate fixtures) reproduces Argus's matrix exactly: I1/I2/I3/I4/I7 and
F1/F2/F3/F4 each LEAK 1 permit; controls I6 (releases then conditionally
raises) and H1 (BaseException: pass) are clean. The new guard's static verdict
matches that measurement 1:1 across all 18 probed shapes, 0 mismatches.

15 new tests (46 total, was 31 on #863's head). Sweep still exit 0 over the
same 2 enumerated sites -- no live site has any of these shapes. Per-file
invocation exit 0, empty-dir sweep still exit 2 (vacuity floor preserved).
ruff 0.15.20 clean on both files.
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
…ere"

Review found 4 MEASURED permit-leak regressions vs the merged #860 guard.
`_handler_may_exit` excused an arm when it found no `ast.Raise`/`ast.Return`
node in it -- but an arm also leaves via a call that raises, an `assert`,
arithmetic, or a plain logging call given a bad format argument. Each is a
real 1-permit loss that #860 caught and this branch let through.

"This arm swallows" is not AST-decidable: the SAME arm text leaks 0 with a
benign logger and leaks 1 with a bad format arg. So the question is inverted
and answered on a whitelist -- an arm is excused only when every statement in
it is provably inert (pass, a bare constant, an assignment between
names/constants, a nested def). Anything else owes the permit back. The arm
check now asks the same question the window check always has.

The branch's own fixture at tests/.../:636 was the counterexample (its arm
held a logging call), so it is narrowed to statements that cannot leave.

MEASURED on the runtime oracle (real asyncio.Semaphore, synchronous fault at
the pre-yield log, permit delta; HEALTHY arm clean on all 26 shapes):
  regressions vs #860      4 -> 0
  false negatives          7 -> 2  (X4/X9 only, both pre-existing on #860/#863
                                    and now named in the DOES NOT COVER block)
  false positives          0 -> 0
  every prior win kept     I1-I7, F1-F5, X5, X6 still FIRE; H1/H2 still clean
  X10 closes for free

Mutation battery 7/7 RED, control 61 green at both ends:
  M8  exemption -> "contains ast.Raise"        7 failed / 54 passed
  M9  _inert_expr always True                  6 failed / 55 passed
  M10 _inert_stmt always True                 23 failed / 38 passed
  M11 _inert_stmt always False                 8 failed / 53 passed
  M1  reachability -> #860 bare-trailing rule 13 failed / 48 passed
  M2  _released_in -> ast.walk                 6 failed / 55 passed
  M3  BaseException coverage -> exiting only   2 failed / 59 passed

Floors held: repo sweep exit 0 over the same 2 sites, per-file exit 0,
empty-dir sweep exit 2, ruff clean.
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