mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 18:41:42 +08:00
163 lines
7.1 KiB
TypeScript
163 lines
7.1 KiB
TypeScript
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const DIR = mkdtempSync(path.join(tmpdir(), 'claude-windows-job-'));
|
|
const FAKE = path.join(DIR, 'fake claude.ts');
|
|
const DESCENDANT = path.join(DIR, 'pipe holder.ts');
|
|
const PID_FILE = path.join(DIR, 'descendant.pid');
|
|
const PROVIDER_PID_FILE = path.join(DIR, 'provider.pid');
|
|
const CLI = path.join(ROOT, 'bin/gstack-claude-code');
|
|
const FAILED_JOB = path.join(DIR, 'failed job.ts');
|
|
|
|
// Exercise the initialization failure at the actual CLI boundary on every OS,
|
|
// without adding a production bypass flag for this safety requirement.
|
|
writeFileSync(FAILED_JOB, `
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
// OpenProcess(0) is invalid on Windows. Other OSes fail earlier opening the
|
|
// Windows DLL; both must fail closed before spawning the configured reviewer.
|
|
Object.defineProperty(process, 'pid', { value: 0 });
|
|
`);
|
|
|
|
// Publish readiness only after the grandchild has initialized and flushed both
|
|
// inherited pipes; a PID returned by spawn alone does not establish that state.
|
|
writeFileSync(DESCENDANT, `
|
|
import { writeFileSync } from 'node:fs';
|
|
setInterval(() => {}, 1000);
|
|
await new Promise(resolve => process.stdout.write(' ', resolve));
|
|
await new Promise(resolve => process.stderr.write(' ', resolve));
|
|
writeFileSync(process.env.PID_FILE!, String(process.pid));
|
|
`);
|
|
|
|
writeFileSync(FAKE, `
|
|
import { spawn } from 'node:child_process';
|
|
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
await Bun.stdin.text();
|
|
writeFileSync(process.env.PROVIDER_PID_FILE!, String(process.pid));
|
|
rmSync(process.env.PID_FILE!, { force: true });
|
|
const child = spawn(process.execPath, [process.env.DESCENDANT!], {
|
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
// Bypass this fake's libuv auto-kill job so the pipe holder survives it.
|
|
// DETACHED does not request CREATE_BREAKAWAY_FROM_JOB: gstack's enclosing
|
|
// job still owns the descendant, which the assertions below require dead.
|
|
detached: process.platform === 'win32' && process.env.FAKE_MODE === 'descendant',
|
|
});
|
|
const readyBy = Date.now() + 2000;
|
|
while (!existsSync(process.env.PID_FILE!)) {
|
|
if (child.exitCode !== null || Date.now() >= readyBy) throw new Error('Descendant did not initialize its inherited pipes');
|
|
await Bun.sleep(5);
|
|
}
|
|
if (process.env.FAKE_MODE === 'timeout') await new Promise(() => {});
|
|
await new Promise(resolve => process.stdout.write(JSON.stringify({ result: 'NO_FINDINGS' }), resolve));
|
|
process.exit(0);
|
|
`);
|
|
|
|
afterAll(() => rmSync(DIR, { recursive: true, force: true }));
|
|
|
|
function environment(mode: string): NodeJS.ProcessEnv {
|
|
return {
|
|
...process.env,
|
|
GSTACK_CLAUDE_BIN: process.execPath,
|
|
GSTACK_CLAUDE_BIN_ARGS: JSON.stringify([FAKE]),
|
|
FAKE_MODE: mode,
|
|
PID_FILE,
|
|
PROVIDER_PID_FILE,
|
|
DESCENDANT,
|
|
};
|
|
}
|
|
|
|
function alive(pid: number): boolean {
|
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
}
|
|
|
|
async function expectDead(pid: number) {
|
|
expect(Number.isSafeInteger(pid) && pid > 0).toBe(true);
|
|
for (let i = 0; i < 40 && alive(pid); i++) await Bun.sleep(25);
|
|
expect(alive(pid)).toBe(false);
|
|
}
|
|
|
|
function cleanupOwnedProcesses() {
|
|
for (const file of [PID_FILE, PROVIDER_PID_FILE]) {
|
|
try {
|
|
const pid = Number(readFileSync(file, 'utf8'));
|
|
if (Number.isSafeInteger(pid) && pid > 0 && alive(pid)) process.kill(pid, 'SIGKILL');
|
|
} catch { /* No owned process remains. */ }
|
|
}
|
|
}
|
|
|
|
// These exercise the actual standalone CLI boundary. A job must never be
|
|
// attached to the test runner process, which owns unrelated concurrent work.
|
|
describe('Windows Claude CLI job containment', () => {
|
|
test('job initialization failure stops before reviewer dispatch and emits a named error', () => {
|
|
rmSync(PID_FILE, { force: true });
|
|
const result = spawnSync(process.execPath, ['--preload', FAILED_JOB, CLI, '--cwd', DIR, '--access', 'none', '--timeout-ms', '2000'], {
|
|
env: environment('descendant'), input: 'review', encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
expect(result.status).toBe(1);
|
|
const parsed = JSON.parse(result.stdout);
|
|
expect(parsed.provider).toBe('claude-code');
|
|
expect(parsed.status).toBe('unavailable');
|
|
expect(parsed.error.code).toBe('supervision');
|
|
expect(parsed.error.message).toContain('Claude Code Windows process supervision could not initialize');
|
|
expect(() => readFileSync(PID_FILE)).toThrow();
|
|
});
|
|
|
|
for (const [mode, expected] of [['descendant', 'output-drain'], ['timeout', 'timeout']]) {
|
|
test.skipIf(process.platform !== 'win32')(`${mode} kills owned descendants and preserves a sibling process`, async () => {
|
|
rmSync(PID_FILE, { force: true });
|
|
rmSync(PROVIDER_PID_FILE, { force: true });
|
|
const sibling = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
|
|
const siblingClosed = new Promise<void>(resolve => sibling.once('close', () => resolve()));
|
|
const started = Date.now();
|
|
try {
|
|
const result = spawnSync(process.execPath, [CLI, '--cwd', DIR, '--access', 'none', '--timeout-ms', '2000'], {
|
|
env: environment(mode), input: 'review', encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status).toBe(1);
|
|
const parsed = JSON.parse(result.stdout);
|
|
expect(parsed.status).toBe('unavailable');
|
|
expect(parsed.error.code).toBe(expected);
|
|
expect(Date.now() - started).toBeLessThan(6000);
|
|
await expectDead(Number(readFileSync(PID_FILE, 'utf8')));
|
|
await expectDead(Number(readFileSync(PROVIDER_PID_FILE, 'utf8')));
|
|
expect(alive(sibling.pid!)).toBe(true);
|
|
} finally {
|
|
cleanupOwnedProcesses();
|
|
sibling.kill('SIGKILL');
|
|
await siblingClosed;
|
|
}
|
|
});
|
|
}
|
|
|
|
test.skipIf(process.platform !== 'win32')('abrupt runner exit closes the job and reaps its descendants', async () => {
|
|
rmSync(PID_FILE, { force: true });
|
|
rmSync(PROVIDER_PID_FILE, { force: true });
|
|
const runner = spawn(process.execPath, [CLI, '--cwd', DIR, '--access', 'none', '--timeout-ms', '10000'], {
|
|
env: environment('timeout'), stdio: ['pipe', 'ignore', 'ignore'],
|
|
});
|
|
const runnerClosed = new Promise<void>(resolve => runner.once('close', () => resolve()));
|
|
runner.stdin!.end('review');
|
|
try {
|
|
let descendant = 0;
|
|
for (let i = 0; i < 200 && !descendant; i++) {
|
|
try { descendant = Number(readFileSync(PID_FILE, 'utf8')); } catch { await Bun.sleep(25); }
|
|
}
|
|
expect(descendant).toBeGreaterThan(0);
|
|
runner.kill('SIGKILL');
|
|
await expectDead(descendant);
|
|
// The fake provider also owns the fixture cwd. Job termination starts
|
|
// every member's shutdown; leaf death alone does not prove it is done.
|
|
await expectDead(Number(readFileSync(PROVIDER_PID_FILE, 'utf8')));
|
|
} finally {
|
|
runner.kill('SIGKILL');
|
|
cleanupOwnedProcesses();
|
|
// Join the direct child and close its streams before fixture teardown.
|
|
await runnerClosed;
|
|
}
|
|
});
|
|
});
|