Skip to content

fix(health-check): feed git its stdin from a temp file so a full pipe cannot deadlock the check - #5663

Merged
qingyun-wu merged 4 commits into
mainfrom
fix/5555-health-check-stdin-tempfile
Oct 18, 2026
Merged

qingyun-wu merged 4 commits into
mainfrom
fix/5555-health-check-stdin-tempfile

Conversation

@qingyun-wu

@qingyun-wu qingyun-wu commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

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 checks timeout= around every select(). 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:

  • A new pipe gets a fixed 512-byte buffer and can't grow.
  • poll() still computes writability against the nominal 16 KiB (MAX_PIPESIZE). It reports POLLOUT on a pipe that is already full.
  • _communicate trusts that report. It does a blocking os.write(fd, input_view[off:off+512]) (subprocess.py:2126 in 3.12.15), and that write sleeps. The timeout is only checked before and after select(), never inside os.write, so timeout= never fires.
  • git check-ignore -v -z --stdin writes 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 in fflush -> write. The deadlock is permanent.

A capture taken 8 s into the reproduced hang matches the issue's capture line for line:

  • Python, 1616 of 1616 samples: _PyEval_EvalFrameDefault -> os_write -> _Py_write_impl -> write, on fd 4 (stdin write end). lsof shows 16384, which is the nominal size, not the real 512.
  • git, 1609 of 1609 samples: cmd_check_ignore -> maybe_flush_or_die -> fflush -> __sflush -> _swrite -> __write_nocancel.

With the pool spent, the parent's _carrier_target_verdict was still hung at a 90 s bound, past its own timeout=60.

Fix (unchanged from the first push): the check-ignore site feeds stdin from a tempfile.TemporaryFile(). Python never writes a pipe, so communicate() only reads stdout and stderr. A read after POLLIN can't block, so timeout= stays live.

Interpreter

Both interpreters reproduce the hang the same way:

  • python-build-standalone 3.12.15, the desktop bundle's runtime and the version named in the issue (_PIPE_BUF 512, PollSelector)
  • Homebrew 3.14.6, which is the python3 first on this host's core PATH, which the proactive loop runs

git is Xcode's /Applications/Xcode.app/Contents/Developer/usr/bin/git.

The cat-file site: kept, now commented as defensive

_index_growth_note feeds git cat-file --batch at 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:

  • It keeps both git stdin sites in health-check the same shape.
  • The existing AST test stays as it is.
  • Kewei's concern about the temp-file dependency is real but bounded: an unavailable temp filesystem makes the trend "unavailable" and doesn't corrupt anything.

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:

  • A holder child spends the pool and keeps it spent.
  • The real _carrier_target_verdict runs on a 2000-file carrier tree in a child with a 20 s wall bound. Needing the kill is the failure.
  • It skips off macOS, and when the pool can't be spent. It never passes vacuously.

Shared-machine safety:

  • The pool is released in finally, again in atexit, and on SIGTERM.
  • The holder also exits on its own stdin EOF, so a test killed with SIGKILL still frees the pool within 50 ms. It also has a hard self-limit of bound + 5 s.
  • The exhausted window lasts about 1 s at head and 20 s at a broken tree.

CI / serial choice. There's no macOS runner in .github/workflows: ci.yml's python legs are all ubuntu-latest, and only windows-runtime.yml runs elsewhere. So in CI this test is a skip on every leg. That makes the serial tag in tests/python-load-sensitive-suites.txt moot here, and the list's admission rule requires cited CI flake evidence anyway.

Locally, npm run test:py runs 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 own health-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_write on fd 4 to git check-ignore). So the test is opt-in via SUTANDO_TEST_PIPE_POOL=1, following the SUTANDO_TEST_REAL_FSWATCH=1 precedent 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_payload docstring was reworded the same way.

Before: parent 22e970b02 (src from main, new test copied in)

== parent 22e970b02 3.12.15
  FAIL check-ignore probe finishes with the pipe pool spent
       killed after 20s: blocked writing git's stdin (a fixed probe takes ~1s)
  FAIL ...and reads carried
       got ''
  probe took 20.03s

2 FAILED: ['check-ignore probe finishes with the pipe pool spent', '...and reads carried']
exit=1 wall=22s
== parent 22e970b02 3.14.6
  FAIL check-ignore probe finishes with the pipe pool spent
       killed after 20s: blocked writing git's stdin (a fixed probe takes ~1s)
  FAIL ...and reads carried
       got ''
  probe took 20.01s

2 FAILED: ['check-ignore probe finishes with the pipe pool spent', '...and reads carried']
exit=1 wall=21s

After: head (3 runs per interpreter, 6 of 6 pass)

== head 3.12.15 run 1
  ok   check-ignore probe finishes with the pipe pool spent
  ok   ...and reads carried
  probe took 0.39s

all ok
exit=0 wall=1s
== head 3.12.15 run 2
  ok   check-ignore probe finishes with the pipe pool spent
  ok   ...and reads carried
  probe took 0.22s

all ok
exit=0 wall=1s
== head 3.12.15 run 3
  ok   check-ignore probe finishes with the pipe pool spent
  ok   ...and reads carried
  probe took 0.19s

all ok
exit=0 wall=1s
== head 3.14.6 run 1
  ok   check-ignore probe finishes with the pipe pool spent
  ok   ...and reads carried
  probe took 0.46s

all ok
exit=0 wall=2s
== head 3.14.6 run 2
  ok   check-ignore probe finishes with the pipe pool spent
  ok   ...and reads carried
  probe took 0.18s

all ok
exit=0 wall=1s
== head 3.14.6 run 3
  ok   check-ignore probe finishes with the pipe pool spent
  ok   ...and reads carried
  probe took 0.20s

all ok
exit=0 wall=1s

Without the opt-in:

$ python3 tests/health-check-git-stdin-pipe-pool.test.py
skip: spends the host-wide pipe pool; set SUTANDO_TEST_PIPE_POOL=1 to run

Neighbouring suites and checks at head

$ python3 tests/health-check-git-stdin-no-pipe.test.py | tail -3
  ok   ...and never the unavailable marker

all ok
$ python3 tests/health-check-carrier-set-enforced.test.py
Ran 49 tests in 5.914s

OK
$ git diff 22e970b02 | bash scripts/review-checks.sh
prose-cap: PASS (comment blocks within cap 2, exts .py)
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)
$ ruff check src/health-check.py tests/health-check-git-stdin-*.test.py tests/health-check-carrier-set-enforced.test.py
All checks passed!

All 136 tests/health-check* suites at head: 135 pass. 1 fails: health-check-fix-down-bridges case 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:

  • This mechanism reproduces the issue's capture exactly: fd 4, "16 KiB", os_write -> write, hung far past the 60 s timeout.
  • No other configuration I tried reproduces it. Control runs with a healthy pool never hung, nor did Kewei's or Susan's 11k- and 40k-file runs.
  • A busy multi-agent host holding about 250 stalled 64 KiB pipes is enough to spend the pool here.

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 other input= call sites were swept earlier as "small". They should be re-audited against this lower bar in a separate issue.

🤖 Generated with Claude Code

`_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>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/health-check.py (100%)

Summary

  • Total: 8 lines
  • Missing: 0 lines
  • Coverage: 100%

@liususan091219 liususan091219 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.

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/reviews returns 0 reviews and reviewDecision is REVIEW_REQUIRED. The PR needs at least one substantive approval from someone who knows src/health-check.py (the carrier probe and the index-trend code).
  • license/cla is missing, not green. commits/89aefaee0/status returns state: pending with total_count: 0. That means the check hasn't run, which is not the same as failing. The @cla-assistant check comment 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 pass stdin=<TemporaryFile> instead of input=. capture_output, text= and the 60 s / 20 s timeouts are unchanged. tempfile is already imported at line 39. The file is closed by the with block once run() returns, and the existing except (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_DIR unset):
    • 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.py back under the new test. Exactly the 4 AST assertions fail (no input= and stdin 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_verdict at 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: carried in 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.sh passes.
  • Same pattern elsewhere: I searched src/, scripts/ and skills/ for other input= uses. The rest (remote-gateway-bridge.py:101 labels JSON, the sutando_platform.py:106-121 clipboard calls, pending_questions_store.py:228 request JSON) send small inputs and/or read back little or no output. The two git --stdin / --batch callers changed here are the only bulk ones, so I'm not asking for a wider sweep.

Notes (none of these block)

  1. 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.
  2. 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 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 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_verdict and _index_growth_note in src/health-check.py both now write the payload to a tempfile.TemporaryFile(), seek(0), and pass it as stdin= inside a with block, replacing subprocess.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 where timeout= is enforced.
  • Ran tests/health-check-git-stdin-no-pipe.test.py myself: 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 real dropped verdict 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.py to the main parent 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 keweichen 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.

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 same PollSelector;
  • 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>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@keweichen thanks. Your blocker was right, and it's what led to the real cause. Your reading of communicate() is correct, and so is the 64 KiB story being wrong. The missing piece is the kernel, not CPython.

Reproduction. Spend macOS's system-wide pipe-buffer pool (maxpipekva). On this host that takes about 222 extra 64 KiB pipes. Then:

  • A new pipe is stuck at 512 bytes.
  • poll() still reports POLLOUT against the nominal 16 KiB, even when the pipe is full. Measured: 39 POLLOUT reports where a 512-byte write would block, against 0 with a healthy pool.
  • _communicate trusts that report and does a blocking os.write (subprocess.py:2126, 3.12.15). That write sleeps, so the timeout check around select() is never reached.
  • git check-ignore -v -z --stdin flushes per path. It blocks in fflush -> write on its 512-byte stdout pipe.

The sampled pair matches the issue's capture line for line:

  • Python: 1616 of 1616 samples in os_write -> write, fd 4. lsof shows "16384".
  • git: 1609 of 1609 samples in cmd_check_ignore -> maybe_flush_or_die -> fflush -> ... -> __write_nocancel.

Results:

  • At the parent, _carrier_target_verdict was still hung at 90 s, past its own timeout=60.
  • input= hung in 6 of 6 runs, the temp-file stdin finished in 6 of 6.
  • Both the bundled python-build-standalone 3.12.15 and Homebrew 3.14.6 behave the same.
  • With a healthy pool nothing hangs. That's why your runs, Susan's, and the first version of this PR never reproduced it.

What changed, at 55babe636:

  • New behavioural test, tests/health-check-git-stdin-pipe-pool.test.py. It spends the pool and runs the real probe on a 2000-file tree in a child bounded at 20 s; needing the kill is the failure.
    • It fails at 22e970b02 (killed at 20 s on both interpreters) and passes at head 6 of 6 (0.18–0.46 s). Output is in the PR body.
    • It is macOS-only and opt-in (SUTANDO_TEST_PIPE_POOL=1), because spending the pool affects every process on the host. There is no macOS CI runner anyway.
  • Both code comments, both docstrings and the PR body now state this mechanism.
  • The cat-file hunk: you were right that its at most 12 specs can't deadlock. With the pool spent, the parent finished 8 of 8 runs there. I kept the temp file for consistency between the two sites, and its comment now says it is defensive.
  • The AST pin stays. The behavioural test now carries the proof.

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 health-check.py hung with that exact stack and was still stuck 20+ minutes later.

Could you re-review at 55babe636? Since #5555 was closed as not reproducible, could it also be reopened with this as the root cause? A separate follow-up should re-audit the other input= call sites against the lower bar this sets: more than about 1 KB of input, plus a child that writes before it drains its input.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu
qingyun-wu merged commit 91683a2 into main Oct 18, 2026
33 checks passed
@qingyun-wu
qingyun-wu deleted the fix/5555-health-check-stdin-tempfile branch October 18, 2026 04:43
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.

health-check hangs forever in the carrier check-ignore probe (stdin pipe deadlock on macOS)

4 participants