A throwing flush can skip pty.kill() and orphan a shell on quit - #562
Open
lukejohnson3-hub wants to merge 1 commit into
Open
A throwing flush can skip pty.kill() and orphan a shell on quit#562lukejohnson3-hub wants to merge 1 commit into
pty.kill() and orphan a shell on quit#562lukejohnson3-hub wants to merge 1 commit into
Conversation
destroyAllTerminals wrapped flush, dispose and kill in a single try. If the flush throws, pty.kill() is skipped, and the this.terminals.clear() immediately below drops the last handle to that PTY. The shell survives the app with nothing left able to reclaim it. The flush can throw: flushOutputBuffer calls sendRendererEvent (visible terminals) or sendDaemonEvent (hidden ones), both of which resolve to a createFanoutEventSink in core/eventSink.ts that collects the first subscriber error and rethrows it after fanning out. One destroyed webContents, or one daemon client that throws while serializing a frame, is enough. A renderer going away during quit is the normal shape of shutdown, not an exotic condition. destroyAllTerminals has exactly one caller, on the quit path in index.ts, under a comment reading "Kill all terminal panel PTY processes so Claude doesn't survive as an orphan". The defect defeats the stated purpose of the call, in the one place every terminal is torn down at once, and the caller hard-exits immediately afterwards so there is no later chance to recover. On Windows each Git Bash terminal is a three-process chain, so one skipped kill leaks three processes. Each teardown step now has its own catch, so a failure in one cannot skip the others, and the whole iteration has an outer catch so one terminal can never abort the loop and leave every later PTY unkilled. saveTerminalState also gets a .catch, in both destroyAllTerminals and destroyTerminal: it is async, so no synchronous try could ever have observed its rejection, and panelManager.updatePanel writes to SQLite mid-shutdown. There is no unhandledRejection handler in the main process, and Electron 41 bundles Node 24, where the default is to throw. No behaviour change when nothing throws. The loop still visits every terminal, the maps are still cleared, and the flush/dispose/kill ordering is unchanged. That ordering matters: moving disposeFlowControlRecord after the flush would let flowControlOnPtyBytes re-arm a safety timer that nothing then clears. destroyAllTerminals had no test coverage. Added one: two terminals, the first throwing from flushOutputBuffer, asserting that its PTY is still killed, that the healthy one is too, and that the terminals map ends empty. It fails without the source fix, on exactly that first assertion.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pre-existing bug on
main— nothing in this PR introduces it. A shell process can survive the app on quit. Bug fix only, non-breaking: 2 files, +84 −8, one commit offmain.destroyAllTerminalswraps flush, dispose and kill in a singletry. If the flush throws,pty.kill()is skipped — and thethis.terminals.clear()immediately below drops the last handle to that PTY, so nothing is left able to reclaim it. The fix gives each teardown step its own catch.This is the follow-through on #561, where I described the bug and offered to send this fix on its own. You have not heard from me before, so every claim below points at code in your own tree.
The bug
main/src/services/terminalPanelManager.ts, currentmain:The flush can throw, and the path is short enough to check by reading:
flushOutputBuffercallssendRendererEvent('terminal:output', …)for visible terminals, andsendDaemonEvent(…)for hidden ones.getPaneEventSink().send(…)andgetPaneDaemonEventSink().send(…).main/src/daemon/bootstrap.tsinstalls acreateFanoutEventSinkfor both, and that sink (main/src/core/eventSink.ts) collects the first subscriber error and rethrows it after fanning out.Concrete throw sources: the Electron sink in
main/src/index.tsguardswindow.isDestroyed()but then callswindow.webContents.send(...)— a destroyed webContents under a live window throws. On the daemon side,terminal:outputis a daemon channel, andserializeJsonTransport/encodePaneDaemonFrameare not wrapped, onlywriteFrameis. A renderer going away during quit is not an exotic condition — it is the normal shape of shutdown.A second, narrower route skips the sinks entirely:
flushOutputBuffer→flowControlOnPtyBytes→onPause()→pausePty, which callsterminal.pty.pause()synchronously on a PTY that by this point has taken two Ctrl-Cs and a 2-second wait and may be dead. It needspendingBytes >= HIGH_WATERMARK, but it is real.Why it matters
destroyAllTerminalshas exactly one caller,main/src/index.ts, on the quit path. The line above the call reads:The defect defeats the stated purpose of the call, in the one place every terminal is torn down at once. On Windows each Git Bash terminal is a three-process chain, so a single skipped kill leaks three processes, and they are unreachable afterwards because the map has been cleared. The
try/catchsits inside the loop, so a throw costs only that one terminal — one leaked chain per throwing subscriber, not all of them. The catch does log, so the failure is not invisible — but it logsError killing terminal <panelId>while the terminal was in fact never killed, which reads as the opposite of what happened.The fix
Three changes, all of them error handling:
flushOutputBuffer(warn and continue),screenEmulator?.dispose()(warn and continue; it serializes through a third-party addon and can also throw),pty.kill()(keeps the existing error log).clear()calls skipped, and the caller hard-exits straight afterwards..catch()on the un-awaitedthis.saveTerminalState(panelId), in bothdestroyAllTerminalsanddestroyTerminal, which had the same call with no catch at all. It isasync, so no synchronoustrycould ever have observed its rejection, andpanelManager.updatePanelwrites to SQLite mid-shutdown. There is nounhandledRejectionhandler in the main process, and Electron 41 bundles Node 24, where the default is to throw. It is deliberately a.catchand not anawait:TerminalStateEmulator.dispose()documents that the save is meant to read the post-dispose snapshot, so awaiting would be a behaviour regression. IncludingdestroyTerminalcloses the class rather than one instance, in the same function family, at no extra scope.No behaviour change when nothing throws. The loop still visits every terminal, the maps are still cleared, and the ordering of flush → dispose → kill is unchanged. That ordering is load-bearing: moving
disposeFlowControlRecordafter the flush would letflowControlOnPtyBytesre-arm a 5-second safety timer that nothing then clears.One repair comes for free:
dispose()now always runs, and it is what resolves the emulator'sidleResolvers. Previously a throwing flush skippeddispose(), leavingsaveTerminalState'sawait waitForIdle()pending forever — so that panel's state was silently lost on quit, on top of the orphaned shell.Testing notes
destroyAllTerminalshad no test coverage at all. This adds one: two terminals, the first with aflushOutputBufferthat throws, asserting both PTYs are killed and the terminals map ends empty. It fails on currentmain, where the throwing terminal'spty.kill()is skipped.Worth a maintainer's eye, and not something unit tests can approximate: this is a quit-path change, and packaged-build quit behaviour is exactly what unit tests cannot reach. A useful regression smoke test is to quit a packaged build with two or more live agent panels, then confirm no shell processes survive.
Unrelated to this PR: if you run the suite on a machine where
python3resolves to the Windows Store alias stub,main/src/services/skillCacheManager.test.tsfails, because one of its three cases is missing theit.skipIf(pythonProbe.status !== 0)its siblings carry. Pre-existing and untouched here.Type of Change
Checklist
pnpm typecheckandpnpm lintlocallypnpm electron-dev— verified against the unit suite; the failure path needs a throwing event-sink subscriber, which the test injects directlyCritical Areas Modified
None. Session output handling, timestamp handling, state management/IPC events and diff viewer CSS are all untouched.
flushOutputBufferkeeps its position and behaviour; only the error handling around it changes.Additional Notes
I found this while investigating a
bash.exeprocess leak on Windows, where terminal shells accumulated until the machine could not fork. That investigation produced a second, much larger change — a bound on how many terminal PTYs stay resident — which is finished and reviewed but deliberately not attached to this PR. This one stands entirely on its own, and is the more urgent of the two: it affects every quit on currentmain, not only a workspace that has accumulated terminals. Nothing here depends on #561 being read, or on any decision about the larger change.Also deliberately not here:
destroyAllTerminalskills WSL terminals immediately, wheredestroyTerminalwritesexit\rand defers the kill 500 ms. That asymmetry looks intentional for a quit path that is already racing a shutdown deadline, and changing it is a separate judgment call.index.tsalreadyawaitssaveAllTerminalStates()for every panel in an earlier shutdown phase, so the fire-and-forget save indestroyAllTerminalsis redundant on the only production call path — and it is the sole source of the rejection this PR is catching. Removing it would be the deeper fix, but it is more invasive than this change should be.Happy to adjust anything here — scope, naming, the test, or the comment wording.