mirror of
https://github.com/smogon/sprites.git
synced 2026-08-18 07:16:46 -05:00
- Verify rename sources against recorded stats at copy time; colliding rename destinations/sources fall back to running the rule - Restrict rename detection to single-input, no-deps rules (sheet-style rules embed input names in output bytes) - Include input extensions in the rename template (magick picks output format from extensions) - Adopt only rules with no stored record, never known-dirty ones - Skip dotfiles in glob (tup parity) - Complete sheet dep closures (data/lib, lib/root) - Validate --jobs; reject non-numeric values instead of silently no-oping - Worker pool drains all workers before rethrowing; per-rule internal errors abort scheduling instead of racing db.close() - SIGKILL all live process groups on second Ctrl-C - Transactional loadStoredRules snapshot; dry run without existing state uses an in-memory db; idempotent DDL - rule() rejects % placeholders in literal outputs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
104 lines
3.5 KiB
TypeScript
104 lines
3.5 KiB
TypeScript
|
|
import {spawn} from 'child_process';
|
|
|
|
export interface ExecResult {
|
|
code : number | null;
|
|
signal : NodeJS.Signals | null;
|
|
output : string;
|
|
durationMs : number;
|
|
}
|
|
|
|
const livePids = new Set<number>();
|
|
|
|
// Emergency stop (e.g. second Ctrl-C): SIGKILL every live process group.
|
|
export function killAllProcessGroups() : void {
|
|
for (const pid of livePids) {
|
|
try {
|
|
process.kill(-pid, 'SIGKILL');
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
// The command script is fed to sh via stdin rather than -c: a single argv
|
|
// entry is capped by the kernel (MAX_ARG_STRLEN, ~128KB) and the largest %f
|
|
// expansion is already 80KB+.
|
|
export function runShell(command : string, opts : {cwd : string, signal : AbortSignal}) : Promise<ExecResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const start = performance.now();
|
|
// detached: own process group, so an abort kills grandchildren
|
|
// (magick, optipng, ...) with one signal
|
|
const child = spawn('sh', [], {cwd: opts.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe']});
|
|
if (child.pid !== undefined) {
|
|
livePids.add(child.pid);
|
|
}
|
|
const chunks : Buffer[] = [];
|
|
child.stdout.on('data', c => chunks.push(c));
|
|
child.stderr.on('data', c => chunks.push(c));
|
|
child.stdin.on('error', () => {}); // EPIPE if the shell exits early
|
|
child.stdin.end(command + '\n');
|
|
|
|
let killTimer : NodeJS.Timeout | undefined;
|
|
const kill = (sig : NodeJS.Signals) => {
|
|
try {
|
|
process.kill(-child.pid!, sig);
|
|
} catch {}
|
|
};
|
|
const onAbort = () => {
|
|
kill('SIGTERM');
|
|
killTimer = setTimeout(() => kill('SIGKILL'), 5000);
|
|
killTimer.unref();
|
|
};
|
|
if (opts.signal.aborted) {
|
|
onAbort();
|
|
} else {
|
|
opts.signal.addEventListener('abort', onAbort, {once: true});
|
|
}
|
|
|
|
const cleanup = () => {
|
|
if (child.pid !== undefined) {
|
|
livePids.delete(child.pid);
|
|
}
|
|
opts.signal.removeEventListener('abort', onAbort);
|
|
if (killTimer !== undefined) {
|
|
clearTimeout(killTimer);
|
|
}
|
|
};
|
|
child.on('error', err => {
|
|
cleanup();
|
|
reject(err);
|
|
});
|
|
child.on('close', (code, signal) => {
|
|
cleanup();
|
|
resolve({
|
|
code,
|
|
signal,
|
|
output: Buffer.concat(chunks).toString(),
|
|
durationMs: performance.now() - start,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// A worker that throws stops its own loop, but the pool always waits for
|
|
// every other worker to finish before rethrowing: failing fast here would
|
|
// return control (and e.g. close the database) while rules are still running.
|
|
export async function workerPool<T>(items : readonly T[], jobs : number,
|
|
fn : (item : T, index : number) => Promise<void>) : Promise<void> {
|
|
let next = 0;
|
|
const workers = [];
|
|
for (let i = 0; i < Math.max(1, Math.min(jobs, items.length)); i++) {
|
|
workers.push((async () => {
|
|
while (next < items.length) {
|
|
const index = next++;
|
|
await fn(items[index]!, index);
|
|
}
|
|
})());
|
|
}
|
|
const results = await Promise.allSettled(workers);
|
|
for (const result of results) {
|
|
if (result.status === 'rejected') {
|
|
throw result.reason;
|
|
}
|
|
}
|
|
}
|