Skip to content

Commit 781ceed

Browse files
fix(hub): kill the whole child-process tree on Windows (#407)
Co-authored-by: zahid emre zorbaz <zahid.zorbaz@live.com>
1 parent 18fa60e commit 781ceed

3 files changed

Lines changed: 154 additions & 12 deletions

File tree

‎packages/hub/src/node/__tests__/host-terminals.test.ts‎

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { DevframeTerminalSession } from '../../types/terminals'
22
import type { DevframeHubContext } from '../context'
3+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4+
import { tmpdir } from 'node:os'
5+
import { join } from 'node:path'
36
import process from 'node:process'
47
import { describe, expect, it, vi } from 'vitest'
58
import { hasNative } from 'zigpty'
@@ -71,8 +74,8 @@ function createTerminalHost() {
7174
}
7275
}
7376

74-
async function waitUntil(assertion: () => void): Promise<void> {
75-
const deadline = Date.now() + 1000
77+
async function waitUntil(assertion: () => void, timeout = 1000): Promise<void> {
78+
const deadline = Date.now() + timeout
7679
let lastError: unknown
7780
while (Date.now() < deadline) {
7881
try {
@@ -397,6 +400,104 @@ describe('devframeTerminalHost child-process status lifecycle', () => {
397400
})
398401
})
399402

403+
// On Windows, tinyexec runs anything that isn't a `.exe`/`.com` (e.g. the
404+
// `node_modules/.bin/*.cmd` shims package managers generate) through
405+
// `cmd.exe /d /s /c`, so the pid the host holds belongs to `cmd.exe`, not to
406+
// the program the shim starts. Killing only that pid leaves the real process
407+
// orphaned (still holding its ports).
408+
describe.runIf(process.platform === 'win32')('devframeTerminalHost child-process tree on Windows', { timeout: 20_000 }, () => {
409+
function isAlive(pid: number): boolean {
410+
try {
411+
process.kill(pid, 0)
412+
return true
413+
}
414+
catch {
415+
return false
416+
}
417+
}
418+
419+
async function startShimSession(host: DevframeTerminalsHost, id: string) {
420+
const dir = mkdtempSync(join(tmpdir(), 'devframe-terminals-shim-'))
421+
writeFileSync(join(dir, 'child.cjs'), 'console.log("pid:" + process.pid); setInterval(() => {}, 1000)\n')
422+
const shim = join(dir, 'child.cmd')
423+
writeFileSync(shim, `@"${NODE}" "%~dp0\\child.cjs" %*\r\n`)
424+
const session = await host.startChildProcess({ command: shim, args: [], cwd: dir }, { id, title: id })
425+
let pid = 0
426+
await waitUntil(() => {
427+
const match = session.buffer?.join('').match(/pid:(\d+)/)
428+
expect(match).toBeTruthy()
429+
pid = Number(match![1])
430+
}, 10_000)
431+
// The host holds the `cmd.exe` wrapper, not the node child.
432+
expect(session.getChildProcess()?.pid).not.toBe(pid)
433+
return { session, pid, cleanup: () => rmSync(dir, { recursive: true, force: true }) }
434+
}
435+
436+
it('terminate() kills the process started by a .cmd shim', async () => {
437+
const { host } = createTerminalHost()
438+
const { session, pid, cleanup } = await startShimSession(host, 'shim-terminate')
439+
try {
440+
await session.terminate()
441+
await waitUntil(() => expect(isAlive(pid)).toBe(false), 5000)
442+
expect(session.status).toBe('stopped')
443+
}
444+
finally {
445+
if (isAlive(pid))
446+
process.kill(pid)
447+
cleanup()
448+
}
449+
})
450+
451+
it('terminate() reports a stopped, killed run rather than a crash', async () => {
452+
const { host, sinks } = createTerminalHost()
453+
const { session, pid, cleanup } = await startShimSession(host, 'shim-result')
454+
const result = session.getResult()
455+
try {
456+
await session.terminate()
457+
await waitUntil(() => expect(sinks.get('shim-result')?.closed).toBe(true))
458+
const output = await result
459+
expect(output.exitCode).toBeUndefined()
460+
expect(result.killed).toBe(true)
461+
expect(session.status).toBe('stopped')
462+
}
463+
finally {
464+
if (isAlive(pid))
465+
process.kill(pid)
466+
cleanup()
467+
}
468+
})
469+
470+
it('restart() kills the previous run started by a .cmd shim', async () => {
471+
const { host } = createTerminalHost()
472+
const { session, pid, cleanup } = await startShimSession(host, 'shim-restart')
473+
try {
474+
await session.restart()
475+
await waitUntil(() => expect(isAlive(pid)).toBe(false), 5000)
476+
expect(session.status).toBe('running')
477+
await session.terminate()
478+
}
479+
finally {
480+
if (isAlive(pid))
481+
process.kill(pid)
482+
cleanup()
483+
}
484+
})
485+
486+
it('cancelling the stream kills the process started by a .cmd shim', async () => {
487+
const { host } = createTerminalHost()
488+
const { session, pid, cleanup } = await startShimSession(host, 'shim-cancel')
489+
try {
490+
host.remove(session)
491+
await waitUntil(() => expect(isAlive(pid)).toBe(false), 5000)
492+
}
493+
finally {
494+
if (isAlive(pid))
495+
process.kill(pid)
496+
cleanup()
497+
}
498+
})
499+
})
500+
400501
describe('devframeTerminalHost interactive PTY sessions', () => {
401502
itPty('inherits the parent process environment', async () => {
402503
expect.assertions(1)

‎packages/hub/src/node/host-terminals.ts‎

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,33 @@ const TERMINAL_BUFFER_LIMIT = 1000
3535
/** TERM handed to spawned PTYs; also used to reject fallback process labels. */
3636
const PTY_TERM_NAME = 'xterm-256color'
3737

38+
/**
39+
* Kill a `startChildProcess()` run together with everything it spawned.
40+
*
41+
* On Windows, tinyexec runs anything that isn't a `.exe`/`.com` - including
42+
* the `node_modules/.bin/*.cmd` shims package managers generate - through
43+
* `cmd.exe /d /s /c`, so the pid held here is the wrapper's. Killing a process
44+
* on Windows doesn't reach its descendants, so `cp.kill()` alone would leave
45+
* the real program running (and holding its ports). `taskkill /T /F` ends the
46+
* whole tree; `cp.kill()` stays the fallback and the POSIX path.
47+
*/
48+
async function killProcessTree(cp: TinyExecResult): Promise<void> {
49+
const child = cp.process
50+
const pid = child?.pid
51+
if (process.platform !== 'win32' || !child || pid === undefined || child.exitCode !== null || child.signalCode !== null) {
52+
cp.kill()
53+
return
54+
}
55+
const { exec } = await import('tinyexec')
56+
try {
57+
const { exitCode } = await exec('taskkill', ['/pid', String(pid), '/T', '/F'])
58+
if (exitCode === 0)
59+
return
60+
}
61+
catch {}
62+
cp.kill()
63+
}
64+
3865
export class DevframeTerminalsHost implements DevframeTerminalsHostType {
3966
public readonly sessions: DevframeTerminalsHostType['sessions'] = new Map()
4067
public readonly events: DevframeTerminalsHostType['events'] = createEventEmitter()
@@ -231,15 +258,26 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
231258
let cp: TinyExecResult | undefined
232259
let currentResult: DevframeChildProcessResult | undefined
233260
let runId = 0
261+
// Runs stopped on purpose (terminate/restart/cancel). On Windows the tree
262+
// kill ends them with exit code 1 rather than a signal, so this keeps them
263+
// reported as a deliberate stop instead of a crash on every platform.
264+
const hostKilled = new WeakSet<TinyExecResult>()
265+
const killRun = (target: TinyExecResult | undefined): Promise<void> => {
266+
if (!target)
267+
return Promise.resolve()
268+
hostKilled.add(target)
269+
return killProcessTree(target)
270+
}
234271

235272
const stream = new ReadableStream<string>({
236273
start(_controller) {
237274
state.controller = _controller
238275
},
239276
cancel() {
240-
cp?.kill()
277+
const target = cp
241278
cp = undefined
242279
closeStream()
280+
return killRun(target)
243281
},
244282
})
245283

@@ -310,26 +348,27 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
310348
markStatus('error')
311349
})
312350
cp.process?.once('close', (code) => {
313-
settle(code ?? undefined)
351+
const killed = hostKilled.has(cp)
352+
settle(killed ? undefined : code ?? undefined)
314353
if (currentRun !== runId)
315354
return
316355
closeStream()
317356
// A spawn/runtime error already settled the status; a non-zero exit
318-
// code is a crash. A clean exit, or a signal kill (no numeric code,
319-
// e.g. terminate()/restart()), is a deliberate/normal stop.
357+
// code is a crash. A clean exit, or a kill by the host (terminate()/
358+
// restart()), is a deliberate/normal stop.
320359
if (!runErrored)
321-
markStatus(typeof code === 'number' && code !== 0 ? 'error' : 'stopped')
360+
markStatus(!killed && typeof code === 'number' && code !== 0 ? 'error' : 'stopped')
322361
})
323362

324363
currentResult = {
325364
get pid() {
326365
return cp.process?.pid
327366
},
328367
get exitCode() {
329-
return cp.process?.exitCode ?? undefined
368+
return hostKilled.has(cp) ? undefined : cp.process?.exitCode ?? undefined
330369
},
331370
get killed() {
332-
return cp.process?.killed === true
371+
return hostKilled.has(cp) || cp.process?.killed === true
333372
},
334373
kill: signal => cp.kill(signal),
335374
then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected),
@@ -343,13 +382,15 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
343382
const restart = async () => {
344383
if (state.streamClosed)
345384
throw diagnostics.DF8206({ id: terminal.id })
346-
cp?.kill()
385+
// Wait for the old tree to go away so the new run can reclaim its ports.
386+
await killRun(cp)
347387
cp = createChildProcess()
348388
markStatus('running')
349389
}
350390
const terminate = async () => {
351-
cp?.kill()
391+
const target = cp
352392
cp = undefined
393+
await killRun(target)
353394
closeStream()
354395
markStatus('stopped')
355396
}

‎packages/hub/src/types/terminals.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export interface DevframeChildProcessExecuteOptions {
7979
* The settled outcome of a {@link DevframeChildProcessTerminalSession} run:
8080
* stdout/stderr captured separately (unlike the session's merged display
8181
* `stream`), plus the process's exit code (`undefined` if it was killed by a
82-
* signal before exiting).
82+
* signal, or by `terminate()`/`restart()`, before exiting).
8383
*/
8484
export interface DevframeChildProcessOutput {
8585
stdout: string

0 commit comments

Comments
 (0)