Sitelet https://github.com/devframes/devframe/issues/402
Skip to content

fix(hub): terminate()/restart() of a startChildProcess session orphans the real process on Windows #402

Description

@zahidzorbaz

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:

  1. startChildProcess() calls tinyexec exec(command, args, …) (host-terminals.ts#L249-L262).
  2. 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').
  3. 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

  • Follow our Code of Conduct
  • Read the Contributing Guide.
  • Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
  • Check that this is a concrete bug. For Q&A, please open a GitHub Discussion instead.
  • The provided reproduction is a minimal reproducible of the bug.

Related: vitejs/devtools#585 (same Vitest UI launcher, separate bug).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions