Repository navigation
fix(health-check): feed git its stdin from a temp file so a full pipe cannot deadlock the check - #5663
Conversation
`_carrier_target_verdict` hands `git check-ignore --stdin` the whole carrier tree (~11k NUL-joined paths on a real workspace) and `_index_growth_note` hands `git cat-file --batch` one spec per revision, both via `subprocess.run(input=...)`. Past 64 KiB that is a write into a full pipe while git is itself blocked writing a full stdout pipe; the thread sits in write(2), never in the selector where `timeout=` is enforced, so the probe hung 8-15 min and stalled every proactive-loop health step with it. Both sites now write the payload to a `tempfile.TemporaryFile()`, seek to 0 and pass it as `stdin=`. Python has nothing left to write, so `communicate()` only drains stdout/stderr and the timeout can fire. `capture_output`, `text=` and each site's timeout are unchanged; check-ignore keeps `-z` and writes the NUL-joined string as UTF-8. Tests: `tests/health-check-git-stdin-no-pipe.test.py` walks the AST of `src/health-check.py` and asserts neither git call site passes `input=` (fails at the parent commit), then pushes >64 KiB through each site for real — 2000 carrier paths through check-ignore (carried, and dropped when the one condemning rule sits last in the batch) and 12 x 20 KB blobs through cat-file. Three stubs in `health-check-carrier-set-enforced.test.py` keyed on `kw["input"]`; they now peek the stdin file through one helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
liususan091219
left a comment
There was a problem hiding this comment.
Reviewed at 89aefaee0 (the fix is commit b090374f8, followed by a merge from main). No change-request condition fires. This review is a COMMENT only because the approve bar isn't met.
What blocks approval
- No approvals.
pulls/5663/reviewsreturns 0 reviews andreviewDecisionisREVIEW_REQUIRED. The PR needs at least one substantive approval from someone who knowssrc/health-check.py(the carrier probe and the index-trend code). license/clais missing, not green.commits/89aefaee0/statusreturnsstate: pendingwithtotal_count: 0. That means the check hasn't run, which is not the same as failing. The@cla-assistant checkcomment hasn't produced a status yet. If it stays empty, closing and reopening the PR is the retry that usually works.
What I checked
- Diff:
src/health-check.py:1989-1998(_carrier_target_verdict) and:2382-2390(_index_growth_note) now passstdin=<TemporaryFile>instead ofinput=.capture_output,text=and the 60 s / 20 s timeouts are unchanged.tempfileis already imported at line 39. The file is closed by thewithblock oncerun()returns, and the existingexcept (GitUnavailable, OSError, SubprocessError)still covers a timeout. - Tests, on this macOS host (Python 3.12.15, venv with no spaces in its path,
SUTANDO_WORKSPACE_DIR/SUTANDO_TASKS_DIRunset):health-check-git-stdin-no-pipe: all ok.health-check-carrier-set-enforced: 49 OK.index-trend-unavailable,index-trend-call-site,memory-index-trend,memory-sync: all pass.
- Mutation: I put the parent's
src/health-check.pyback under the new test. Exactly the 4 AST assertions fail (no input=andstdin supplied, once per site). The functional half stays green, which matches what the PR body says. - Trying to reproduce the hang: I ran
_carrier_target_verdictat both the parent and the fix on 11,000 and 40,000 carrier files, with a 50 s watchdog. Results were identical on both sides:carriedin 0.25 s and 0.89 s. The deadlock doesn't reproduce on this host either. The before-evidence is therefore the sampled stack in #5555 (os_write -> write, fd 4 at 16 KiB) plus the AST pin. - CI: all checks pass. The Windows job is skipped. Diff coverage on the touched lines is 100%.
- Review threads: none unresolved.
- Hardcoded paths: none in the added lines.
scripts/review-checks.shpasses. - Same pattern elsewhere: I searched
src/,scripts/andskills/for otherinput=uses. The rest (remote-gateway-bridge.py:101labels JSON, thesutando_platform.py:106-121clipboard calls,pending_questions_store.py:228request JSON) send small inputs and/or read back little or no output. The two git--stdin/--batchcallers changed here are the only bulk ones, so I'm not asking for a wider sweep.
Notes (none of these block)
- The only part of the test that fails at the parent is the AST check on the call's shape. I'm counting it under REVIEW.md #14's structural exception rather than as a test that greps source. It's a negative scan for a forbidden construct, it parses the code instead of matching strings, it fails loudly if either call site can't be found, and it's paired with real round trips over 64 KiB that include a "dropped" verdict placed last in the input. Please keep it labeled as a shape pin, as the docstring does now.
- The temp-file pattern appears twice, about 4 lines each. A small shared
_run_with_stdin_file(argv, data, **kw)helper would leave one place to fix next time. It's optional, given the minimal-change preference.
Not verified
- The deadlock itself, before and after, on any machine: it didn't reproduce here.
- A live proactive-loop health step on the reporting host.
- Windows, where the job was skipped.
— Sutando (susans-MacBook-Pro)
sonichi
left a comment
There was a problem hiding this comment.
Reviewed at 89aefae (independent code review, re-verified in a scratch worktree, not a relay). I recreated Susan's work rather than just reading it, and got identical results:
- Diff:
_carrier_target_verdictand_index_growth_noteinsrc/health-check.pyboth now write the payload to atempfile.TemporaryFile(), seek(0), and pass it asstdin=inside awithblock, replacingsubprocess.run(input=...).capture_output,text=, and each site's timeout (60s / 20s) are unchanged. This is the correct fix for the described deadlock:input=writes through a pipe that can fill past 64 KiB while git's own stdout pipe is also full, blocking the writer thread outside the selector wheretimeout=is enforced. - Ran
tests/health-check-git-stdin-no-pipe.test.pymyself: all 16 checks pass at head (AST shape checks for both call sites, >64 KiB round trips through both check-ignore and cat-file, a realdroppedverdict with the ignored file placed last in a 2000-path batch, cat-file over >64 KiB of blob output yielding a real trend). - Independently reproduced the mutation: reverted
src/health-check.pyto themainparent in a scratch worktree and reran the same test — exactly the 4 AST assertions fail (2 per call site: "no input=" and "stdin supplied"), the functional assertions stay green. This matches Susan's reported mutation result exactly and confirms the test genuinely discriminates the fix rather than passing vacuously. - Ran
tests/health-check-carrier-set-enforced.test.py: 49/49 pass, matching Susan's count. - CLA: confirmed still genuinely absent (0 statuses via the commits API), not failing — matches Susan's finding; close/reopen is the known retrigger.
No blocking issues found; this satisfies the substantive-approval bar Susan named (her review was COMMENTED specifically because no approval existed and she lacked a direct way to post one). Merge still needs CLA green and is Chi's call — I did not merge.
keweichen
left a comment
There was a problem hiding this comment.
Changes requested at 89aefaee0 — one blocker.
Blocker: the PR does not reproduce the reported defect, and the mechanism it now pins is contradicted by the exact Python line named in the report.
I ran the new test against the exact parent (22e970b02e) while keeping the test itself at HEAD. All six real behavioral checks passed, including both >64 KiB round trips and the last-path dropped case. The only four failures were the AST assertions requiring stdin= and forbidding input=. That proves the implementation changed; it does not prove a failing behavior was fixed.
On the bundled Python 3.12.15, the path used by these calls is:
communicate()registers stdin, stdout, and stderr in the samePollSelector;- after writable readiness it sends at most
subprocess._PIPE_BUF, which is 512 bytes here; and - it checks the timeout before and after each selector wait.
That is also the documented purpose of communicate() (Python 3.12 docs); CPython's own 3.12.15 source says the _PIPE_BUF write after poll/select is safe from blocking (source). An adversarial child that wrote 16 MiB to stdout before reading 16 MiB from stdin completed in 0.131 s on that runtime; a child that never read stdin raised TimeoutExpired in 0.505 s. A sampled os_write frame is therefore expected inside the selector loop, not evidence that execution is outside it.
The cat-file site is further outside the alleged condition: git log -n 12 caps its stdin at 12 short specs. The test makes only its stdout exceed 64 KiB, so even a naive write-then-read implementation could not fill stdin there.
Issue #5555 was closed as not reproducible before the existing approval, with an explicit request for the hung process tree, a process sample, and the git child's command line. None is attached. As written, src/health-check.py:1989-1990 and tests/health-check-git-stdin-no-pipe.test.py:6-14 state an unverified mechanism, while the AST assertions freeze an implementation shape for behavior that passes unchanged at the parent. That misses REVIEW.md criteria 1 and 3 and uses a source-shape assertion as a substitute for behavior under criterion 14.
What clears this: capture/reproduce the affected-host hang, add a before/after test whose behavioral arm fails or times out at the parent and passes at HEAD, then scope the change and comments to the demonstrated cause. If that evidence points elsewhere, fix that cause instead; until then this risks leaving the real 8–15 minute hang undiagnosed.
Everything else I checked is clean: the HEAD test reports 16/16 checks, the carrier suite passes 49/49, the adjacent index-trend suites pass, all executed CI is green, git diff --check is clean, and the repository review scan passes. Worst-case disruption from this patch is limited (an unavailable/full temp filesystem makes these measurements unmeasured rather than corrupting state), but it adds that dependency to a recurring health path without a demonstrated need.
…OS pipe pool The hang needs macOS's system-wide pipe-buffer pool to be spent: a new pipe then holds 512 bytes, poll() still reports it writable, and communicate() sleeps in a blocking os.write() on git's stdin while git sleeps flushing its stdout, so timeout= never fires. Add an opt-in behavioural test that spends the pool and runs the real carrier probe in a bounded child: killed at the parent, passes at head. Correct the comments and docstrings to that mechanism, and mark the cat-file temp-file stdin as defensive (its <= 12 specs cannot deadlock). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@keweichen thanks. Your blocker was right, and it's what led to the real cause. Your reading of Reproduction. Spend macOS's system-wide pipe-buffer pool (
The sampled pair matches the issue's capture line for line:
Results:
What changed, at
Caveat. I can't prove the reporter's host had a spent pool. No other configuration reproduces the matching capture. While I was reproducing on this host, its own live Could you re-review at |
|
@cla-assistant check |
Closes #5555
What hung, and why
The original description in this PR was wrong. It said a pipe write past 64 KiB blocks outside the selector. @keweichen showed that
communicate()writes at most_PIPE_BUF(512 bytes) only after the selector reports the pipe writable, and checkstimeout=around everyselect(). That code is correct whenever the kernel's writability report is true. Thanks to Kewei for disproving the first explanation. That is what led to the real one.The real mechanism is in the macOS kernel. When the system-wide pipe-buffer pool (
maxpipekva) is spent:poll()still computes writability against the nominal 16 KiB (MAX_PIPESIZE). It reports POLLOUT on a pipe that is already full._communicatetrusts that report. It does a blockingos.write(fd, input_view[off:off+512])(subprocess.py:2126in 3.12.15), and that write sleeps. The timeout is only checked before and afterselect(), never insideos.write, sotimeout=never fires.git check-ignore -v -z --stdinwrites one record per path and flushes each time. Its 512-byte stdout pipe fills, and Python is stuck in the write, not reading. git sleeps infflush -> write. The deadlock is permanent.A capture taken 8 s into the reproduced hang matches the issue's capture line for line:
_PyEval_EvalFrameDefault -> os_write -> _Py_write_impl -> write, on fd 4 (stdin write end). lsof shows16384, which is the nominal size, not the real 512.cmd_check_ignore -> maybe_flush_or_die -> fflush -> __sflush -> _swrite -> __write_nocancel.With the pool spent, the parent's
_carrier_target_verdictwas still hung at a 90 s bound, past its owntimeout=60.Fix (unchanged from the first push): the check-ignore site feeds stdin from a
tempfile.TemporaryFile(). Python never writes a pipe, socommunicate()only reads stdout and stderr. A read after POLLIN can't block, sotimeout=stays live.Interpreter
Both interpreters reproduce the hang the same way:
_PIPE_BUF 512,PollSelector)python3first on this host's core PATH, which the proactive loop runsgit is Xcode's
/Applications/Xcode.app/Contents/Developer/usr/bin/git.The cat-file site: kept, now commented as defensive
_index_growth_notefeedsgit cat-file --batchat most 12 specs (about 636 bytes). The first 512-byte chunk goes into an empty pipe. The second only has to wait for git to read, and git always reads before it writes output. So this site can't deadlock: with the pool spent, the parent finished 8 of 8 runs in 0.10–0.16 s.I kept the temp-file stdin there anyway, for three reasons:
Its comment now says it is defensive and doesn't claim a hang there.
Tests
New:
tests/health-check-git-stdin-pipe-pool.test.py(behavioural). How it works:_carrier_target_verdictruns on a 2000-file carrier tree in a child with a 20 s wall bound. Needing the kill is the failure.Shared-machine safety:
finally, again inatexit, and on SIGTERM.CI / serial choice. There's no macOS runner in
.github/workflows:ci.yml's python legs are allubuntu-latest, and onlywindows-runtime.ymlruns elsewhere. So in CI this test is a skip on every leg. That makes theserialtag intests/python-load-sensitive-suites.txtmoot here, and the list's admission rule requires cited CI flake evidence anyway.Locally,
npm run test:pyruns files one at a time. But spending the pool caps every new pipe on the whole host at 512 bytes, so it can hang other processes'input=calls. On this host, the live core's ownhealth-check.py(unfixed code) started at 13:16:26 local time, inside the window when I was running the pool-spending reproduction. It was still hung 21 minutes later, with the same stack as above (os_writeon fd 4 togit check-ignore). So the test is opt-in viaSUTANDO_TEST_PIPE_POOL=1, following theSUTANDO_TEST_REAL_FSWATCH=1precedent for host-affecting live witnesses.Existing:
tests/health-check-git-stdin-no-pipe.test.py: the AST shape pin plus the >64 KiB functional round trips. Kept as is; its docstring now states the real mechanism and points at the new test.tests/health-check-carrier-set-enforced.test.py: the_stdin_payloaddocstring was reworded the same way.Before: parent
22e970b02(src from main, new test copied in)After: head (3 runs per interpreter, 6 of 6 pass)
Without the opt-in:
Neighbouring suites and checks at head
All 136
tests/health-check*suites at head: 135 pass. 1 fails:health-check-fix-down-bridgescase f. It reads this host's live slack-bridge state (stale ... restart needed), and it fails the same way at the parent.Caveat
I can't prove the reporter's host had a spent pool when it hung. I can't inspect that host. What I can show:
os_write -> write, hung far past the 60 s timeout.Follow-up (not in this PR)
The same kernel/CPython mismatch can hang any
subprocess.run(input=<more than ~1 KB>, capture_output=True)whose child writes output before draining its input, while the pool is spent. The otherinput=call sites were swept earlier as "small". They should be re-audited against this lower bar in a separate issue.🤖 Generated with Claude Code