From c2ed4fbd3bcb3529ad1771c2af506540c5c6f877 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:24:27 -0700 Subject: [PATCH] fix(terminal-host): avoid reattach to terminating session, force-kill on dispose (#801) Reattaching to a session where kill() has been called but the subprocess hasn't exited yet races the in-flight exit. Treat terminating sessions the same as fully-exited ones in createOrAttach, and have Session.dispose() force-kill a stuck subprocess and notify attached clients so we don't leak the process when the killTimer is cleared mid-flight. --- src/main/daemon/session.ts | 19 +++++++++++++++++++ src/main/daemon/terminal-host.ts | 7 ++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index e36e5ceb..bcf6ada3 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -178,6 +178,21 @@ export class Session { if (this._disposed) { return } + + // Why: if kill() was called but the subprocess hasn't exited yet, our + // killTimer is the only thing that would forceKill a stuck subprocess. + // Clearing it below without force-killing would leak an orphaned process, + // so mirror forceDispose(): issue the force-kill, notify attached clients + // (handleSubprocessExit is guarded by _disposed and won't broadcast later), + // and clear the terminating flag for a consistent final state. + const wasTerminating = this._isTerminating && this._state !== 'exited' + const clientsToNotify = wasTerminating ? this.attachedClients.slice() : [] + if (wasTerminating) { + this.subprocess.forceKill() + this._exitCode = -1 + this._isTerminating = false + } + this._disposed = true this._state = 'exited' @@ -193,6 +208,10 @@ export class Session { this.attachedClients = [] this.preReadyStdinQueue = [] this.emulator.dispose() + + for (const client of clientsToNotify) { + client.onExit(-1) + } } private handleSubprocessData(data: string): void { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index b1e7c72a..67e02ad9 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -46,7 +46,12 @@ export class TerminalHost { async createOrAttach(opts: CreateOrAttachOptions): Promise { const existing = this.sessions.get(opts.sessionId) - if (existing && existing.isAlive) { + // Why: a session that has been asked to terminate (kill() called but the + // subprocess hasn't exited yet) must not be reattached. Reattaching would + // hand the caller a handle that races with the in-flight exit, and any + // subsequent operation (write/kill/resize) would fail once the subprocess + // finally exits. Treat terminating sessions the same as fully-exited ones. + if (existing && existing.isAlive && !existing.isTerminating) { const snapshot = existing.getSnapshot() existing.detachAllClients() const token = existing.attachClient(opts.streamClient)