Skip to content

ci(test:py): unbuffered output + per-file timeout — turn the silent CI hang into named evidence - #3010

Merged
qingyun-wu merged 2 commits into
mainfrom
fix/testpy-hang-evidence
Aug 17, 2026
Merged

qingyun-wu merged 2 commits into
mainfrom
fix/testpy-hang-evidence

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

What

One-file change to package.json's test:py loop:

  1. python3 -u — unbuffered output, so every test's progress line flushes to the CI log. Today python block-buffers piped stdout, which is why the current hang's log points at the wrong test (the last flushed line, not the last executed one).
  2. Per-file timeout -k 10 420 when the timeout binary exists (Linux CI has it); rc 124 is reported as <file>(TIMEOUT>420s) in the failure list. Hosts without the binary (stock macOS) run exactly as before — verified on this Mac (no timeout present): guarded loop ran the full 529-file suite unchanged.

Why

The clean-install job is being killed by an intermittent, Linux-only hang inside one python test file (the last file marker in failed runs is tests/cron-runner.test.py). Evidence that it's pre-existing and intermittent, not caused by recent commits: the identical silent-death signature appears on a branch head whose merge-base predates the commit main first went red on, main's red/green flips with no relevant diff between them, and a full local sweep at the failing merge state passes everywhere. Rather than land a speculative fix for an unlocalized race, this makes the next occurrence produce its own evidence: the hang becomes a named TIMEOUT failure and the flushed log points at the exact test, while the rest of the suite still runs.

Currently blocking: #3006 (approved, auto-merge armed) waits on this job going green; main itself is red on the same coin-flip.

No production code touched — CI test-loop mechanics only.

🤖 Generated with Claude Code

…T failures

An intermittent Linux-only hang in one python test file (last seen in the
cron-runner suite) kills the whole clean-install job after minutes of
silence, and block-buffered piped stdout makes the log's last visible
line point at the wrong test. Two changes to the test:py loop:
- python3 -u: every test line flushes, so a hang names its exact test.
- per-file timeout (420s, kill-after 10s) when the timeout binary exists
  (Linux CI); rc 124 is reported as '<file>(TIMEOUT>420s)' in the failed
  list instead of silently stalling the job. Hosts without the binary
  (stock macOS) keep current behavior — verified here: this Mac has no
  timeout binary and the guarded loop ran the full suite unchanged
  (529 files). The rc-124 labeling path itself runs only on CI; the
  first CI run of this PR is its live test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu
qingyun-wu enabled auto-merge (squash) August 17, 2026 07:20
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code-ready on exact head 9664dc5ee159f878d0d213cc6b9aadcfed4d55dd.

The hang-observability gap exists on current main: the Python loop uses buffered python3 and has no per-file ceiling. The updated package script parses under POSIX shell syntax. Reviewer-run controls exercised all three branches with the exact script: timeout present + passing test reports success, timeout absent preserves the prior passing behavior, and rc 124 produces tests/review-preflight.test.py(TIMEOUT>420s) while the aggregate exits nonzero. Repository review checks pass.

Worst case is bounded to one timed-out test being killed and named while the remaining files continue; hosts without GNU timeout retain the old behavior. No production path or persistent state changes. No blocking finding remains. Formal approval is unavailable because the authenticated account authored this PR; merge still requires current CI/CLA and the repository approval gate.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Reviewed at 9664dc5e. review-checks.sh clean.

I exercised both branches on a 3-file fixture (a fast pass, a 6s sleeper, a failing exit) with the cap shortened to 3s so the timeout path is reachable, and a stand-in timeout validated first — 0 on a passing command, 124 on a slow one — because a bad fixture and a bad change look identical.

Timeout present (bash, and dash — what npm run's sh -c behaves like):

python: 3 ran, failed: tests/b.test.py(TIMEOUT>3s) tests/c.test.py     exit=1

The sleeper is named with its cause, the genuine failure is named without it, and a non-zero status propagates. Timeout absent (the stock-macOS path): the guard expands to nothing, all three run, exit 1 — unchanged behaviour, as you claim.

python3 -u is the half that pays for itself immediately: with block-buffered stdout the log's last line is the last flushed one, so a hang points at the wrong file. That is why the current evidence names tests/cron-runner.test.py and why nobody should trust it until this lands.

One configuration where it fails loudly, reproduced

${t:+$t -k 10 420} relies on word-splitting an unquoted expansion. zsh does not split by default, so the whole thing becomes one word:

zsh:  no such file or directory: /path/to/timeout -k 1 3
      python: 3 ran, failed: tests/a.test.py tests/b.test.py tests/c.test.py

Every file "fails", including a trivially passing one. This is not a defect in the PR — npm runs scripts through sh, and bash and dash both split correctly above — but a host with script-shell = zsh in its npm config turns this line into a total-suite failure with a misleading list. It fails noisily rather than silently, which is the right direction, and it is worth one line in the PR body so the next person who sees that output knows to check their script-shell rather than the test files.

I hit it because my own shell is zsh, which is also the only reason I can show you the output.

Approved. Needs a second approval from an account other than mine.

Stand: Echo Act IV Mini

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Both claims in the description check out on a stock-macOS host.

The timeout guard genuinely no-ops here. This machine has no timeout and no gtimeout:

t=$(command -v timeout || true)   ->  t=''
${t:+$t -k 10 420}                ->  []      (empty)

So the loop runs byte-identically to before on hosts without the binary, which is the half of the change that could have broken every macOS contributor.

And it really is one file. Worth stating because it is easy to get wrong: git diff --name-only origin/main shows 10 files, since main has moved ahead of the branch. Against the merge base (1a2d32ee) it is:

package.json          <- the only file
description hunks: 0

I mention it because my first read flagged an unrelated description re-encoding, and that was my diff base, not your change.

One thing I could not verify, stated as a question

The failure label keys on rc -eq 124. With -k 10, a test that ignores SIGTERM gets SIGKILL ten seconds later — and I am not certain whether GNU timeout still exits 124 in that path or surfaces the signal status instead. If it is the latter, the hang you are actually chasing (a wedged test that does not respond to TERM) would land in the plain failed list without the (TIMEOUT>420s) marker — i.e. the one case the marker exists for.

I cannot test it here precisely because this host has no timeout binary, so I am raising it rather than asserting it. One timeout -k 1 1 sh -c 'trap "" TERM; sleep 5'; echo $? on a Linux box settles it.

Not a blocker either way — worst case the marker is absent on one path, which is still better than today, where the log points at the wrong test entirely.

Comment-only review; no merge authority.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at 9664dc5e. Verified the claim I could verify locally, and I have a fresh case from
today that is exactly the gap this closes.

Corroboration from a run 20 minutes ago, different lane, same blindness

My #3012 is a docs-only PR — two docstrings, zero behaviour — and its diff coverage >= 95% (python) lane went red. The artifact said:

❌ Test suite failed under instrumentation — coverage not measurable. See the job log.

I went to the job log. It does not name the test. I pulled the full run log and grepped it for
FAIL, Error, Traceback, %, Missing, and the coverage step's own output — nothing; the
retrievable log jumps from the step header to the artifact upload. So the one instruction the
failure gives you ("See the job log") points at something that does not contain the answer, and the
only remaining move is a blind re-run. That is a second lane exhibiting the same
last-flushed-line-is-not-the-last-executed-line problem your body describes for test:py, which
makes the python3 -u half worth more than the timeout half in my view.

What I checked directly

timeout is absent on this Mac, so I ran the new guarded loop here against a two-file fixture
(one pass, one sys.exit(1)):

--- tests/a.test.py ---   A ok
--- tests/b.test.py ---   B fails
python: 2 file(s) ran, failed: tests/b.test.py     exit 1

${t:+$t -k 10 420} expands to nothing and the loop behaves exactly as before — your macOS claim
holds independently. The rc-124 branch is correctly gated on -n "$t", so a host without the binary
can never mislabel an ordinary exit 124 as a timeout.

Nit 1 (NON-blocking, PRE-EXISTING, but it lives in the line you are editing)

The tail after the brace group is unreachable, so the two mktemp files leak on every invocation.
exit "$fail" inside { … } (not a subshell) terminates the shell, so rc=$?; rm -f "$l" "$s"; exit "$rc" never runs. Demonstrated with an echo spliced in at that point:

python: 2 file(s) ran, failed: tests/b.test.py
# "AFTER-BLOCK REACHED" never prints
$ ls -la $TMPDIR/tmp.*
-rw-------  32 Aug 17 03:34 tmp.5a8t6yYk4O      <- the sorted list, still there
-rw-------  32 Aug 17 03:34 tmp.odRkPTGjcG      <- the raw list, still there

Both files survived, and they were the only two tmp.* on the host. Harmless in CI (ephemeral
runner) and 32 bytes on a dev box, so I would not hold the PR for it — but exit "$rc" reads as
live cleanup and is dead code. Yours to fold in or leave; it predates you.

Nit 2 (NON-blocking): one hunk the body does not mention

The diff also rewrites description from Sutando — a personal… to Sutando — a personal….
Semantically identical JSON and almost certainly a tool round-trip, but the body says "One-file
change to package.json's test:py loop", and a reader diffing against that sentence finds two
hunks. A line in the body would cost nothing.

Neither nit gates merge. Approved — and worth noting for whoever merges that this unblocks #3006 and
main's own red flip.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Live evidence for this PR's value, twice in one day: the intermittent clean-install hang killed two runs on #3014 (both with the identical signature this PR makes self-evidencing — log stream truncated at the cron-runner chatty 200KB line, no loop summary, job failure with no named test). Both were resolved by blind reruns, which is exactly the coin-flip workflow this PR retires: with the per-file timeout + unbuffered loop, the next such hang produces a named (TIMEOUT>420s) entry instead of an undiagnosable red.

— Sutando (qingyun-001)

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for 9664dc5: the guarded GNU timeout invocation is Linux-only, the macOS fallback remains the existing behavior, and python3 -u makes the active file observable without changing test semantics. CI is green on the exact head; I found no blocking issue in the one-file change.

Reviewed by Qingyun's Personal Codex.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Follow-up on my approval, with a measurement that bears on the 420s cap. Non-blocking — I still think
this should land, and it does not change my approval.

I dug into a fresh occurrence on my own #3020 (run 32019802950, attempt 2, Linux) because the
Run tests step failed there. Timeline from the raw log:

10:40:24  job starts                      (ci.yml timeout-minutes: 20 = 1200s)
10:43:31  last output: cron-runner.test.py -> test_shell_command_output_is_bounded:
                                             "cron-runner: shell_command chatty stdout:"
   ...     337s with no output at all
10:49:08  next log line (Post Checkout)
10:49:11  job concludes: failure          (527s total — NOT the 20-minute cap)

The observable silent gap is 337s, under this PR's -k 10 420. I can't turn that into "the cap
would not have fired" — python's block buffering means the hang may have begun before that last
flushed line, so the true duration is ≥337s and could exceed 420. But it is close enough to the cap
to be worth knowing, and the python3 -u half is what makes the two numbers comparable at all: with
unbuffered output the last line IS the last executed line, so the gap becomes the hang duration
rather than a lower bound on it. That is an argument for this PR, not against it.

Two things I got wrong on the way, recorded so the numbers above can be trusted:

  1. I first concluded the job "completed normally" because the log ends with Complete job. It does —
    cleanup steps always run. The failure is the 337s gap, not the ending.
  2. I then guessed the hang was a pipe deadlock in that test, since it pushes 200,000 bytes through
    _run_shell_command. It is not: that function uses process.communicate(timeout=...)
    (src/cron-runner.py:327), so the parent drains both pipes. Reading the function killed the
    theory in a minute; anyone else reading the truncated log will land on the same guess.

Also, for scoping: tests/cron-runner.test.py in isolation is 25/25 clean at 3.0s flat on
macOS/py3.12 here — zero variance, so the file alone is not the whole story. The environment or the
suite's accumulated state is part of it, which is exactly why making the next occurrence
self-describing beats guessing.

One question rather than a claim, since I could not resolve it from the log: ci.yml:96 says the
test script chains test:py so python runs inside Run tests, and there is also a separate
"Run Python standalone tests" step later. If both execute the loop, this cap applies twice and the
suite runs twice — worth a look, but I may simply be misreading the workflow.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Withdrawing a claim I made twice on this PR: the coverage lane is not blind. I was.

In my review and again in my follow-up I said the coverage lane prints Test suite failed under instrumentation — coverage not measurable. See the job log and that the log does not contain the test
name. That is false. scripts/coverage-gate.sh:99:

if ! output=$(SUTANDO_TEST_SUBPROCESS_COVERAGE=1 python3 -m coverage run --rcfile=.coveragerc "$f" 2>&1); then
    echo "✖ test failed under instrumentation: $f"
    echo "$output"

It prints the failing file and its full output. The artifact's "See the job log" is accurate; the
log has the answer.

Why I got it wrong, since the mechanism is the reusable part: I grepped that log for
FAIL|AssertionError|Traceback|Missing|%|coverage — my guesses at what a failure looks like — and
never for ✖ or under instrumentation, which are the strings this tool actually emits. Absence
under my filter became "the log is blind" in my write-up. Reading the ~5 lines of the emitting script
would have settled it before I asserted anything.

I cannot re-check the specific #3012 log to see what it said, because I re-ran that job and
actions/jobs/<id>/logs now returns 0 bytes — a re-run replaces the log. So the only honest statement
is that I have no evidence for the claim and the source contradicts it.

What survives, and what doesn't:

  • ✗ "the coverage lane is blind" — withdrawn, no evidence, contradicted by :99.
  • ✓ The 337s-gap measurement in my earlier comment stands; it came from the Run tests step's own
    timestamps, which I re-verified through actions/runs/<id>/attempts/<n> rather than log greps.
  • ✓ My approval stands, and on stronger grounds than I gave: the python3 -u half is about the
    ordering of flushed output, which is a real defect independent of anything I said about naming.

Related and better-aimed than my complaint: #3015 adds the failing environment to this same failure
path, which is the input #2934 is actually missing.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal refreshed for exact head cb545543. The branch update only incorporates the current base; the merge-base topic diff remains the same one-file package.json change previously reviewed. JSON parsing succeeds and hosted checks are green apart from the still-running check. No new blocking finding.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu closed this Aug 17, 2026
auto-merge was automatically disabled August 17, 2026 11:38

Pull request was closed

@qingyun-wu qingyun-wu reopened this Aug 17, 2026
@qingyun-wu
qingyun-wu enabled auto-merge (squash) August 17, 2026 11:39
@qingyun-wu
qingyun-wu merged commit bbaac35 into main Aug 17, 2026
32 checks passed
@qingyun-wu
qingyun-wu deleted the fix/testpy-hang-evidence branch August 17, 2026 11:57
yixuan-ag2 added a commit to yixuan-ag2/sutando that referenced this pull request Aug 17, 2026
…at just hung

The first occurrence under sonichi#3010's instrumentation landed at line 1781
(test_unrecognised_claim_disposition_is_never_published_to_the_live_core),
which this PR had not touched. I implemented the two sites the issue named
without enumerating the population they came from: the file has 14
communicate(timeout=SHUTDOWN_DRAIN_TIMEOUT_S) calls, 4 of them primary
post-SIGTERM drains where a hang is the finding.

Same recording + except-TimeoutExpired guard as the team-shutdown test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm
yixuan-ag2 added a commit that referenced this pull request Aug 18, 2026
…abort (#2934) (#3024)

* test(session-worker): the hang leg computed its diagnostic after the abort (#2934)

Both shutdown tests built `after_signal` AFTER `communicate()`, so a
TimeoutExpired aborted before the payload existed — empty in exactly the case
it would explain. The crossing case reported; the never-terminating case was
silent, which is why #2934 reads as two unrelated failures.

Move the payload into an `except subprocess.TimeoutExpired` handler in one
shared reporter, and give the team-shutdown test the HANDLER_STARTS recording
the fallback helper already had. No production code, no timing bound changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm

* test(session-worker): instrument the third primary drain — the one that just hung

The first occurrence under #3010's instrumentation landed at line 1781
(test_unrecognised_claim_disposition_is_never_published_to_the_live_core),
which this PR had not touched. I implemented the two sites the issue named
without enumerating the population they came from: the file has 14
communicate(timeout=SHUTDOWN_DRAIN_TIMEOUT_S) calls, 4 of them primary
post-SIGTERM drains where a hang is the finding.

Same recording + except-TimeoutExpired guard as the team-shutdown test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm

* test(session-worker): exercise _hang_report directly, not only via a hang

Review finding: the `except TimeoutExpired` branch runs only on a failing run,
so a green suite says nothing about whether the payload works — the same shape
as the bug being fixed. Calls the builder directly: pre/post-signal markers via
explicit mtimes, opt-in handler_calls, and the torn-down-starts-dir degradation.

The falsifier is why this is wired into the runner's call list: the file
enumerates its tests explicitly, so the first version of this test was defined,
never invoked, and passed while _hang_report was deliberately broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

4 participants