Files
PaperClipAI/scripts/prefetch-child.mjs

61 lines
2.9 KiB
JavaScript

// Background downloads for verification scripts: start a child early with its
// output in a log file, join it where the result is needed, and stop it before
// the scratch directory it writes into is removed. No shell, no inherited
// terminal, and a failure is reported where the caller awaits it.
import { spawn } from 'node:child_process';
import { closeSync, openSync, readFileSync, statSync } from 'node:fs';
const running = child => child.pid !== undefined && child.exitCode === null && child.signalCode === null;
// Returns { name, child, done, durationMs }. `done` resolves on exit code 0
// and otherwise rejects with the tail of the log; the rejection is observed
// here so an unawaited failure never surfaces as an unhandled rejection.
export function startPrefetch({ name, cmd, args, cwd, env, logPath }) {
const startedAt = Date.now();
const log = openSync(logPath, 'w');
let child;
try {
child = spawn(cmd, args, { cwd, env, stdio: ['ignore', log, log] });
} finally {
closeSync(log); // the child holds its own descriptor
}
const handle = { name, child, done: null, durationMs: null };
let settled = false;
handle.done = new Promise((settle, reject) => {
const finish = failure => {
if (settled) return;
settled = true;
// The caller may be blocked in synchronous work when the child exits, so
// this event can arrive long after the fact. When the child wrote
// anything, its last write is the end time; both downloads this serves
// print their final status line as they complete. A silent child falls
// back to the time the exit was observed.
let finishedAt = Date.now();
try {
const { mtimeMs, size } = statSync(logPath);
if (size > 0) finishedAt = Math.min(finishedAt, mtimeMs);
} catch {}
handle.durationMs = Math.max(0, Math.round(finishedAt - startedAt));
if (failure === null) return settle();
let output = '';
try { output = readFileSync(logPath, 'utf8').trim().slice(-2000); } catch {}
reject(new Error(`${name} prefetch failed (${failure}): ${output}`));
};
child.once('error', error => finish(error.message));
child.once('exit', (code, signal) => finish(code === 0 ? null : (signal ?? `exit ${code}`)));
});
handle.done.catch(() => {});
return handle;
}
// Stops every prefetch that is still running and resolves only once all of
// them have exited, so the caller can remove their working directory without
// racing a write. A child that ignores SIGTERM is killed after `graceMs`.
export async function stopPrefetches(prefetches, { graceMs = 5000 } = {}) {
await Promise.all(prefetches.map(async ({ child, done }) => {
if (running(child)) child.kill('SIGTERM');
const escalate = setTimeout(() => { if (running(child)) child.kill('SIGKILL'); }, graceMs);
try { await done; } catch {} finally { clearTimeout(escalate); }
}));
}