Skip to content

fix: tree-kill a cancelled video render on Windows (#4171) - #4267

Merged
atomantic merged 5 commits into
mainfrom
claim/issue-4171
Aug 15, 2026
Merged

fix: tree-kill a cancelled video render on Windows (#4171)#4267
atomantic merged 5 commits into
mainfrom
claim/issue-4171

Conversation

@atomantic

@atomantic atomantic commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Summary

spawnDetached() (server/lib/detachedSpawn.js) has no POSIX sh for its double-fork on Windows, so that branch fell back to a plain spawn() and returned the bare ChildProcess. The cancel and watchdog paths in server/services/videoGen/local.js then call proc.kill('SIGKILL'), which terminates only the python runner — whatever the runner spawned (the ffmpeg mux a CUDA video runtime shells out to, a model download) survived as an orphan still holding its output file and, mid-render, GPU memory. killProcessGroup could not help: signalPid() implements it as a POSIX -pid signal, so the two runtimes that set it and run on Windows got nothing from it.

The win32 fallback now installs its own kill that delegates to killProcessTree(child, signal, { processGroup: true }) (server/lib/bufferedSpawn.js) — taskkill /T /F on Windows, the POSIX group signal elsewhere. That is a delegation to the existing shared helper, not a new mechanism.

  • The POSIX spawnDetached path is deliberately not rerouted through it: signalPid also serves reattached/reaped runs by raw pid, which killProcessTree (ChildProcess-only) does not.

  • killProcessTree is handed an object that inherits from the child (so pid reads through and its instanceof ChildProcess taskkill gate still holds) but carries Node's own kill, so the helper's POSIX fall-through can never re-enter the override.

  • child.killed is set by the override so the .killed-gated re-entrancy guards in videoGen/local.js (completion watchdog, idle-stall watchdog) engage on Windows too.

  • taskkill terminates the tree out of band, so libuv records no exit_signal and the child would report close(1, null) where Node's own kill() reported close(null, 'SIGKILL'). Callers classify on exactly that signal — isWatchdogSuccess() keeps a finished .mp4 only when signal === 'SIGKILL', and describeSignalDeath() reads it for the failure reason — so the handle re-stamps the requested signal onto its terminal events, matching both a native kill and the POSIX handle's decoded close. A concurrent clean exit (code 0) is left alone.

  • The override keeps the rest of the ChildProcess.kill() contract: it refuses to fire at a child that already reported a terminal code/signal (taskkill only gets a pid, and Windows recycles pids, so a late escalation could tree-kill whatever inherited the number), treats signal 0 as an existence probe, decodes a numeric signal to its name before stamping, and delegates an unknown signal to Node so it still throws ERR_UNKNOWN_SIGNAL.

Test plan

  • server/lib/detachedSpawn.test.js — new test asserts the win32 handle's kill('SIGKILL') calls killProcessTree exactly once with { processGroup: true }, that the target is still a real ChildProcess carrying the child's pid, and that it does not carry the override (no recursion). Verified it fails on the pre-fix source (expected "vi.fn()" to be called 1 times, but got 0 times).
  • Two more win32 tests drive a child that exits with a plain non-zero code and no signal — the shape taskkill /T /F produces — and assert close reports (null, 'SIGKILL'); and that a clean exit 0 racing a cancel is still reported as (0, null). Verified the signal test fails without the re-stamp (expected 1 to be null).
  • New companion test asserts the POSIX path never reaches killProcessTree, alongside the pre-existing reparent/kill/reattach/reap suite that covers survive-a-pm2-restart behavior.
  • Further win32 tests cover the kill-after-exit refusal, the signal-0 probe, a numeric kill(9) reported as 'SIGKILL', and an unknown signal throwing rather than force-killing the tree. These tests are not platform-gated, so their child is driven by node -e (no POSIX shell assumed).
  • CI green on all four gates, including the Windows server unit tests job, which runs these tests on a real win32 process.platform.
  • cd server && npm test — 1339 files / 27772 tests pass. 56 files fail identically on a clean main in this environment (no local PostgreSQL: Pipeline series require PostgreSQL, plus 4 unrelated health/updateExecutor assertions); none touch detachedSpawn, bufferedSpawn, or videoGen.
  • server/lib/index.test.js (barrel + README catalog guard) passes with the updated detachedSpawn.js README row.

Closes #4171

spawnDetached's win32 fallback returned a bare ChildProcess, so the cancel
and watchdog paths in videoGen/local.js killed only the python runner —
whatever it spawned (the ffmpeg mux, a model download) survived as an
orphan holding the output file and GPU memory. killProcessGroup couldn't
help: it is implemented as a POSIX `-pid` signal.

The win32 handle now gets its own kill that delegates to killProcessTree
(taskkill /T /F). The POSIX path is untouched — signalPid also serves
reattached/reaped runs by raw pid, which killProcessTree does not.
…'s terminal events

taskkill terminates the tree out of band, so libuv records no exit_signal
and the child reports close(1, null) where Node's own kill reported
close(null, 'SIGKILL'). videoGen's isWatchdogSuccess keeps a finished .mp4
only when signal === 'SIGKILL', so a completion/idle-stall kill on Windows
would have discarded the render as 'Exit code 1'. A concurrent clean exit
(code 0) is left alone.
…p signal 0 a probe

taskkill only gets a pid, and Windows recycles pids — a late escalation
against an already-exited child could tree-kill whatever inherited the
number. Refuse the kill once the child reports a terminal code/signal, and
delegate signal 0 (an existence probe) to Node's own kill instead of
force-killing the tree.
…efore stamping

kill() accepts a signal number, but ChildProcess reports signal NAMES on
close — stamping a raw 9 would break every `signal === 'SIGKILL'`
comparison downstream. Decode through the module's existing
SIGNAL_BY_NUMBER table; an unrecognized number stamps nothing.
…hell from the win32 tests

- reject an unknown signal through Node's own kill (ERR_UNKNOWN_SIGNAL)
  instead of silently force-killing the whole tree
- the win32 tests are not IS_POSIX-gated, so drive their child with
  node -e rather than sh -c, which a real Windows checkout may not have
- attach the close listener before killing in the POSIX no-tree-kill test
@atomantic
atomantic merged commit d73d3fa into main Aug 15, 2026
7 checks passed
@atomantic
atomantic deleted the claim/issue-4171 branch August 15, 2026 06:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tree-kill a cancelled video render on Windows so the runner's children die with it

1 participant