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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 15:05:23 +00:00
co-authored by Claude Opus 4.8
parent e68c8edc23
commit 23b7df5fa9
+54 -2
View File
@@ -1,6 +1,6 @@
import type { ServerWebSocket } from 'bun'; import type { ServerWebSocket } from 'bun';
import { join, isAbsolute } from 'node:path'; 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 { getTaskByDirName } from './task-files';
import { getHomeDirForRole, DATA_PATH } from '../../data-path'; import { getHomeDirForRole, DATA_PATH } from '../../data-path';
import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox'; import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox';
@@ -36,6 +36,58 @@ type OutMessage =
// Active processes per WebSocket // Active processes per WebSocket
const activeProcs = new WeakMap<ServerWebSocket<WSData>, { proc: ReturnType<typeof Bun.spawn>; kill: () => void }>(); const activeProcs = new WeakMap<ServerWebSocket<WSData>, { proc: ReturnType<typeof Bun.spawn>; 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<number, number[]>();
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'; import { tmpdir } from 'node:os';
function send(ws: ServerWebSocket<WSData>, msg: OutMessage) { function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
@@ -179,7 +231,7 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
activeProcs.set(ws, { activeProcs.set(ws, {
proc, proc,
kill: () => { kill: () => {
try { proc.kill(); } catch { /* already dead */ } try { killTree(proc.pid); } catch { /* already dead */ }
}, },
}); });