Files
gstack/test/helpers/qa-only-cleanup.ts
T
Garry Tan dcaea52800 v1.91.7.0 feat: add functional QA and pre-publication docs checks (#2983)
* feat: add surface-aware exploratory QA and ship documentation gates

* test: preserve delegated QA setup authority after main integration

* fix(qa): clarify exploration order and preserve report artifacts

* test(qa): follow the shared setup reference directly

* refactor(ship): make verification and recovery routes explicit

* test(ship): align evidence and review guards with explicit routes

* fix(workflows): clarify ship recovery and functional QA evidence

* fix(workflows): clarify approval recovery and full QA coverage

* refactor(workflows): order review transactions and clarify ship state

* fix(ship): clarify final verification and fail closed at publication

* fix(evals): attribute native atomic documentation writes

* fix(ship): clarify recovery and documentation lifecycle guidance

* fix(test): preserve observed native placeholder styling in CI

* fix(codex): report watchdog timeouts without a process-exit race

* Checkpoint functional QA implementation and workflow validation repairs

* Fix documentation and shared-review fixture contracts

* docs: clarify judge reuse and evaluation supervision

* test: align review evidence and selected case contracts

* test: verify append-only documentation checkpoints and recovery

* fix: qualify QA workflows and CI validation repairs

* fix: launch shared-libs fixture scripts on Windows

* fix: qualify QA deadlines, fixture isolation, and shard cleanup

* fix: preserve qualified QA and cancellation repairs

* fix: enforce functional fixture authority and share strict event decoding

* fix: retain free-test evidence and explain recovery

* fix: reject malformed native evidence after decoder consolidation

* test: use reliable capture for telemetry privacy filters

* test: refresh measured quick coverage and document validation costs

* Fix native fixture receipts and preserve VM validation evidence

* Align negative judge controls with upstream clarity policy

* Fix report-only QA preparation and public evidence handling

* Clarify QA-only preparation and current-report preservation

* Stream Ship quality judgments with an explicit 64k response contract

* Validate compact judge reasoning locally with supported wire schema

* Align functional QA fixture instructions with evidence acceptance

* Bind native browser diagnostics to execution evidence and align review verdicts

* Preserve native diagnostic line boundaries

* Serialize functional QA evidence from native captures

* Keep large QA evidence fixture payload out of Windows argv
2026-09-29 06:07:35 -07:00

141 lines
7.3 KiB
TypeScript

import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { isProcessAlive } from '../../browse/src/error-handling';
import { readPidCmdline, readPidStartTime } from '../../browse/src/xvfb';
import { isAgentRecordGone, isOurAgent, readAgentRecord } from '../../browse/src/terminal-agent-control';
export async function stopQaOnlyBrowser(directory: string, timeoutMs: number): Promise<void> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 100) throw new Error('QA-only browser cleanup: no settlement budget remains; retaining fixture');
const worker = Bun.spawn([process.execPath, import.meta.path, directory, String(timeoutMs - 100)], {
stdout: 'ignore', stderr: 'pipe',
});
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; worker.kill('SIGKILL'); }, timeoutMs);
const stderr = new Response(worker.stderr).text();
try {
const code = await worker.exited;
const error = await stderr;
if (timedOut) throw new Error('QA-only browser cleanup: owned worker settlement deadline exceeded; retaining fixture');
if (code !== 0) throw new Error(error.trim() || `QA-only browser cleanup: worker exited ${code}; retaining fixture`);
} finally { clearTimeout(timer); }
}
async function settleOwnedBrowser(directory: string, timeoutMs: number): Promise<void> {
const started = performance.now();
const deadline = started + timeoutMs;
let pending = ['identity verification'];
const fail = (message: string): never => { throw new Error(`QA-only browser cleanup: ${message}`); };
const remaining = () => {
const ms = Math.floor(deadline - performance.now());
if (ms <= 0) fail(`owned process settlement deadline exceeded (${pending.join(', ')}); retaining fixture`);
return ms;
};
if (fs.lstatSync(directory).isSymbolicLink()) fail('fixture directory is a link');
const root = fs.realpathSync(directory);
const stateDir = path.join(root, '.gstack');
if (!fs.existsSync(stateDir)) return;
if (fs.lstatSync(stateDir).isSymbolicLink()) fail('state directory is a link');
const stateFile = path.join(stateDir, 'browse.json');
const agentFile = path.join(stateDir, 'terminal-agent-pid');
for (const file of [stateFile, agentFile]) {
if (fs.existsSync(file) && !fs.lstatSync(file).isFile()) fail('state record is not a regular file');
}
if (!fs.existsSync(stateFile)) {
if (fs.existsSync(agentFile)) fail('terminal record has no daemon state');
return;
}
const raw = fs.readFileSync(stateFile, 'utf8');
const state = JSON.parse(raw);
if (!Number.isSafeInteger(state.pid) || state.pid <= 1
|| typeof state.instanceId !== 'string' || !state.instanceId) fail('invalid daemon identity');
const agentRaw = fs.existsSync(agentFile) ? fs.readFileSync(agentFile, 'utf8') : undefined;
const agent = readAgentRecord(stateDir);
if (!agentRaw || !agent) fail('terminal identity is unavailable');
if (!isProcessAlive(state.pid)) fail('daemon exited before its owned processes could be identified');
const daemonStart = readPidStartTime(state.pid);
const command = readPidCmdline(state.pid);
if (!daemonStart || !(typeof state.serverPath === 'string' && command.includes(state.serverPath)
|| command.includes('--server') && /browse/.test(command))) fail('daemon identity is unavailable');
let cwd: string;
if (process.platform === 'linux') cwd = fs.realpathSync(`/proc/${state.pid}/cwd`);
else {
const result = spawnSync('lsof', ['-a', '-p', String(state.pid), '-d', 'cwd', '-Fn'], {
encoding: 'utf8', timeout: Math.min(1000, remaining()),
});
if (result.error || result.status !== 0) fail('daemon working directory is unavailable');
cwd = result.stdout.split('\n').find(line => line.startsWith('n'))?.slice(1) ?? '';
}
if (cwd !== root) fail('daemon belongs to another fixture');
if (agent && (agent.ownerPid !== state.pid || agent.ownerStartTime !== daemonStart
|| !isAgentRecordGone(agent) && !isOurAgent(agent, state.pid))) fail('terminal ownership is unconfirmed');
const processes = spawnSync('ps', ['-eo', 'pid=,ppid='], {
encoding: 'utf8', timeout: Math.min(1000, remaining()),
});
if (processes.error || processes.status !== 0) fail('owned child identities are unavailable');
const rows = processes.stdout.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number));
const descendants = new Set<number>([state.pid]);
for (let previous = 0; previous !== descendants.size;) {
previous = descendants.size;
for (const [pid, parent] of rows) if (descendants.has(parent)) descendants.add(pid);
}
const chromium = [...descendants].filter(pid => pid !== state.pid && /chrom|headless_shell/i.test(readPidCmdline(pid)))
.map(pid => ({ pid, start: readPidStartTime(pid) }));
if (!chromium.length || chromium.some(child => !child.start)) fail('Chromium identity is unavailable');
if (state.chromiumPid !== undefined && !chromium.some(child => child.pid === state.chromiumPid
&& child.start === state.chromiumStartTime)) fail('Chromium ownership is unconfirmed');
const unchanged = () => {
if (fs.existsSync(stateFile) && fs.readFileSync(stateFile, 'utf8') !== raw) fail('daemon state was replaced');
if (fs.existsSync(agentFile) && fs.readFileSync(agentFile, 'utf8') !== agentRaw) fail('terminal state was replaced');
};
unchanged();
if (readPidStartTime(state.pid) !== daemonStart || readPidCmdline(state.pid) !== command) fail('daemon identity changed before stop');
unchanged();
remaining();
try { process.kill(state.pid, 'SIGINT'); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; }
const settled = (pid: number, start: string) => {
if (!isProcessAlive(pid)) return true;
const actual = readPidStartTime(pid);
if (actual && actual !== start) return true;
if (process.platform === 'linux') {
try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').match(/^\d+ \(.*\) ([A-Z])/u)?.[1] === 'Z'; }
catch { return !isProcessAlive(pid); }
}
const result = spawnSync('ps', ['-p', String(pid), '-o', 'stat='], {
encoding: 'utf8', timeout: Math.min(1000, remaining()),
});
return result.status === 0 && result.stdout.trim().startsWith('Z');
};
for (;;) {
unchanged();
remaining();
pending = [
...(!settled(state.pid, daemonStart) ? [`daemon ${state.pid}`] : []),
...chromium.filter(child => !settled(child.pid, child.start)).map(child => `Chromium ${child.pid}`),
...(agent && !isAgentRecordGone(agent) ? [`terminal ${agent.pid}`] : []),
];
if (!pending.length) {
unchanged();
remaining();
return;
}
if (pending.length === 1 && pending[0] === `daemon ${state.pid}`
&& performance.now() - started >= Math.min(1000, timeoutMs / 2)) {
if (readPidStartTime(state.pid) !== daemonStart || readPidCmdline(state.pid) !== command) fail('daemon identity changed before final termination');
unchanged();
try { process.kill(state.pid, 'SIGKILL'); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; }
}
await Bun.sleep(Math.min(25, remaining()));
}
}
if (import.meta.main) {
try { await settleOwnedBrowser(process.argv[2], Number(process.argv[3])); }
catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}