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.
This commit is contained in:
Jinjing 2026-04-18 09:24:27 -07:00 committed by GitHub
parent bbf38cce54
commit c2ed4fbd3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 25 additions and 1 deletions

View file

@ -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 {

View file

@ -46,7 +46,12 @@ export class TerminalHost {
async createOrAttach(opts: CreateOrAttachOptions): Promise<CreateOrAttachResult> {
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)