/** * celilo#1003: a hook that times out must be KILLED, not abandoned. * * `module-lifecycle`'s spec has always required this: * * > WHEN a hook produces no output for longer than the idle timeout * > THEN celilo SHALL terminate it rather than hang indefinitely * * The old executor raced the hook's promise against a timer and cancelled * nothing, because a promise cannot be cancelled. The existing suite asserted * the rejection, which held, and the requirement did not. * * So these tests assert the harm rather than the rejection: a marker file the * hook writes only after its bound has passed. Both of them fail against the * in-process executor and neither says anything about how the kill is * implemented. */ import { afterEach, describe, expect, test } from 'bun:test'; import { execSync } from 'node:child_process'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { executeHookScript } from './executor'; import { createCapturingLogger } from './logger'; import { configStore, secretStore } from './test-fixtures/store-backed'; import type { HookContext } from './types'; const FIXTURES = join(__dirname, 'test-fixtures'); const dirs: string[] = []; afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); function scratch(): string { const dir = mkdtempSync(join(tmpdir(), 'celilo-timeout-')); dirs.push(dir); return dir; } function contextFor(config: Record): HookContext { return { config: configStore(config), secrets: secretStore(), systems: [], logger: createCapturingLogger().logger, debug: false, screenshotDir: scratch(), stateDir: scratch(), capabilities: {}, }; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** * Hook processes still parented to this one. By ppid rather than by script * name: matching the name picks up whatever shell has the filename in its own * command line, which is a false positive that looks exactly like a real leak. */ function survivingHookProcesses(): string[] { return execSync('ps -Ao ppid=,args=', { encoding: 'utf-8' }) .split('\n') .filter( (line) => Number.parseInt(line.trim(), 10) === process.pid && line.includes('hook-runner'), ); } describe('hook timeout is a kill, not a race', () => { test('a hook that outruns its total timeout stops doing work', async () => { const marker = join(scratch(), 'kept-running'); await expect( executeHookScript( join(FIXTURES, 'runaway-hook.ts'), contextFor({ sleep_ms: 1500, marker_path: marker }), { timeoutMs: 400, idleTimeoutMs: 400 }, ), ).rejects.toThrow(/timeout/i); // Past when the abandoned hook would have written it. await sleep(2000); expect(existsSync(marker)).toBe(false); expect(survivingHookProcesses()).toEqual([]); }, 15_000); test('a hook that declines SIGTERM is killed anyway', async () => { const marker = join(scratch(), 'survived'); await expect( executeHookScript( join(FIXTURES, 'sigterm-ignoring-hook.ts'), contextFor({ sleep_ms: 6000, marker_path: marker }), { timeoutMs: 400, idleTimeoutMs: 400 }, ), ).rejects.toThrow(/timeout/i); await sleep(6500); expect(existsSync(marker)).toBe(false); expect(survivingHookProcesses()).toEqual([]); }, 20_000); });