fix(process_registry): release Popen/PTY handles when a session finishes - #75162
fix(process_registry): release Popen/PTY handles when a session finishes#75162RGerrish wants to merge 1 commit into
Conversation
Finished sessions retained their subprocess.Popen pipe objects (and PTY masters) until the finished-process TTL (FINISHED_TTL_SECONDS, default 30 minutes) elapsed. Under heavy background churn — deployments, archivers, watchers — finished-but-unpruned sessions accumulated one open pipe FD each, exhausting the gateway process's file descriptor budget and surfacing as a 'file descriptor limit' error on new background spawns. The registry never rejects spawns (it prunes oldest-finished at MAX_PROCESSES), so the real defect was the retained-handle leak, not a registry-cap rejection. The fix closes each finished session's Popen stdout/stderr/stdin streams and PTY master in _move_to_finished(), right after the reader loop drains EOF. poll()/wait()/read_log() serve output from the buffered output_buffer — never from the pipe — so the release is lossless. Tests: 4 new cases in TestFinishedHandleRelease — Popen pipes closed, PTY closed, no-handle sessions safe, and poll() still serves buffered output after the release. All 4 fail on main (reproduction) and pass with the fix.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing the retained-descriptor path — the underlying leak is present on current main: local spawns retain Popen.stdout (tools/process_registry.py:780-794), while finished sessions retain their ProcessSession objects without a close path (tools/process_registry.py:1149-1153).
Problems
tools/process_registry.py:1161assumes every_move_to_finished()call follows reader EOF. That is not true:kill_process()marks the session exited and calls_move_to_finished()immediately after termination (tools/process_registry.py:1648-1660), while the reader can still be selecting or reading the same handle. Closing it there can race the reader and lose an unbuffered output tail.
Suggested changes
- Release handles after reader completion, or synchronize the kill path with reader shutdown; preserve a deliberate cleanup path for orphan-held pipes.
- Add a live-reader +
kill_process()regression test. The new tests model already-finished handles and do not exercise this race.
Remote main is one commit ahead of the PR base (9e4492fd74e6), affecting the reader-loop region rather than this hunk, so the staleness is limited but the race needs a substantive adjustment.
Automated hermes-sweeper review.
| # poll()/wait()/read_log() serve output from the buffered | ||
| # ``output_buffer``, never from the pipe, so closing the handles here | ||
| # is lossless. | ||
| self._release_finished_handles(session) |
There was a problem hiding this comment.
This helper is not reached only after reader completion: kill_process() marks the session exited and calls _move_to_finished() immediately after terminating the process (current main tools/process_registry.py:1648-1660), while the reader may still be selecting or reading this handle. Closing it here can race that reader and drop its tail; defer release until reader completion or explicitly coordinate the kill path, with a regression test for that race.
There was a problem hiding this comment.
Confirmed — this race is real and is now covered by the same bounded drain-owner fix as the orphan-descendant case (commit b1271f142).
kill_process() → _move_to_finished() → _release_finished_handles() now goes through _should_defer_pipe_close() at the single release choke point: when the reader thread is still alive and the pipe has not reached true EOF, the close is deferred and the read end is handed to the bounded drain owner (≤ _ORPHAN_DRAIN_WINDOW_S = 5.0s), so the reader's tail is never dropped by a concurrent kill. The gate also excludes the reader's own finally path and Windows (blocking path), so it can't deadlock the reader or double-close.
The regression tests exercise the kill path directly: both tests in tests/tools/test_process_registry_orphan_pipe.py call registry.kill_process() and then assert the descendant survives, late output is captured, and the handle releases cleanly. Verification: orphan-pipe tests 2/2, existing tests/tools/test_process_registry.py 76/76.
|
There is one more lifecycle case that should block this from closing handles in
Closing Please add a deterministic regression that:
The release boundary also needs to stay physical: the FD charge should not be returned merely because the session moved to This is separate from the live-reader race already noted in review: it is reachable after the reader loop intentionally stops without EOF. |
|
We independently reproduced this against a local copy of the branch and confirmed the claim — then built a bounded drain owner that fixes it. Details below for folding into Repro (deterministic regression test,
Fix (commit
Verification: orphan-pipe tests 2/2 (descendant survives AND late output captured; bounded-property test proves the window actually closes handles), and the existing The same process-tree class applies to the |
Disposition: keep open; not supersededStudio production carries a mechanically rebased downstream patch as Current upstream Recommendation: keep the PR open for salvage/update, with a deterministic real-process orphan-pipe regression and an explicit bounded drain/late-output policy. Revisit closure only after the fix lands on upstream |
Bug
Background processes that finish keep their OS file descriptors open until the finished-process TTL (30 min by default) elapses. Under heavy background churn the gateway process can exhaust its file descriptor budget, and new
terminal(background=true)spawns start failing with a "file descriptor limit" style error.The registry never rejects spawns — every spawn path calls
_prune_if_needed()first and evicts the oldest finished entry atMAX_PROCESSES(64). The real defect is the retained-handle leak, not a registry-cap rejection.The broken code
tools/process_registry.py,_move_to_finished():It moves the session to
_finishedbut never closes the handles:subprocess.Popenonsession.processwithstdout=PIPE(spawn at ~line 801-815) — the parent's read-end pipe FD stays open.ptyprocess.PtyProcessonsession._pty(spawn at ~line 757-766) — the PTY master FD stays open._prune_if_needed()(line 1914) deletes the dict entry but does not close the handles either.So a finished session holds 1+ pipe/PTY FDs for the entire
FINISHED_TTL_SECONDSwindow. 64 finished sessions × 1-2 FDs each can push a launchd-spawned gateway (typically a 256-FD soft limit) over the edge.How it was found
tools/process_registry.pyshowedMAX_PROCESSESnever blocks spawns —_prune_if_needed()evicts oldest-finished before insert. So the registry-count rationale was wrong; the symptom pointed at retained handles instead._move_to_finished()and_prune_if_needed()move/delete session records without touchingsession.process(Popen) orsession._pty(PtyProcess). The pipes were only released when the dict entry finally disappeared AND GC ran — i.e. after the TTL.main):tests/tools/test_process_registry.py::TestFinishedHandleRelease—proc.stdout.closedstaysFalseafter finish onmain.Reproduction
Or manually:
Fix
_move_to_finished()now calls_release_finished_handles(session), which closes the session'sPopenstdout/stderr/stdin streams and PTY master — right after the reader loop has drained EOF. This is lossless:poll()/wait()/read_log()serve output from the bufferedsession.output_buffer— they never touch the pipe after finish.kill_process()/write_stdin()/send_eof()early-return onsession.exitedbefore touching handles.Tests
4 new cases in
TestFinishedHandleRelease:test_move_to_finished_closes_popen_pipes— stdout pipe FD released at finish (red on main)test_move_to_finished_closes_pty— PTY master released (red on main)test_move_to_finished_safe_without_handles— env/detached sessions don't crashtest_poll_still_serves_output_after_handle_release— buffered output still queryable after close (red on main)Related
The finished-process TTL is independently being made configurable in #75144 (retention knob for how long finished output stays queryable) — that PR does not include this handle-release fix; the two are complementary.