mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-01 10:50:39 +02:00
refactor(evals): paid shards spool to disk + shared runShardChild lifecycle
- runPaidShard no longer buffers whole 30-min stream-json streams in RAM (x concurrent jobs): every byte tees to a per-shard log file (slug-named, path printed at START for mid-run inspection and on the FAILED terminal line); failures print a 64KiB tail read back from disk; passing shards stay quiet (the file is the record) — the free runner's proven contract. Classification unchanged: the strict classifier still sees every byte first. - the ~35 duplicated spawn/group-kill/wall-timer/finally-reap lines move into runShardChild in test-strict-output.ts (detached-per- platform spawn, signal forwarding, SIGKILL group kill at the wall, drain-before-verdict); designed so the free runner can migrate later - expectedFiles drift fixed toward ENFORCEMENT: the injected-command exemption is gone — a fake command exiting 0 without bun's terminal summary now reads FAILED (pinned: silent-pass → failed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6ef8aaba65
commit
05bca51961
@@ -11,6 +11,7 @@
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -115,10 +116,17 @@ describe('tier classification', () => {
|
||||
describe('shard execution', () => {
|
||||
const BUSY_LOOP = 'const end = Date.now() + 600000; while (Date.now() < end) {}';
|
||||
|
||||
// PIN UPDATE (deliberate): the strict expectedFiles check is now enforced
|
||||
// for injected fake commands too (drift fix toward the free runner's
|
||||
// behavior), so a fake PASSING command must print a synthetic bun terminal
|
||||
// summary — a summary-less exit 0 is the truncation class and reads FAILED.
|
||||
const PASS_WITH_SUMMARY = 'console.log("ok"); console.log("Ran 1 tests across 1 files. [1ms]")';
|
||||
|
||||
const commandFor = (files: string[]) => {
|
||||
if (files[0] === 'spin') return { command: process.execPath, args: ['-e', BUSY_LOOP] };
|
||||
if (files[0] === 'fail') return { command: process.execPath, args: ['-e', 'process.exit(3)'] };
|
||||
return { command: process.execPath, args: ['-e', 'console.log("ok")'] };
|
||||
if (files[0] === 'silent-pass') return { command: process.execPath, args: ['-e', 'console.log("ok")'] };
|
||||
return { command: process.execPath, args: ['-e', PASS_WITH_SUMMARY] };
|
||||
};
|
||||
|
||||
test('a spinning shard times out, is killed, and the run continues', async () => {
|
||||
@@ -153,6 +161,48 @@ describe('shard execution', () => {
|
||||
expect(lines.some((l) => /PASSED in \d+s/.test(l))).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
test('exit 0 WITHOUT the terminal summary is FAILED — enforced for injected commands too', async () => {
|
||||
// The invisible-non-execution backstop: previously the paid runner
|
||||
// exempted injected commandFor from the expectedFiles check, so a fake
|
||||
// that exited 0 without bun's terminal summary recorded 'passed'. Now it
|
||||
// matches the free runner: enforcement always on.
|
||||
const summary = await runPaidShards([['silent-pass']], {
|
||||
timeoutMs: 30_000, jobs: 1, commandFor, log: () => {},
|
||||
});
|
||||
expect(summary.outcomes[0].status).toBe('failed');
|
||||
}, 30_000);
|
||||
|
||||
test('shard output spools to a per-shard log file; failures name the path', async () => {
|
||||
const logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paid-shard-logs-'));
|
||||
const lines: string[] = [];
|
||||
try {
|
||||
const summary = await runPaidShards([['fail'], ['pass']], {
|
||||
timeoutMs: 30_000, jobs: 2, commandFor, logDir, log: (line) => lines.push(line),
|
||||
});
|
||||
const byName = (name: string) => summary.outcomes.find((o) => o.files[0] === name) as ShardOutcome;
|
||||
expect(byName('fail').status).toBe('failed');
|
||||
expect(byName('pass').status).toBe('passed');
|
||||
|
||||
// One log per shard, named by slug, and it holds the child's full stream
|
||||
// (nothing buffered in RAM: the file IS the record).
|
||||
const logs = fs.readdirSync(logDir).sort();
|
||||
expect(logs.length).toBe(2);
|
||||
expect(logs.some((f) => f.includes('fail'))).toBe(true);
|
||||
const passLog = logs.find((f) => f.includes('pass')) as string;
|
||||
expect(fs.readFileSync(path.join(logDir, passLog), 'utf8')).toContain('Ran 1 tests across 1 files.');
|
||||
|
||||
// Every shard announces its log path up front; the FAILED terminal line
|
||||
// repeats it, the PASSED one stays clean.
|
||||
expect(lines.filter((l) => l.includes('full log:') && !l.includes('FAILED')).length).toBe(2);
|
||||
const failLine = lines.find((l) => l.includes('FAILED')) as string;
|
||||
expect(failLine).toContain(logDir);
|
||||
const passLine = lines.find((l) => l.includes('PASSED')) as string;
|
||||
expect(passLine).not.toContain(logDir);
|
||||
} finally {
|
||||
fs.rmSync(logDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('summarize reports shards that never ran', () => {
|
||||
const summary = summarize([
|
||||
{ shard: 1, files: ['a'], status: 'passed', exitCode: 0, elapsedMs: 1, groupPid: 1 },
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Direct pins for runShardChild (scripts/test-strict-output.ts) — the shared
|
||||
* spawn/detached/group-kill/wall-timer/reap lifecycle extracted from the paid
|
||||
* runner's runPaidShard, designed for scripts/test-free-shards.ts to migrate
|
||||
* onto next. test/paid-shards.test.ts pins the paid runner end-to-end; these
|
||||
* pin the helper's own contract so the free-runner migration has a floor.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { runShardChild } from '../scripts/test-strict-output';
|
||||
|
||||
/** Collect the child's full stdout+stderr, resolving only when drained. */
|
||||
function collectingHook(chunks: string[]) {
|
||||
return (child: ChildProcess): Array<Promise<void>> => {
|
||||
const consume = (stream: NodeJS.ReadableStream | null): Promise<void> =>
|
||||
stream
|
||||
? new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk: Buffer | string) => chunks.push(chunk.toString()));
|
||||
stream.on('end', resolve);
|
||||
stream.on('error', reject);
|
||||
})
|
||||
: Promise.resolve();
|
||||
return [consume(child.stdout), consume(child.stderr)];
|
||||
};
|
||||
}
|
||||
|
||||
describe('runShardChild', () => {
|
||||
test('clean exit: exitCode 0, not timed out, output drained before resolve', async () => {
|
||||
const chunks: string[] = [];
|
||||
const result = await runShardChild({
|
||||
command: process.execPath,
|
||||
args: ['-e', 'console.log("hello-from-child")'],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
timeoutMs: 30_000,
|
||||
hookStreams: collectingHook(chunks),
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.groupPid).toBeGreaterThan(0);
|
||||
// The hookStreams promises are awaited AFTER close — trailing output is
|
||||
// fully drained before callers read their classifier/log state.
|
||||
expect(chunks.join('')).toContain('hello-from-child');
|
||||
}, 30_000);
|
||||
|
||||
test('non-zero exit code propagates untouched', async () => {
|
||||
const result = await runShardChild({
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.exit(7)'],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
timeoutMs: 30_000,
|
||||
hookStreams: () => [],
|
||||
});
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.timedOut).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
test('a spinning child is group-SIGKILLed at the wall deadline and reported timedOut', async () => {
|
||||
const startedAt = Date.now();
|
||||
const result = await runShardChild({
|
||||
command: process.execPath,
|
||||
// A real busy loop: an in-process timer could never fire in this child.
|
||||
args: ['-e', 'const end = Date.now() + 600000; while (Date.now() < end) {}'],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
timeoutMs: 1_200,
|
||||
hookStreams: () => [],
|
||||
});
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(Date.now() - startedAt).toBeLessThan(30_000);
|
||||
if (process.platform !== 'win32') {
|
||||
// The whole group is gone, not left to burn a core.
|
||||
expect(() => process.kill(result.groupPid as number, 0)).toThrow();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('a spawn failure THROWS so callers keep their could-not-run handling', async () => {
|
||||
await expect(runShardChild({
|
||||
command: path.join(os.tmpdir(), 'definitely-not-a-real-binary-8b1f'),
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
timeoutMs: 5_000,
|
||||
hookStreams: () => [],
|
||||
})).rejects.toThrow();
|
||||
}, 30_000);
|
||||
});
|
||||
Reference in New Issue
Block a user