From 23b7df5fa97d0bfc5c3a6932aff50c1420318fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 22 Jul 2026 15:05:23 +0000 Subject: [PATCH] task runner: kill the whole process tree on Stop proc.kill() only signalled the direct child (bash), leaving a running ffmpeg grandchild orphaned and still encoding. now killTree() walks /proc, SIGTERMs the whole subtree (bash + ffmpeg), then SIGKILLs any straggler after 2s. Co-Authored-By: Claude Opus 4.8 --- src/servers/api/tasks/task-executor.ts | 56 +++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/servers/api/tasks/task-executor.ts b/src/servers/api/tasks/task-executor.ts index 589a30c2..ed951787 100644 --- a/src/servers/api/tasks/task-executor.ts +++ b/src/servers/api/tasks/task-executor.ts @@ -1,6 +1,6 @@ import type { ServerWebSocket } from 'bun'; import { join, isAbsolute } from 'node:path'; -import { mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; +import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs'; import { getTaskByDirName } from './task-files'; import { getHomeDirForRole, DATA_PATH } from '../../data-path'; import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox'; @@ -36,6 +36,58 @@ type OutMessage = // Active processes per WebSocket const activeProcs = new WeakMap, { proc: ReturnType; kill: () => void }>(); +// Every descendant PID of `root`, from a single /proc walk (Linux). Bun's proc.kill() signals only +// the direct child (bash), so a running grandchild like ffmpeg is left orphaned and keeps going. +function descendantPids(root: number): number[] { + const childrenOf = new Map(); + let entries: string[]; + try { + entries = readdirSync('/proc'); + } catch { + return []; + } + for (const entry of entries) { + const pid = Number(entry); + if (!Number.isInteger(pid)) continue; + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); + // "pid (comm) state ppid …" — comm may contain spaces/parens, so parse after the last ')'. + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + const ppid = Number(fields[1]); + if (!Number.isInteger(ppid)) continue; + const list = childrenOf.get(ppid); + if (list) list.push(pid); + else childrenOf.set(ppid, [pid]); + } catch { + // process vanished mid-scan + } + } + const out: number[] = []; + const stack = [root]; + while (stack.length > 0) { + const parent = stack.pop()!; + for (const child of childrenOf.get(parent) ?? []) { + out.push(child); + stack.push(child); + } + } + return out; +} + +// Terminate a process and its whole subtree: SIGTERM everything, then SIGKILL stragglers after a +// grace period. Killing the root (bash) too stops it from spawning the next file mid-batch. +function killTree(root: number) { + const pids = [root, ...descendantPids(root)]; + for (const pid of pids) { + try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ } + } + setTimeout(() => { + for (const pid of pids) { + try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } + } + }, 2000); +} + import { tmpdir } from 'node:os'; function send(ws: ServerWebSocket, msg: OutMessage) { @@ -179,7 +231,7 @@ async function handleRun(ws: ServerWebSocket, msg: RunMessage) { activeProcs.set(ws, { proc, kill: () => { - try { proc.kill(); } catch { /* already dead */ } + try { killTree(proc.pid); } catch { /* already dead */ } }, });