fix(acp): reset the client even when the kill fails on shutdown - #4643
Conversation
`AcpClient.shutdown()` awaited `_kill_process(force=True)` and then called `_reset_state()` sequentially, so any exception out of the kill skipped the reset entirely. `_kill_process` has several exits. It awaits four `run_in_executor` calls (child scan, record capture, escaped-child sweep) that are not individually guarded; `subprocess_executor()` refuses new work once the loop is tearing down; and `asyncio.CancelledError` is a `BaseException` arriving mid-await -- which is precisely what a shutdown produces. Nothing retries. Every caller treats `shutdown()` as terminal and drops the client immediately after: `AcpWorker` (`knowledge/llm_pool.py`, two sites) and `_shutdown_quietly` (`connections/mint.py`) each `except Exception`, log, and set their reference to `None`. So a skipped reset is permanent -- the pipes stay open, the sandbox temp files stay on disk, the stderr task is never cancelled, and for the claude backend `.claude/settings.local.json`, which exists only to carry `bypassPermissions` for the live session, survives the process it belonged to. Move the reset into a `finally`. The exception still propagates. Running the reset after a failed kill is safe by construction, and that is not an assumption: `_reset_state` untracks only PIDs it confirms dead via `_pid_gone_or_unmanaged`, and deliberately RETAINS tracking for survivors so the periodic orphan sweep and `cleanup_orphaned_sessions()` still reap them. Its own comment calls untracking a survivor "the memory-leak this guards against". Tests: `test/test_acp_client_shutdown_reset.py` -- a cancelled kill and a failing kill must both still reset, asserted on a real `settings.local.json` in a real work dir rather than on a mock call, with the exception still propagating; plus the clean path as a control. Against `origin/main` the first two fail, the control passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17deafe to
2a391f1
Compare
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe diff is a minimal try/finally fix and the discovery pass found no candidates. I've verified No findings. [OPUS-REVIEWED] 2a391f1 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Minimal, well-evidenced fix: cleanup moves into Suggestions
[DESIGN-REVIEWED] 2a391f1 |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of I've read the contract, the intent file, the patch, and the relevant base code ( First-Principles-Verdict: PASS One defect, one What this change shipsIntent: stop an interrupted
The zero option is real harm: callers ( WatchGrepping SubtractionsShrink the [FIRST-PRINCIPLES-REVIEWED] 2a391f1 |
Base branch was modified
Fixes #4642
Problem / Motivation
Sequential, so any exception out of
_kill_processskips_reset_stateentirely.
It can escape.
_kill_processawaits fourrun_in_executorcalls — thechild-PID scan, the child-record capture, and two
_kill_escaped_childrensweeps — none of them individually guarded;
subprocess_executor()refuses newwork once the loop is tearing down; and
asyncio.CancelledErroris aBaseExceptionarriving mid-await, which is exactly what a shutdown produces.Why it matters
Nothing retries. Every caller treats
shutdown()as terminal and drops theclient immediately after:
knowledge/llm_pool.py:291,:376try: await self._client.shutdown()→except Exception: logger.debug(...)→self._client = Noneconnections/mint.py:493(_shutdown_quietly)except Exception, never called againproviders/acp.py:1189So a skipped reset is permanent, and
_reset_stateis not bookkeeping-only. Itleaves behind the process's stdin/stdout/stderr pipes, the macOS seatbelt
sandbox temp files, an uncancelled stderr reader task, confirmed-dead PIDs still
recorded in the orphan-tracking files, and — for the claude backend —
.claude/settings.local.json, which the code's own comment describes as"Remove settings.local.json so bypassPermissions doesn't persist after crash",
surviving the session it was written for.
What changed (motivation → approach → change)
The reset moves into a
finally. The exception still propagates — nothing isswallowed, only the cleanup is made unconditional.
Running the reset after a failed kill is safe by construction, and that is not
an assumption I am making.
_reset_stateuntracks only PIDs it confirms deadvia
_pid_gone_or_unmanaged, and deliberately retains tracking for survivorsso the periodic orphan sweep and
cleanup_orphaned_sessions()still reap them —its own comment calls untracking a survivor "the memory-leak this guards
against". A kill that failed halfway therefore leaves the tracking files in
exactly the state those sweeps expect.
This is the same shape as
AcpRuntime.terminate_session, which alreadyunregisters its queue in a
finallyfor the same statedBaseExceptionreason.Tests
New
test/test_acp_client_shutdown_reset.py:_kill_processraisesCancelledError→settings.local.jsonis gone,_session_idis cleared,CancelledErrorstill propagates_kill_processraisesRuntimeError("cannot schedule new futures after shutdown")→ sameThey use a real
work_dirand a realsettings.local.jsonand assert on thefile, rather than asserting
_reset_statewas called.origin/main(55ea3ac1d), pristine worktreeThe one that passes either way is the control.
Wider relevant suite on this branch:
All 9 failures are
OSError [WinError 1314]— creating a symlink needs aprivilege this box does not hold — in
test_acp_liveness.pyandtest_connections_mint.py. Running those two files against pristineorigin/maingives the same9 failed, 106 passed. (A collection error intest/test_bench_download_fd.py,KeyError: 'file', is likewise pre-existingand unrelated.)
flake8clean; the baselined black gate passes with both files in scope.Manual verification
Not applicable — reproducing this by hand means cancelling a shutdown mid-kill
on a live claude-backend session and then looking for
.claude/settings.local.json. The tests drive the same production method withthat precondition and assert on the same file.