Skip to content

ci(tests): add a plain-unittest leg to catch conftest-masked ordering defects - #984

Merged
JarryShaw merged 6 commits into
mainfrom
fix/981-registration-gate-ordering
Oct 2, 2026
Merged

JarryShaw merged 6 commits into
mainfrom
fix/981-registration-gate-ordering

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Please follow the guide below

What is the purpose of your pull request?

Tick the commit type your subject line carries.

  • fix — corrects a defect
  • feat — adds a feature
  • perf — changes performance, not behaviour
  • refactor — changes neither behaviour nor performance
  • test — tests only
  • docs — documentation only
  • ci — workflows or build tooling
  • chore — anything else

Description of your pull request and other information

Fixes the test half of #981 and adds the CI leg it calls for.

The defect. RegistrationGateTests bound Engine, EngineBase, Dumper, DumperBase, Reassembly, ReassemblyBase, TraceFlow, TraceFlowBase and Extractor once, at import time. A sibling module that purges pcapkit from sys.modules and reimports it (deliberately asymmetric; pytest's own autouse fixture reconciles it, plain unittest does not) leaves those names stale while the registration hooks they exercise see the fresh generation, so a dynamically-created subclass registers in one generation's registry while the test reads another's. setUp now re-resolves everything via importlib.import_module instead.

Before: tests.protocols.application.test_http_unit + tests.test_base_class_contract + tests.const.test_const_str_payload_870_unit under plain unittest → Ran 73 tests ... FAILED (failures=1, errors=3). After: OK.

The CI leg. util/run_unittest_leg.py + the new unittest-ordering job run each tests/ subdirectory (paired with the root-level modules) under unittest.TextTestRunner, which has no pytest-subtests to swallow a subTest failure into a passing parent. tests/corekit and tests/vendor are excluded; see the job's own comment for the known, pre-existing reasons (one already documented, one newly found while sizing this leg and left for separate attention).

tests/utilities/test_decorators.py's own leaked sys.tracebacklimit is a second instance of the same reporting gap, left untouched here — already being fixed as a side effect of #719.

…rdering defects (#981)

RegistrationGateTests bound Engine, EngineBase, Dumper, DumperBase,
Reassembly, ReassemblyBase, TraceFlow, TraceFlowBase and Extractor once, at
import time. A sibling module that purges pcapkit from sys.modules and
reimports it (deliberately asymmetric; tests.conftest's autouse fixture
reconciles it, but only under pytest) leaves those names stale while the
registration hooks they exercise re-import their own collaborators and see
the fresh generation -- so a dynamically-created subclass registers in one
generation's registry while the test reads another's. Four subTests failed
this way and pytest never reported it: pytest-subtests marks the parent
test "passed" the moment only its subTests failed.

RegistrationGateTests.setUp now re-resolves every base/public pair and the
Extractor singleton via importlib.import_module, so the suite always
compares like generation to like.

util/run_unittest_leg.py and a new unittest-ordering job run each tests/
subdirectory, paired with the root-level modules, under
unittest.TextTestRunner -- the runner this class of defect is invisible to.
Scoped per directory for cost (the full suite OOMs at 29 GB in one
process); tests/corekit and tests/vendor are excluded, see the job's own
comment for why.

Build: tests/test_base_class_contract.py passes alone and alongside its
#981 reproduction; util/run_unittest_leg.py protocols/const/etc. all pass.
@JarryShaw JarryShaw added ci Pull requests that change CI or workflow configuration (ci: subject prefix) review: pending No verdict for the current head - never reviewed, or the head moved since the last one test Pull requests that add or correct tests (test: subject prefix) labels Oct 2, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

NEEDS CHANGES at 0d2a20cc8 — opus cross-review. The code is right and the mechanism is proven.
The prose that justifies the new CI job is wrong on its central claim, and I verified that myself
before writing this, because it contradicted a standing belief of my own.

pytest-subtests is not installed, and pytest does not mask subTest failures. The pull request
names it as the masking agent 4 times — twice in util/run_unittest_leg.py, twice in the workflow
comment. Measured in the repo venv: pip show pytest-subtests → Package(s) not found; it is absent
from the test extra, so CI never had it either. pytest 9.1.1 handles subTests itself
(_pytest.unittest.TestCaseFunction.addSubTest exists) and reports each failure:

SUBFAILED(i=1) ... AssertionError: 1 != 0
SUBFAILED(i=2) ... AssertionError: 2 != 0
2 failed, 2 passed, 1 subtests passed

So pytest's green on #981 comes entirely from conftest.py's autouse restore_module_table
preventing the defect from occurring — not from subtest masking. The job is still justified, since plain
unittest loads no conftest, but the stated reason is a fabricated mechanism and it is load-bearing: it
is the whole argument for needing a different runner. The review showed pytest --noconftest over two
files reproduces the same four subTests in ~70s, under the runner CI already installs. Restate the
cause, and say why --noconftest was rejected as the cheaper instrument — or whether it was considered.

The mechanism itself is proven, not papered over. Independent id() comparison: Engine_g1 is Engine_g2 False, Extractor.__output__ two distinct dicts, issubclass(Engine_g1, EngineBase_g2)
False, and a subclass registered against g1 found in g1's registry but not g2's. The pre-fix text
corroborates it — RegistryError: engine must be an Engine subclass, not <class 'abc.UserOptIn_engines'>. Red-to-green reproduced: FAILED (failures=1, errors=3) → OK, and the full
protocols leg catches #981 pre-fix (failures=1, errors=3, skipped=58) and passes post-fix.
It also caught a trap worth keeping: purge_modules takes an iterable, so purge_modules('pcapkit')
is a silent no-op that iterates single characters.

timeout-minutes: 20 has no real margin, and I confirmed it is the lowest in the file while carrying
the longest serial workload — test 45, integration 45, engine-tests 30, pypcap-parity 45, all
of which get xdist. On an idle machine the reviewer's medium legs came in at 1.22–1.31× the author's
figures, which puts protocols at 17–18.5 min before the 39s install overhead, against a 20-minute
job-level cap. Its contended run hit 1275s outright. Raise it to 35–45 or split protocols, add the
nproc step every other test job has, and print elapsed time per leg — as it stands a timeout leaves
nothing in the log saying how close it was.

The tests/vendor exclusion is a hole, and I have closed the tracking half: #985. The review
confirmed the instance independently (stale VendorRuntimeWarning generation; the module alone passes in
0.179s) and its judgement is that excluding the directory does not quarantine one test — it makes all 14
modules' future ordering defects permanently invisible to the one job built to see them. The YAML comment
claimed the finding was "reported for separate attention rather than silently hidden"; no issue
existed
, so it was hidden in the comment that excluded it. Either fix the one test or cite #985.

corekit's exclusion is fully defensible (5 failures, matching purge_modules's docstring).
integration's is redundant — is_unit_tier already filters it, so the leg would run 5 root modules.
YAML re-derived with yaml.safe_load: top-level 5→5, jobs 7→8, all seven pre-existing jobs
byte-identical on parsed structure.

Four prose defects: :data:LEGS`` does not exist (referenced twice as "below"; the legs are in the
YAML matrix); the 226s figure contradicts the same file's own 847s; const is called the smallest
multi-file directory when `dumpkit` is (2 files / 199 tests / 39s); "two directories are deliberately
absent" when three are.

One structural point I am passing on rather than acting on. The reviewer argues the reconciliation
should move out of tests/conftest.py into runner-neutral tests/_support.py, closing corekit,
vendor and this site at once instead of one setUp at a time — and explicitly would not touch the
library, since __init_subclass__ re-importing its collaborators is correct behaviour. That is a larger
change than #981 asks for; worth its own issue if you want it.

UNVERIFIED by the review: an uncontended protocols timing; actual ubuntu-latest wall time (no run
triggered); the const/foundation/project/toolkit/utilities leg figures.

…d timeout

Problem: #981's job and script blamed pytest-subtests for masking the
ordering defect, four times over, but that plugin is not installed and
current pytest already reports subTest failures on its own -- the real
masking comes from tests/conftest.py's autouse restore_module_table
fixture, which plain unittest never loads. The job's 20-minute timeout
also had no margin: a review pass measured contended protocols runs up
to 1275s, over the cap, and the job carried no `nproc` step the other
jobs have. The tests/vendor exclusion cited a nonexistent tracking
issue, undercounted its excluded directories at two instead of three,
and :data:`LEGS` was referenced twice though it lives in the YAML
matrix, not this module.

Fix: rewrote the four pytest-subtests passages (two per file) to name
the autouse fixture as the real mechanism, and recorded `pytest
--noconftest` as a cheaper alternative that was not evaluated at the
time rather than inventing a reason for the choice after the fact.
Raised timeout-minutes to 45, added the "Report available parallelism"
step, and made the leg runner print wall-clock elapsed time
unconditionally. Cited #985 for the vendor exclusion, named all three
excluded directories, and fixed the LEGS/stats errors.

Evidence: pytest-subtests absent (`pip show` warns not found); a
synthetic subTest probe under this venv's pytest 9.1.1 reports
SUBFAILED entries, not a passing parent; `pytest --noconftest` over
tests/protocols/application/test_http_unit.py then
tests/test_base_class_contract.py on the pre-#981-fix tree reproduced
the same four subTest failures in 68.4s. cli and dumpkit legs still
pass under the edited script (185 and 199 tests, 18.3s/39.2s elapsed).
YAML reparsed: 5 top-level keys, 8 jobs, the other 7 jobs structurally
unchanged.

Refs #981, #985
@JarryShaw

Copy link
Copy Markdown
Owner Author

All four findings applied at 1323de16b, and the --noconftest reproduction is now confirmed three
independent ways.
I verified the pushed diff myself: 2 files, +111/−35, nothing under tests/ touched.

The mechanism claim is corrected rather than merely softened. Both files now name
tests/conftest.py's autouse restore_module_table as the real cause. One pytest-subtests mention
survives in each file, and it should: it is now a refutation — "pytest-subtests is not why:
that plugin is not installed in this project at all". The worker re-derived the whole thing rather than
trusting my brief, and then went further than the review: it built a scratch worktree at the pre-fix
commit and ran pytest --noconftest over the two modules, reproducing the same four subTest failures
(engines, reassembly, traceflow, dumpers) in 68.4s
against the review's ~70s. So the cheaper
instrument is measured, not hypothetical. It is recorded as an unevaluated alternative rather than given
an invented justification, which is what I asked for.

timeout-minutes 20 → 45, with the measured figures in the comment, plus the Report available parallelism step the other jobs carry, and time.monotonic() elapsed printing that fires on failure
too — so a future timeout leaves the margin in the log. YAML re-derived with yaml.safe_load myself:
top-level keys 5→5, jobs 8→8, only unittest-ordering changed by structural hash, the other seven
byte-identical, timeout-minutes == 45, nproc step present.

The vendor exclusion now states its own cost. #985 cited three times, and the comment admits "the
other 13 tests/vendor modules get no unittest-ordering coverage at all, not just the one file #985 is
about". Directory count corrected from two to three, with tests/integration named alongside.

Prose: both :data:LEGS`` references re-pointed at the YAML matrix. The garbled stats sentence dropped
the 226s and the 344 MB figures rather than guessing them — the right call, since neither could be
attributed; const is no longer called the smallest multi-file directory (`dumpkit` is, 2 files / 199
tests, which its own run confirmed at 39.2s). `self.SUITES` lives in `tests/`, which it does not own, so
it correctly left it and said so.

Legs re-run through the edited script with the tree proven: cli 185 tests / 18.3s, dumpkit 199 / 39.2s.

UNVERIFIED: the inherited per-leg figures for const/foundation/project/toolkit/utilities; a
contended protocols run (it was told not to run that leg); and CI-runner parity for the 68.4s figure.

@JarryShaw JarryShaw changed the title ci(tests): add a plain-unittest leg to catch pytest-subtests-masked ordering defects ci(tests): add a plain-unittest leg to catch conftest-masked ordering defects Oct 2, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

NEEDS CHANGES at 1323de16b — opus cross-review, round 2. The central correction holds and is now
proven rather than inferred, but the fix for round 1's finding reintroduced the same class of defect
three times
, and the PR title still carried the claim this PR exists to refute.

The causal claim is now demonstrated, by a control neither earlier round ran. Same pre-fix tree, same
two modules, same pytest — green with tests/conftest.py, four SUBFAILED failures without it:

pre-fix,  --noconftest   -> 4 failed, 67 passed, 404 subtests passed, rc=1   66.30s
post-fix, --noconftest   -> 67 passed, 408 subtests passed, rc=0             66.94s
pre-fix,  WITH conftest  -> 67 passed, 408 subtests passed, rc=0  (green)    66.45s

That third row is what makes restore_module_table the masker as a measured fact. The worker's 68.4s is
corroborated at 3% apart.

I have already corrected the title myself. It read "...to catch pytest-subtests-masked ordering
defects"
— the exact claim the second commit refutes. The repo has squash_merge_commit_title = COMMIT_OR_PR_TITLE, so on a squash merge that would have landed as the permanent subject on main. Now
conftest-masked. Both earlier rounds missed it because they reviewed file prose, not metadata.
0d2a20cc8's own subject still carries it, which only surfaces on a non-squash merge — your call,
since fixing it means rewriting the branch.

Three new false claims, all verified by me:

  1. The elapsed-print comment claims timeout survival it cannot deliver. There is no try/finally
    in the file (grep: 0), and the print sits after runner.run() returns — so a timeout-minutes kill
    prints nothing. The "including when the leg fails" half is true.
  2. The two files contradict each other. The YAML says the integration exclusion "is enforced in the
    script itself rather than only here"; the script's docstring, edited in the same commit, says the
    legs are defined in the workflow "not in this module". I re-derived leg_modules('integration') → (),
    so the leg would be vacuously green — nothing enforces anything. (My first attempt at this measurement
    was wrong: loading the script from /tmp makes its ROOT = parents[1] resolve to /. The reviewer
    was right and I was not.)
  3. An RSS claim whose cross-reference holds no RSS data. "Every leg measured well under a gigabyte of
    peak RSS (see that workflow comment for the full per-leg timings)" — the comment has timings only.
    Round 2 deleted the one concrete figure (344 MB) as unattributable while keeping the broader
    unmeasured claim; the reviewer measured dumpkit at 239.7 MB, making 344 MB for the larger const
    plausible, so the deletion was the over-cautious half of an inconsistent pair.

Confirmed fixed: timeout 45 (adequate even at 2.5× the baseline), nproc step byte-identical to its five
siblings, #985 cited 3×, 13 is right (leg_modules('vendor') = 14, minus the one #985 names), three
excluded directories correct, LEGS gone from both files.

Its judgement on whether the job earns its cost, which I think is the most useful thing here. For
#981 alone the one-liner wins outright — 66s against ~1691s of runner time across 9 legs plus a 204-line
script. What justifies the matrix is the thing currently buried in an exclusion bullet: running it this way
found an unknown second instance of the defect class (#985), which a pinned two-module leg never could.
That should be the argument the comment makes.

And a sharper framing of the exclusion cost: the job's coverage is anti-correlated with where the
defect is known to live — it skips corekit (24 modules) and vendor (14), the only two directories with
documented instances, and the comment accounts for 13 of those 38. "That cost is accepted for now" is
enforced by nothing: no check fires when #985 lands. Its suggestion is vendor in the matrix under
continue-on-error: true, or the re-enable written into #985 as an acceptance criterion.

A worker is on items 1-3. UNVERIFIED by the review: protocols' 847s and 1275s figures and the 1.22-1.31×
factor (not run, per my instruction); peak RSS for any leg but dumpkit; the ~39s CI setup figure.

…ering leg (#981)

Round 2 fixed a false `pytest-subtests` attribution but introduced three new
claims of the same class -- prose asserting more than the code or the
cross-reference delivers. All three are comment/docstring only.

* The elapsed-time print claimed a future timeout would "leave behind a
  measured number". It could not: there was no `try`/`finally` in the file, so
  a step killed at `timeout-minutes` printed nothing. The print now runs in a
  `finally`, and the comment claims only what that buys -- a failing leg, and
  an unwind such as the `KeyboardInterrupt` CPython raises for SIGINT. SIGKILL
  runs no Python and SIGTERM has no default handler, so the comment says
  outright that an Actions timeout is still not covered.
* The workflow's `tests/integration` bullet said the exclusion was "enforced in
  the script itself", while the script's docstring said the legs are defined in
  the workflow and not in the module. The script enforces nothing:
  `is_unit_tier()` rejects all 12 of that directory's `test_*.py` files, so
  `leg_modules('integration')` returns an empty tuple and the leg would run the
  root modules alone. The bullet now says that, keeping the two true reasons.
* The "every leg measured well under a gigabyte of peak RSS" claim cited a
  workflow comment that holds wall times and no RSS at all. Replaced in both
  files with figures actually measured here via
  `resource.getrusage(RUSAGE_CHILDREN)`: cli 152 MiB, dumpkit 237 MiB,
  interface 305 MiB, with no figure claimed for the larger legs.

Also reconciled two true-but-inconsistent passages: both files opened on "three
test modules" while their own `--noconftest` paragraph said two, and `dumpkit`
was called "the smallest of the multi-file directories" when `interface` has
the same two unit-tier modules, 201 tests against 199, and runs ~2s faster.

Behaviour unchanged. `yaml.safe_load` gives the same 5 top-level keys, 8 jobs,
`unittest-ordering.timeout-minutes: 45`, and a byte-identical per-job digest for
all 8 jobs. `py_compile` clean; cli (185 tests, 18.3s) and dumpkit (199 tests,
39.2s) legs both pass against the edited script.
@JarryShaw

Copy link
Copy Markdown
Owner Author

All three round-2 items applied at c688633bc, and the one I flagged as "preferred" was taken in both
cases. Verified myself: 2 files, +76/−51, and the parsed YAML document is byte-identical to
1323de16b's — so the workflow change is provably comment-only, top-level keys 5, jobs 8,
timeout-minutes still 45.

The elapsed-print comment no longer claims what it cannot do, and the control flow earns the narrower
claim.
It is now in a finally, and the comment states explicitly that SIGKILL never runs Python and
SIGTERM has no default handler, so a timeout-minutes expiry is not covered — a leg killed at the
cap still prints nothing. What it buys is the number from a run that finished close to the cap. Three
details that make it correct rather than merely present: result is pre-initialised to None, so an
interrupt cannot raise NameError in the finally and mask the real exception; build_suite() is left
outside the try, so a bad directory exits with no bogus elapsed line; and start is still set before
build_suite, preserving the number's old meaning. All three were exercised, including a patched
KeyboardInterrupt that printed interrupted before a result was available, 0.7s elapsed and then
propagated.

The cross-file contradiction is resolved, with a precision I had got slightly wrong. I called the
integration leg "vacuously green"; it is not quite — build_suite still appends the root modules, so the
leg would run ~185 root tests and pass while covering none of tests/integration. The comment now says
that. leg_modules('integration') → () reproduced, all 12 of its test_*.py files failing
is_unit_tier.

RSS is now measured rather than asserted, via resource.getrusage(RUSAGE_CHILDREN): cli 152 MiB,
dumpkit 237 MiB, interface 305 MiB. The dumpkit figure independently corroborates the reviewer's
239.7 MB, which confirms round 2's deletion of the 344 MB const figure was the over-cautious half of an
inconsistent pair. The claim is scoped to the three legs measured and says plainly that no figure was taken
for the larger ones. It also caught itself rounding — wrote "two orders of magnitude", then corrected to
~90× because 29 GB / 305 MiB is 91×.

One change beyond my brief, and it was the right call. The YAML carried the same unmeasured RSS
claim; fixing only the script would have re-created exactly the cross-file contradiction item 2 exists to
remove. It narrowed both and flagged it rather than doing it quietly.

Both "three modules"/"two modules" contradictions fixed — it found the YAML carried it too, which I had
only spotted in the docstring. The dumpkit superlative is dropped, and it turned up two facts I did not
have: tests/dumpkit holds three test_*.py files on disk, so "at two files" was wrong on the raw
axis as well, and on raw file count dumpkit is larger than interface.

One defect it found and correctly left alone: util/run_unittest_leg.py:68 cross-references
:data:ROOT_MODULES, which does not exist — the real names are `root_modules()` and `_EXCLUDED_ROOT_MODULES`. I confirmed it. Same class as the `:data:`LEGS targets round 1 caught, and it
fails silently because the docs build sets neither -n nor -W. It is now fixing that, plus sweeping the
file for any other dangling target.

UNVERIFIED by it: that two modules suffice to reproduce #981 (it relied on my measurement, since proving it
needs tests/, which it does not own); peak RSS for the six larger legs, protocols included; and the
inherited wall-time table beyond cli/dumpkit/interface, all three of which it reproduced within a
second.

…#981)

`util/run_unittest_leg.py`'s docstring referenced `:data:`ROOT_MODULES``, a name
the module does not define -- the real names are the `root_modules()` function
and the `_EXCLUDED_ROOT_MODULES` frozenset. Same defect class as the
`:data:`LEGS`` references round 1 caught: a dangling Sphinx target renders as
plain text and warns about nothing, since the docs build sets neither `-n` nor
`-W` and does not take `util/` in at all.

Re-pointed at `:func:`root_modules``, which is what the sentence means -- the
set of modules that run every invocation. That is also more precise than the old
target implied, since `root_modules()` excludes `tests.test_tier_guard_xdist`
and a constant listing every module would not have.

Fixing the reference exposed a worse claim in the same sentence: it had the
ordering backwards. It read "ahead of the directory's own modules -- not
interleaved, and not after", while `build_suite()` queues `leg_modules()` first
and `root_modules()` second. Measured on both the `dumpkit` and `cli` legs: the
root-level modules occupy the last five positions, after the directory's own.
Three other places in the tree already said so correctly -- `build_suite()`'s
own docstring, the `_EXCLUDED_ROOT_MODULES` comment, and the workflow's
"directory first and root second" -- as did the next sentence but one of this
very paragraph, so the docstring was contradicting itself. Now reads "after the
directory's own modules -- not before, and not interleaved", which is the order
#981 reproduces in and the only order that reproduces it.

Audited every remaining cross-reference in the file by resolving each target:
33 role occurrences, 25 of them resolvable roles, all 25 now resolve. Comment
and docstring only; the workflow file is untouched this round. `py_compile`
clean, and the `cli` leg still passes (185 tests, 18.3s).
@JarryShaw

Copy link
Copy Markdown
Owner Author

The dangling cross-reference turned out to be hiding a worse defect: the docstring stated the leg's
module order backwards.
3306b7e63, one file, +12/−13, workflow untouched.

The ordering claim. Line 68 read "Every ROOT_MODULES module runs in every invocation, ahead of
the directory's own modules -- not interleaved, and not after."
build_suite() queues
leg_modules(directory) first and root_modules() second — I read it myself. So root modules run
after, and the sentence asserted the exact opposite.

It was self-contradicting inside its own paragraph: two sentences later the same text says "Running the
directory first and the root modules second is what gives any purging module in that directory a chance to
desync a root module"
— which is correct. Three other places in the tree already had it right too:
build_suite()'s own docstring, the _EXCLUDED_ROOT_MODULES comment, and the workflow. Line 68 was the
lone outlier, and it was wrong about the one thing this script exists for — #981 reproduces in exactly
one order, so a reader deciding whether the leg order is safe to change would have been actively misled. A
dangling role renders as plain text; a reversed ordering claim does damage.

It only surfaced because the fix could not be applied without reading what the sentence asserted. Worth
noting against the "minimum edit" instinct: a mechanical substitution would have left it.

The reference itself is now :func:root_modules``, which is also more accurate than the old target
implied — root_modules() excludes `tests.test_tier_guard_xdist`, where a constant named `ROOT_MODULES`
reads as listing all of them. House style checked rather than assumed: local names take a bare target,
functions take `:func:`.

Dangling-target audit: none beyond that one. It resolved every role rather than eyeballing them —
33 occurrences (:mod: 16, :program: 5, :data: 4, :func: 3, :file: 3, :class: 2), of which 25
have a target to resolve. 1 dangling before, 0 after. All 16 :mod: targets import, including
tests.protocols.application.test_http_unit, and tests.conftest.restore_module_table resolves as a
FixtureFunctionDefinition because pytest wraps it.

One contextual finding I confirmed: util/run_unittest_leg.py is not in the Sphinx build at all — no
reference to it anywhere under docs/. So these roles never render, and with -n/-W unset a dangling
target in this file is catchable by no automated means in this repo, only by reading. That is a gap
worth tracking rather than fixing here; say if you want an issue for a resolver check.

cli leg against the final tree: 185 tests, 0 failures, 18.2s. UNVERIFIED, unchanged: that two modules
suffice to reproduce #981 (unprovable without tests/, which that worker does not own), and the inherited
29 GB and wall-time figures.

@JarryShaw

Copy link
Copy Markdown
Owner Author

NEEDS CHANGES at 3306b7e63 — sonnet, round 3. One finding, and it is the fourth instance of this
pull request's recurring defect: the disclaimer round 2 added is itself a false mechanism claim.

The finally comment says SIGKILL runs no Python and SIGTERM has no default handler, therefore "a leg
killed at the cap prints nothing."
Neither of those signals is the one that arrives first. I verified
both halves from primary sources rather than taking the review's word.

From actions/runner, src/Runner.Sdk/ProcessInvoker.cs on main:

CancelAndKillProcessTree -> SendSIGINT(_sigintTimeout)      // FIRST
                         -> SendSIGTERM(_sigtermTimeout)
                         -> Process.Kill()                   // SIGKILL, last
_sigintTimeout  = 7500ms      _sigtermTimeout = 2500ms

And unittest/case.py's _Outcome.testPartExecutor carries an explicit except KeyboardInterrupt: raise.
Measured here: a KeyboardInterrupt raised in a test propagates out of TextTestRunner.run() and the
caller's finally runs with result is None, in under a millisecond. So a timed-out leg normally does
print
, inside the 7.5-second grace — the opposite of the claim. The real gap is narrower: a test blocked
in a C extension defers signal delivery, so the handler may not run before the SIGTERM and SIGKILL that
follow.

Why this one survived three rounds of careful verification, which is the part worth keeping. Rounds 1-3
were all settled by re-deriving something in this repo — run the command, read build_suite(), resolve
the Sphinx target. This claim is about GitHub's runner, and no amount of local measurement touches it.
A hedge is not safer than an assertion: round 2's careful-sounding SIGKILL/SIGTERM wording read as rigour
precisely because it named real signal semantics, while reaching a false conclusion about which signal
arrives. I have briefed the fix to carry the review's own caveat rather than overclaim — it verified the
generic step-cancellation ladder, not specifically the job-level timeout-minutes path.

Everything else checks out, including two bullets nobody had ever verified. The corekit exclusion was
re-run, not quote-matched: Ran 400 tests ... FAILED (failures=5, skipped=16), all five the sentinel-identity
assertions. The vendor bullet's method name and assertWarnsRegex call exist verbatim, and #985 is open as
cited. leg_modules('integration') returns () with all 12 files failing is_unit_tier. RSS reproduced to
within ~2 MiB on all three legs (cli 151.2, dumpkit 235.2, interface 304.7 MiB), the ~90× figure holds at
90.8× decimal, the ordering sentence is now correct against build_suite(), const is 299 + 179 = 478
exactly, and all five timeout-minutes cross-references match.

One unclaimed gap it names fairly: build_suite() runs before the try, so a bad directory argument
exits with no elapsed line. The comment never claims otherwise, so that is a fact to know rather than a
defect.

On the exclusion's missing trigger I agree it is a follow-up, not a blocker. vendor is excluded for a
live bug rather than an accepted limitation, and nothing prompts anyone to re-add it when #986 merges —
which is now review: good-to-go. That belongs on #985 as an acceptance criterion.

UNVERIFIED by it: whether the job-timeout path funnels through that same ProcessInvoker routine; the
protocols 847s and const 268s runs; a real sphinx-build -W (it resolved targets by reading); and the
pre-fix --noconftest timing.

#981)

Problem: round 2's `finally`-block comment in util/run_unittest_leg.py drew an
accurate pair of Python-signal facts (SIGKILL runs no Python code; SIGTERM has
no default handler) into a false conclusion -- that a GitHub Actions
`timeout-minutes` expiry is therefore not covered and a leg killed at the cap
prints nothing. Neither signal is what the runner sends first.

Fix: `actions/runner`'s `src/Runner.Sdk/ProcessInvoker.cs` sends SIGINT with a
7.5s grace period before SIGTERM (2.5s) and only then SIGKILL
(`CancelAndKillProcessTree`, `_sigintTimeout`/`_sigtermTimeout`); `run:` steps
select that ladder by passing `killProcessOnCancel: false`
(`Handlers/ScriptHandler.cs`). Traced the job-level path too, not just the
generic step-cancellation one the previous round verified: the backend's
cancellation reaches the worker as a `CancelRequest`
(`Runner.Worker/Worker.cs`), cancelling the token `JobRunner`/`StepsRunner`
thread into the step's own `ExecutionContext.CancellationToken` -- the same
token `ScriptHandler.cs` hands `ProcessInvoker.ExecuteAsync`. So a
`timeout-minutes` expiry normally does still print, via the
`KeyboardInterrupt` CPython raises for SIGINT, which
`unittest.case._Outcome.testPartExecutor` re-raises rather than swallowing.
Narrowed the real gap to a test blocked inside a C extension, which can defer
signal delivery past SIGTERM/SIGKILL. No mirror of this claim exists in
.github/workflows/unit-tests.yml -- round 2's diff there touched three other
passages, not this one -- so that file is unchanged this round.

Evidence: unittest.case._Outcome.testPartExecutor in this venv (Python 3.14.7)
contains `except KeyboardInterrupt: raise`, verbatim. A real SIGINT sent to a
child running the `cli` leg 3s in printed "tests/cli + 5 root module(s):
interrupted before a result was available, 2.9s elapsed" and exited via signal
2, 0.148s after the signal. `py_compile` clean; `cli` leg still passes (185
tests, 18.4s elapsed) against the edited script.

Refs #981
@JarryShaw

Copy link
Copy Markdown
Owner Author

Round 3's finding is fixed at fa6216ee7, and the fix also caught a wrong claim in my own brief — which is
what stopped it becoming the fifth.

I told it the SIGKILL/SIGTERM passage was mirrored in the workflow comment. It is not, and never was. It
grepped for every spelling and got zero matches, then diffed round 2's commit to show that hunk touched three
other passages. I verified: zero matches at c688633bc, 3306b7e63 and fa6216ee7 alike. Had it
taken my word and written matching prose into the workflow, that would have been a new false claim created by
the fix for a false claim — the exact loop this pull request has been stuck in. It declined and said why.
The diff is one file, +23/−8.

It also closed the caveat I told it to keep, properly, by tracing the job-timeout path to primary source:

Runner.Worker/Worker.cs          CancelRequest -> jobRequestCancellationToken.Cancel()
Runner.Worker/JobRunner.cs       -> jobContext.CancellationToken
Runner.Worker/StepsRunner.cs     -> step's ExecutionContext.CancellationToken
Handlers/ScriptHandler.cs        -> ExecuteAsync(..., killProcessOnCancel: false)
Runner.Sdk/ProcessInvoker.cs     CancelAndKillProcessTree -> SIGINT 7500ms -> SIGTERM 2500ms -> Kill()

killProcessOnCancel: false is what selects the ladder rather than an immediate kill for a run: step. So a
job-level timeout is the same path, not a separate teardown — the caveat was dischargeable and is now gone
rather than hedged.

And it demonstrated the corrected claim instead of only asserting it. It started the cli leg, sent a
real SIGINT 3s in, and got tests/cli + 5 root module(s): interrupted before a result was available, 2.9s elapsed, with the process exiting on signal 2 just 0.148s later. That is the first time anything in this
pull request's prose about signal behaviour has been backed by an observation rather than an inference.

The comment now names its sources inline — ProcessInvoker.cs, Worker.cs, ScriptHandler.cs — and narrows
the genuine gap to a test blocked inside a C extension, keeping "SIGKILL runs no Python code at all" because
that part was always true. Workflow untouched; I re-derived the YAML as unchanged.

One open thread it names honestly, and I would rather leave it stated than closed by assertion: the
Actions backend's own decision to emit the cancellation on timeout is closed-source and not in
actions/runner, so the trace starts at the CancelRequest the worker receives. It also did not pin the
runner release against the main branch it read. Neither changes the conclusion, and both are exactly the
kind of limit that this pull request's earlier rounds asserted past.

cli leg 185 tests / 18.4s at this head, pcapkit.__file__ proven inside its worktree.

@JarryShaw

Copy link
Copy Markdown
Owner Author

NEEDS CHANGES at fa6216ee7 — opus, round 4. This is the fifth wrong claim, and it is wrong one hop
past where the citations stop. I verified it myself.

Every round, mine included, reasoned about signal sending and signal handling. None asked which process
is signalled.

  • ProcessInvoker.SendSignal is kill(_proc.Id, (int)signal) — one positive pid. I grepped the file for
    killpg, setsid, getpgid and "process group": zero hits on the Unix path.
  • _proc is the step's shell. The step is run: python util/run_unittest_leg.py ${{ matrix.leg }} with
    no shell:, so Python is bash's child.
  • Non-interactive bash does not forward SIGINT to a foreground child. My own measurement:
    /usr/bin/bash -e step.sh running a Python child, kill -INT <bash pid> — the Python process was still
    alive 2.5s later.
    At a 45-minute cap the leg has minutes left, so both the 7.5s and 2.5s windows expire
    with it running, and the eventual SIGKILL goes to bash.

The review reproduced that end to end: at t+3.0s it sent SIGINT to bash exactly as SendSignal does, and
Python ignored it and ran the leg to completion, printing a full tally rather than the interrupted one.

So round 2's "prints nothing" was right for the wrong reason, and round 4 replaced it with a densely-cited
wrong conclusion. The retained "real gap is narrower: a test blocked inside a C extension" names the wrong
gap too — the process never receives the signal at all, whatever it is executing.

What the citations did and did not buy. Every path in the five-file chain checks out, and the boolean is
not inverted — killProcessOnCancel: false genuinely selects the ladder. Two were misdescribed: it hands to
StepHost.ExecuteAsync, which forwards to ProcessInvoker (one unnamed hop); and StepsRunner does not
thread the token
— a step's CTS is fresh and unlinked, and StepsRunner registers a callback that
re-evaluates the step's if and cancels only when the re-test fails. So a step with if: always() would not
be cancelled at all, which the "thread the token" phrasing hides.

I am having the paragraph deleted, not corrected a fifth time, and I think that is clearly right rather
than a concession. The finally needs no CI justification: "in a finally so a failing leg and a
KeyboardInterrupt-interrupted leg still report their elapsed time" is the whole defensible claim, is two
lines, and cannot rot. Twenty-five comment lines pinning five paths, three method names and two private
field values from a third-party repo's moving main — no sha, no date — will drift silently, and none of it
prevented the error. The rigour was concentrated in the hops that were checked.

Its verdict on the artefact, which I agree with: merge after the paragraph is deleted, not after a fifth
rewrite.

One residual it spotted: the text says the line buys "the number from a run that finished … close to the
cap" — a run that finished prints by the normal path, finally or not. Going with the rest.

UNVERIFIED: actions/runner read at unpinned main, and which runner version hosted ubuntu-latest actually
runs — neither the comment nor the review establishes that; whether an orphaned Python's stdout stays captured
after the step concludes; and real job-timeout behaviour on a hosted runner, which neither of us triggered.

#981)

* The comment on `main`'s `finally` block has been wrong four times running,
  each fix introducing the next error: `pytest-subtests` masking the defect
  (not installed), the print surviving a kill (no `try`/`finally` existed
  yet), a timed-out leg printing nothing, and now a timeout expiry normally
  reaching the line.
* The fourth is wrong too. Every round reasoned about which signal is sent
  and how Python handles it; none asked which process is signalled.
  `ProcessInvoker.SendSignal` is `kill()` on one positive pid -- no
  `killpg`/`setsid` on the Unix path -- and that pid is the step's shell,
  since the step is a bare `run:` and Python is bash's child.
  Non-interactive bash does not forward SIGINT to a foreground child
  (measured: child alive 2.5s after `kill -INT` of the bash pid), so both
  grace windows expire with the leg running and SIGKILL lands on bash.
* So delete the paragraph rather than rewrite it a fifth time: a CI timeout
  says nothing about whether this line prints, and the claim was never
  load-bearing. What remains needs no CI justification -- the `finally` is
  there so a failing leg, and one unwinding on an exception, still report
  elapsed time. The residual "number from a run that finished" clause goes
  with it; a finished run prints by the normal path regardless.

Comment-only: tokenising before and after, excluding comments and
docstrings, gives an identical 552-token sequence. `py_compile` clean,
workflow file byte-identical, `cli` leg still prints its elapsed line.
@JarryShaw JarryShaw added review: good-to-go Cross-review at the current head says ready; CI state is separate and removed review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Oct 2, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

GOOD TO GO at 69fd563a8. The paragraph is deleted, not corrected a fifth time, and the verdict rests on
round 4's own explicit condition — "merge after the CI-timeout paragraph is deleted rather than rewritten" —
plus my own proof that nothing but comments changed.

25 comment lines became 5, and the surviving sentence claims only what the code does:

# In a ``finally`` so a leg that *fails*, and a leg that unwinds on an
# exception -- including the ``KeyboardInterrupt`` CPython's default
# SIGINT handler raises, which :class:`unittest.case._Outcome`'s
# ``testPartExecutor`` re-raises rather than swallowing -- still
# reports its elapsed time.

Gone: the timeout-minutes claim, every actions/runner citation, the signal ladder, the C-extension "real
gap" sentence, and the "from a run that finished" clause that said nothing. The unittest-ordering
cross-reference went with it; the docstring's two other references to that job were correctly left, since
neither supported the timeout claim.

I re-derived the comment-only proof independently rather than taking the worker's: tokenising the file with
comments and docstrings stripped gives 552 code tokens before and 552 after, identical sequence and hash.
One file changed; the workflow is untouched. result = None, build_suite() outside the try, the
try/finally and the tally expression all stand. cli leg 185 tests / 17.8s with the tree proven.

It declined to attempt a fifth rewrite and gave a better reason than mine. My argument was that four
corrections had each produced a new error. Its argument is stronger: the paragraph's subject — whether a
timeout expiry reaches a Python print — is runner, shell and signal plumbing that the code does not depend
on and no test in this repo can pin.
A comment no local check can keep honest is a liability whichever of
the five wordings is nearest correct. That is the general principle, and it is why deleting beat rewriting.

One thing it was properly honest about: it did not reproduce my kill -INT measurement or read
ProcessInvoker.cs itself, and it said so — while noting the deletion does not rest on them. That is right.
The case for removal holds whichever way the plumbing behaves.

Labelled review: good-to-go. Unpublished and untouched otherwise — yours to merge, and per your ruling a
squash merge puts the corrected title on main rather than 0d2a20cc8's stale subject.

Still open on this thread, neither a blocker: tests/vendor's exclusion has no re-enable trigger tied to
#986 merging, which belongs on #985 as an acceptance criterion; and no test asserts a non-BaseError is
trimmed by the real tbtrim hook.

@JarryShaw
JarryShaw merged commit 32bcfba into main Oct 2, 2026
72 checks passed
@JarryShaw
JarryShaw deleted the fix/981-registration-gate-ordering branch October 2, 2026 04:06
@JarryShaw JarryShaw removed the review: good-to-go Cross-review at the current head says ready; CI state is separate label Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci Pull requests that change CI or workflow configuration (ci: subject prefix) test Pull requests that add or correct tests (test: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant