Describe the bug
On Windows, terminate() (and restart()) of a session created with ctx.terminals.startChildProcess() only kills the cmd.exe wrapper that tinyexec spawns for .cmd shims. The actual program started by the shim (e.g. node …/vitest.mjs) keeps running as an orphan and keeps its port. The session is reported as stopped even though the process is still alive.
How we hit it: in Vite DevTools (@vitejs/devtools 0.7.5), the Vitest UI launcher starts vitest --ui --no-open --watch --api.port <port> via startChildProcess(). After hub:terminals:terminate + remove, the session was gone from the hub, but the vitest node process (and a dart.exe child it had started) were still alive and still listening on the Vitest UI port. We had to kill the tree by hand with taskkill /T /F. A relaunch then gets a different port from get-port, so the orphans pile up silently.
Mechanism:
startChildProcess() calls tinyexec exec(command, args, …) (host-terminals.ts#L249-L262).
- On Windows, for a command resolved to a
.cmd shim in node_modules/.bin, tinyexec spawns cmd.exe /d /s /c "<shim> …". The ChildProcess pid devframe holds is the cmd.exe pid (spawnfile: 'cmd.exe').
terminate() / restart() / stream cancel() call cp.kill() (#L240, #L343-L355). On Windows that is TerminateProcess() on cmd.exe only. Windows does not kill descendants when a parent dies, so the shim's node child survives with a dead parent pid.
POSIX is not affected in the same way, since the shebang shim execs node directly and the signal reaches it.
A likely fix is to kill the whole tree on Windows (e.g. taskkill /pid <pid> /T /F) inside terminate(), restart() and the stream cancel(), and keep cp.kill(signal) elsewhere.
Reproduction
Minimal script that mirrors what startChildProcess() + terminate() do (tinyexec 1.3.1 from the devframe workspace). Save in an empty folder on Windows:
sleeper.cjs
require('node:http').createServer((q, s) => s.end('alive')).listen(Number(process.argv[2]), '127.0.0.1', () => console.log('listening pid', process.pid))
node_modules/.bin/sleeper.cmd (same shape as an npm/pnpm .bin shim)
@node "%~dp0\..\..\sleeper.cjs" %*
repro.mjs
import { exec } from 'tinyexec'
const port = 47811
const cp = exec('sleeper', [String(port)], { nodeOptions: { cwd: process.cwd(), stdio: 'pipe' } })
cp.process.stdout.on('data', d => process.stdout.write(`[child] ${d}`))
await new Promise(r => setTimeout(r, 4000))
console.log('pid devframe holds:', cp.process.pid, 'spawnfile:', cp.process.spawnfile)
cp.kill() // what terminate() does
await new Promise(r => setTimeout(r, 1500))
console.log('after kill(): signalCode=', cp.process.signalCode)
const r = await fetch(`http://127.0.0.1:${port}`).catch(() => null)
console.log(r ? `port STILL served -> ${r.status}` : 'port closed')
Output:
[child] listening pid 75464
pid devframe holds: 69856 spawnfile: cmd.exe
after kill(): signalCode= SIGTERM
port STILL served -> 200
netstat -ano still shows 127.0.0.1:47811 LISTENING 75464, and Win32_Process for 75464 reports ParentProcessId 69856 (the killed cmd.exe). The repro script itself also never exits, because the orphan still holds the inherited stdout/stderr pipes.
Expected: after terminate() the session's process tree is gone and the port is free.
Actual: only cmd.exe is killed; the real process keeps running and keeps the port.
System Info
OS: Windows 11 Pro 10.0.26200 (x64)
Node: 24.21.0
pnpm: 12.5.1
@devframes/hub: 1.0.0 (also upstream main 18fa60e)
tinyexec: 1.3.1
@vitejs/devtools / @vitejs/devtools-vitest: 0.7.5
vitest / @vitest/ui: 5.0.0
Used Package Manager
pnpm
Validations
Related: vitejs/devtools#585 (same Vitest UI launcher, separate bug).
Describe the bug
On Windows,
terminate()(andrestart()) of a session created withctx.terminals.startChildProcess()only kills thecmd.exewrapper that tinyexec spawns for.cmdshims. The actual program started by the shim (e.g.node …/vitest.mjs) keeps running as an orphan and keeps its port. The session is reported asstoppedeven though the process is still alive.How we hit it: in Vite DevTools (
@vitejs/devtools0.7.5), the Vitest UI launcher startsvitest --ui --no-open --watch --api.port <port>viastartChildProcess(). Afterhub:terminals:terminate+remove, the session was gone from the hub, but thevitestnode process (and adart.exechild it had started) were still alive and still listening on the Vitest UI port. We had to kill the tree by hand withtaskkill /T /F. A relaunch then gets a different port fromget-port, so the orphans pile up silently.Mechanism:
startChildProcess()calls tinyexecexec(command, args, …)(host-terminals.ts#L249-L262)..cmdshim innode_modules/.bin, tinyexec spawnscmd.exe /d /s /c "<shim> …". TheChildProcesspid devframe holds is thecmd.exepid (spawnfile: 'cmd.exe').terminate()/restart()/ streamcancel()callcp.kill()(#L240, #L343-L355). On Windows that isTerminateProcess()oncmd.exeonly. Windows does not kill descendants when a parent dies, so the shim'snodechild survives with a dead parent pid.POSIX is not affected in the same way, since the shebang shim
execs node directly and the signal reaches it.A likely fix is to kill the whole tree on Windows (e.g.
taskkill /pid <pid> /T /F) insideterminate(),restart()and the streamcancel(), and keepcp.kill(signal)elsewhere.Reproduction
Minimal script that mirrors what
startChildProcess()+terminate()do (tinyexec 1.3.1 from the devframe workspace). Save in an empty folder on Windows:sleeper.cjsnode_modules/.bin/sleeper.cmd(same shape as an npm/pnpm.binshim)repro.mjsOutput:
netstat -anostill shows127.0.0.1:47811 LISTENING 75464, andWin32_Processfor 75464 reportsParentProcessId 69856(the killedcmd.exe). The repro script itself also never exits, because the orphan still holds the inherited stdout/stderr pipes.Expected: after
terminate()the session's process tree is gone and the port is free.Actual: only
cmd.exeis killed; the real process keeps running and keeps the port.System Info
Used Package Manager
pnpm
Validations
Related: vitejs/devtools#585 (same Vitest UI launcher, separate bug).