Files
gstack/hosts/claude/hooks/spawn-bin.ts
T
Garry Tanandcapy-ai[bot] 6664e57e21 fix(hooks): question-log hook calls share the hook's 5 s budget, not a fixed 3 s each (#3948)
* fix(hooks): question-log hook children share the hook's 5 s budget instead of a fixed 3 s each

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Assert the killed child's signal-and-elapsed log line; pin hookChildBudgetMs to literal values and stop exporting HOOK_EXIT_RESERVE_MS.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hooks): read the question-log budget after the Git Bash lookup and skip under 250 ms

Scoped fix-check grant a-67d987ab49cc (GStack Minder). The changelog now claims only what the code guarantees.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-04 01:39:08 +00:00

210 lines
10 KiB
TypeScript

/**
* Windows-safe resolution + spawn for gstack's bash bins. Two Windows-only
* bugs made every hook subprocess a silent no-op; both are fixed here so all
* call sites are covered at once.
*
* 1. `new URL(import.meta.url).pathname` yields `/C:/Users/...`; path.resolve
* then rebases it onto the drive root as `C:\C:\Users\...`. fileURLToPath
* is the correct conversion. (ENOENT before the bin ever ran.)
* 2. `bin/gstack-*` are extensionless shebang scripts. Windows has no shebang
* support, so lib/script-invocation.ts hands them to Git Bash (or Bun).
*
* Also home to runExternal: the contained runner for EXTERNAL executables
* (third-party binaries a hook hands data to; see its doc comment). Unlike
* runBin it refuses win32, because its guarantee is process-group containment.
*/
import * as path from 'path';
import { fileURLToPath } from 'url';
import { spawn, type SpawnSyncOptions } from 'child_process';
import { resolveWindowsBashCached, runScriptSync } from '../../../lib/script-invocation';
import { resolveStateRoot } from '../../../lib/state-root';
/** gstack install root. This file lives at hosts/claude/hooks/. */
export function repoRoot(): string {
const here = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(here, '..', '..', '..');
}
/** Absolute path to a `bin/` script. */
export function binPath(name: string): string {
return path.join(repoRoot(), 'bin', name);
}
/**
* Resolve `name` under bin/ and run it through lib/script-invocation.ts. On
* Windows the Git Bash resolution is cached in the state root (keyed by the
* git.exe path and mtime), so a hook spawns nothing extra on the hot path.
*/
export function runBin(name: string, args: string[], opts: SpawnSyncOptions) {
return runScriptSync(binPath(name), args, opts, {
resolve: () => resolveWindowsBashCached(path.join(resolveStateRoot(), 'cache', 'windows-bash.json')),
});
}
/** Claude Code's timeout for a gstack hook, as setup registers it (`--timeout 5`). */
export const HOOK_TIMEOUT_MS = 5000;
/** Kept back from HOOK_TIMEOUT_MS so a hook logs and exits before Claude Code kills it. */
const HOOK_EXIT_RESERVE_MS = 500;
/**
* What is left of a hook's registered budget for its next child. Every child
* a hook spawns shares one deadline: a fixed per-child timeout either kills a
* cold child that would have finished in time, or lets several children run
* past the point where Claude Code kills the hook itself.
*/
export function hookChildBudgetMs(startedAtMs: number, nowMs: number = Date.now()): number {
return HOOK_TIMEOUT_MS - HOOK_EXIT_RESERVE_MS - (nowMs - startedAtMs);
}
/** Kept tail of the child's stderr, for the caller's error log. */
const STDERR_TAIL_BYTES = 500;
/** After a group kill, how long to wait for 'exit'/'close' before resolving anyway (kept under the callers' post-spawn reserve). */
const KILL_GRACE_MS = 100;
/** After the direct child exits, how long to keep draining stdout before resolving. */
const EXIT_DRAIN_MS = 150;
export interface RunExternalOptions {
/** bytes written to the child's stdin, then stdin is closed */
input?: Buffer | string;
/** wall-clock limit; on expiry the child's whole process group is SIGKILLed */
timeoutMs: number;
/** stdout cap in bytes; exceeding it kills the group and reports error 'ENOBUFS' (default 1 MiB) */
maxBuffer?: number;
/** the child's COMPLETE environment (callers allowlist; never pass process.env for a third-party binary) */
env?: Record<string, string | undefined>;
cwd?: string;
/** called once the child is running with a function that SIGKILLs its whole process group (for a caller's signal handler) */
onSpawn?: (killGroup: () => void) => void;
/** test seam: override process.platform */
platform?: NodeJS.Platform;
}
export interface RunExternalResult {
status: number | null;
signal: NodeJS.Signals | null;
stdout: Buffer;
/** last STDERR_TAIL_BYTES of stderr, for the error log — never forwarded */
stderrTail: string;
/** 'EPLATFORM' (win32 unsupported), 'ENOBUFS', 'ETIMEDOUT', or a spawn errno */
error?: string;
/**
* errno from writing the child's stdin (EPIPE when it exits before reading
* a large input). Advisory and separate from `error`: a child that exited 0
* with output still answered.
*/
stdinError?: string;
timedOut: boolean;
}
/**
* Run an EXTERNAL executable (not a gstack bin) with containment a hook can
* rely on:
* - `detached: true` makes the child a process-group leader, so a timeout
* kills the whole group (`process.kill(-pid)`) — a fork-style vendor shim
* cannot outlive the reported timeout the way a bare child kill allows.
* - resolves when the DIRECT child exits (after a short stdout drain), not
* only on 'close': a child that exits 0 but leaves a background process
* holding its pipes gets its output delivered and the straggler group-
* killed, instead of being reported as a timeout with its answer dropped.
* - NOTHING in the group outlives the call: the group is killed on every
* resolve, including a clean 'close' (a helper the child forked with its
* stdio redirected would otherwise run on unsupervised). A child that
* must leave a daemon behind has to setsid it; that is the child's
* explicit choice, visible in its own code, not an accident of ours.
* - a child that has already exited when the deadline fires keeps its
* result: the deadline then ends the drain, it does not rewrite a
* completed exit as a timeout.
* - stderr is drained continuously (an undrained pipe blocks a noisy child
* before it writes stdout) and only its tail is kept, never forwarded.
* - stdin gets an error listener, so a child that exits before reading a
* large input surfaces EPIPE as `stdinError`, not an unhandled event.
* - stdout is capped; the cap kills the group and reports ENOBUFS.
* - win32 is refused ('EPLATFORM'): there are no process groups to kill, so
* the containment guarantee cannot be given (Windows support for the
* bridges that use this is tracked in TODOS.md).
* Async on purpose: spawnSync can only signal the direct child.
*/
export function runExternal(exe: string, args: string[], opts: RunExternalOptions): Promise<RunExternalResult> {
const platform = opts.platform ?? process.platform;
const maxBuffer = opts.maxBuffer ?? 1024 * 1024;
const empty = (error: string): RunExternalResult =>
({ status: null, signal: null, stdout: Buffer.alloc(0), stderrTail: '', error, timedOut: false });
if (platform === 'win32') return Promise.resolve(empty('EPLATFORM'));
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(exe, args, {
detached: true,
cwd: opts.cwd,
env: opts.env as NodeJS.ProcessEnv | undefined,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (e) {
resolve(empty((e as NodeJS.ErrnoException)?.code ?? 'ESPAWN'));
return;
}
const chunks: Buffer[] = [];
let total = 0;
let stderrTail = '';
let error: string | undefined;
let stdinError: string | undefined;
let timedOut = false;
let done = false;
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
let drainTimer: ReturnType<typeof setTimeout> | undefined;
const killGroup = (): void => {
try { if (child.pid) process.kill(-child.pid, 'SIGKILL'); } catch { /* group already gone */ }
try { child.kill('SIGKILL'); } catch { /* already gone */ }
};
const finish = (status: number | null, signal: NodeJS.Signals | null): void => {
if (done) return;
done = true;
clearTimeout(timer);
if (graceTimer) clearTimeout(graceTimer);
if (drainTimer) clearTimeout(drainTimer);
// Nothing in the group outlives the call; a straggler holding our pipes
// must not pin this process either.
killGroup();
for (const s of [child.stdout, child.stderr, child.stdin]) { try { s?.destroy(); } catch { /* closed */ } }
try { child.unref(); } catch { /* fine */ }
resolve({ status, signal, stdout: Buffer.concat(chunks), stderrTail, ...(stdinError ? { stdinError } : {}), error, timedOut });
};
const timer = setTimeout(() => {
// The child already answered and exited; the deadline only ends the drain.
if (exited) { finish(exited.code, exited.signal); return; }
timedOut = true;
error = error ?? 'ETIMEDOUT';
killGroup();
// If 'exit' never arrives (a grandchild holding the pipes open past the
// kill), resolve anyway: the caller's own deadline is what matters.
graceTimer = setTimeout(() => finish(null, 'SIGKILL'), KILL_GRACE_MS);
}, Math.max(1, opts.timeoutMs));
opts.onSpawn?.(killGroup);
child.on('error', (e) => { error = (e as NodeJS.ErrnoException)?.code ?? 'ESPAWN'; finish(null, null); });
child.stdout?.on('data', (d: Buffer) => {
if (done) return;
total += d.length;
if (total > maxBuffer) { error = 'ENOBUFS'; killGroup(); return; }
chunks.push(d);
});
child.stderr?.on('data', (d: Buffer) => { stderrTail = (stderrTail + d.toString('utf8')).slice(-STDERR_TAIL_BYTES); });
child.on('exit', (code, signal) => {
exited = { code, signal };
if (done) return;
// A killed child (timeout, ENOBUFS) has nothing worth draining: resolve
// now so the caller keeps its post-spawn reserve.
if (timedOut || error) { finish(code, signal); return; }
// stdio may still be open (a background grandchild inherited the pipes):
// drain what the child itself wrote, then resolve with its real exit and
// kill whatever is still holding the group.
drainTimer = setTimeout(() => { killGroup(); finish(code, signal); }, EXIT_DRAIN_MS);
});
child.on('close', (code, signal) => finish(code, signal));
if (child.stdin) {
child.stdin.on('error', (e) => { stdinError = stdinError ?? ((e as NodeJS.ErrnoException)?.code ?? 'EPIPE'); });
if (opts.input !== undefined) child.stdin.end(opts.input); else child.stdin.end();
}
});
}