Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions .agents/skills/verify-session-lifecycle/scripts/replay-hold.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
// <nth> hold the nth matching call (1 = the first) in that process
//
// The detached host inherits NODE_OPTIONS and REPLAY_HOLD from `create`.
// Set REPLAY_LOG=<file> to log each hold.
import { appendFileSync } from 'node:fs';
// Set REPLAY_LOG=<file> to log each hold. Set REPLAY_RELEASE=<file> to end
// every hold as soon as that file exists, so <ms> is only a cap.
import { appendFileSync, existsSync } from 'node:fs';
import fsp from 'node:fs/promises';
import { syncBuiltinESMExports } from 'node:module';
import process from 'node:process';
Expand Down Expand Up @@ -42,6 +43,17 @@ const holds = (process.env.REPLAY_HOLD ?? '')

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function holdFor(ms) {
const release = process.env.REPLAY_RELEASE;
const end = Date.now() + ms;
while (Date.now() < end) {
if (release !== undefined && existsSync(release)) {
return;
}
await sleep(Math.min(release === undefined ? ms : 50, end - Date.now()));
}
}

for (const call of new Set(holds.map((hold) => hold.call))) {
const original = fsp[call];
const mine = holds.filter((hold) => hold.call === call);
Expand All @@ -61,12 +73,12 @@ for (const call of new Set(holds.map((hold) => hold.call))) {
}
log(`hold ${call} ${path} for ${String(hold.ms)} ms`);
if (HOLD_BEFORE.has(call)) {
await sleep(hold.ms);
await holdFor(hold.ms);
log(`release ${call} ${path}`);
return original(...args);
}
const result = await original(...args);
await sleep(hold.ms);
await holdFor(hold.ms);
log(`release ${call} ${path}`);
return result;
};
Expand Down
23 changes: 22 additions & 1 deletion test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ export function destroySession(testHome: string, sessionId: string): void {
});
}

export function crashSession(testHome: string, sessionId: string): void {
// Returns the pid of the host that it killed.
export function crashSession(testHome: string, sessionId: string): number {
const session = inspectSession(testHome, sessionId);
const hostPid = session.hostPid;
expect(hostPid).toBeTypeOf('number');
Expand All @@ -262,6 +263,26 @@ export function crashSession(testHome: string, sessionId: string): void {
} catch {
// Process may already be dead
}
return hostPid;
}

// A killed process stays a zombie until its parent, init here, reaps it.
export async function processEnds(pid: number): Promise<boolean> {
const deadline = Date.now() + 10_000;
for (;;) {
try {
process.kill(pid, 0);
} catch (error) {
if (hasErrorCode(error, 'ESRCH')) {
return true;
}
throw error;
}
if (Date.now() > deadline) {
return false;
}
await sleep(50);
}
}

export function inspectSession(
Expand Down
79 changes: 55 additions & 24 deletions test/integration/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
inspectSession,
isGroupAlive,
killGroupIfAlive,
processEnds,
readEvents,
runCli,
sleep,
Expand Down Expand Up @@ -101,7 +102,9 @@ interface GcResult {

let testHome = '';

describe('lifecycle integration', { timeout: 30000 }, () => {
// Many tests spend 5 to 15 s in real waits and CLI starts, and a loaded
// runner takes up to three times as long.
describe('lifecycle integration', { timeout: 60_000 }, () => {
beforeEach(async () => {
// oxfmt-ignore
testHome = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-home-')));
Expand Down Expand Up @@ -656,8 +659,7 @@ describe('lifecycle integration', { timeout: 30000 }, () => {
const beforeCrash = inspectSession(testHome, sessionId);
expect(beforeCrash.status).toBe('running');

crashSession(testHome, sessionId);
await sleep(500);
expect(await processEnds(crashSession(testHome, sessionId))).toBe(true);

const afterCrash = inspectSession(testHome, sessionId);
expect(afterCrash.status).toBe('failed');
Expand Down Expand Up @@ -698,8 +700,7 @@ describe('lifecycle integration', { timeout: 30000 }, () => {
'session directory should exist on disk before stale-host reconciliation',
).resolves.toBeDefined();

crashSession(testHome, sessionId);
await sleep(1000);
expect(await processEnds(crashSession(testHome, sessionId))).toBe(true);

const listResult = runCli(['list', '--json'], {
AGENT_TTY_HOME: testHome,
Expand Down Expand Up @@ -1106,8 +1107,7 @@ describe('lifecycle integration', { timeout: 30000 }, () => {
const sessionId = await createProgram(
'trap "" TERM HUP; sleep 300 & touch "$0"; wait',
);
crashSession(testHome, sessionId);
await sleep(500);
expect(await processEnds(crashSession(testHome, sessionId))).toBe(true);
expect(isGroupAlive(pgid)).toBe(true);

expect(inspectSession(testHome, sessionId).status).toBe('failed');
Expand Down Expand Up @@ -1667,6 +1667,7 @@ describe('lifecycle integration', { timeout: 30000 }, () => {

// The held start-up write keeps the host from listening past create's
// readiness loop, as a host that starts slowly on a loaded machine does.
// The hold ends once create has given up.
// Without a session token, program.json names no group that a CLI can
// prove, so only the host can stop the program.
describe.each([
Expand All @@ -1689,6 +1690,7 @@ describe('lifecycle integration', { timeout: 30000 }, () => {

it('lets its host stop the program and record destroyed', async () => {
const holdLog = join(testHome, 'hold.log');
const release = join(testHome, 'release');
const creator = spawn(
process.execPath,
[
Expand All @@ -1708,7 +1710,8 @@ describe('lifecycle integration', { timeout: 30000 }, () => {
...process.env,
AGENT_TTY_HOME: testHome,
REPLAY_LOG: holdLog,
REPLAY_HOLD: '_host:rename:/session.json:1:10000',
REPLAY_RELEASE: release,
REPLAY_HOLD: '_host:rename:/session.json:1:60000',
NODE_OPTIONS: [
process.env.NODE_OPTIONS ?? '',
`--import=${pathToFileURL(resolve('.agents/skills/verify-session-lifecycle/scripts/replay-hold.mjs')).href}`,
Expand Down Expand Up @@ -1747,14 +1750,15 @@ describe('lifecycle integration', { timeout: 30000 }, () => {
expect(isGroupAlive(pgid)).toBe(true);

const [code] = (await creatorExit) as [number | null];
await writeFile(release, '');

expect(code).toBe(6);
expect(JSON.parse(stdout)).toMatchObject({
ok: false,
error: { code: 'HOST_UNREACHABLE', details: { sessionId } },
});
// The host hangs the program up, then kills its group after the
// shutdown grace, and exits once its held write has landed.
// shutdown grace, and exits once its released write has landed.
const stopDeadline = Date.now() + 20_000;
while (isGroupAlive(pgid) || isProcessAlive(hostPid)) {
expect(Date.now()).toBeLessThan(stopDeadline);
Expand Down Expand Up @@ -1883,38 +1887,61 @@ describe('lifecycle integration', { timeout: 30000 }, () => {
// The program can run destroy before the host's start-up write names its
// pid. Holding that write keeps create's manifest, with no childPid, for
// the whole destroy. The hold matches the host's argv only, so the inner
// CLI, which inherits it, runs at full speed.
// CLI, which inherits it, runs at full speed. A held host never answers
// create, which gives up after about 5 s and sends it SIGTERM. On a slow
// runner that came before the inner destroy, so create is killed once the
// program runs, and the destroy kills the held host.
it('destroy --force before the host names the program prints its result', async () => {
const out = join(testHome, 'early-destroy.json');
runCli(
const started = `${out}.started`;
const create = spawn(
process.execPath,
[
'--import',
'tsx',
'./src/cli/main.ts',
'create',
'--json',
'--',
'/bin/sh',
'-c',
'"$@" destroy "$AGENT_TTY_SESSION_ID" --force --json > "$0"',
': > "$0.started"; "$@" destroy "$AGENT_TTY_SESSION_ID" --force --json > "$0"',
out,
...cli,
],
{
AGENT_TTY_HOME: testHome,
NODE_OPTIONS: [
process.env.NODE_OPTIONS ?? '',
`--import=${pathToFileURL(resolve('.agents/skills/verify-session-lifecycle/scripts/replay-hold.mjs')).href}`,
].join(' '),
REPLAY_HOLD: '_host:rename:/session.json:1:4000',
cwd: process.cwd(),
env: {
...process.env,
AGENT_TTY_HOME: testHome,
NODE_OPTIONS: [
process.env.NODE_OPTIONS ?? '',
`--import=${pathToFileURL(resolve('.agents/skills/verify-session-lifecycle/scripts/replay-hold.mjs')).href}`,
].join(' '),
REPLAY_HOLD: '_host:rename:/session.json:1:120000',
},
stdio: 'ignore',
},
);
try {
const deadline = Date.now() + 20_000;
while (!existsSync(started)) {
expect(Date.now()).toBeLessThan(deadline);
await sleep(50);
}
} finally {
create.kill('SIGKILL');
}
const [sessionId] = await readdir(join(testHome, 'sessions'));

expect(await readEnvelopeOnceWritten(out)).toMatchObject({
ok: true,
result: { sessionId, destroyed: true },
});
expect(inspectSession(testHome, sessionId as string).status).toBe(
'destroyed',
);
expect(inspectSession(testHome, sessionId as string)).toMatchObject({
status: 'destroyed',
childPid: null,
});
}, 60_000);

// Bash runs a typed command as a job in its own process group, so only
Expand Down Expand Up @@ -2092,10 +2119,14 @@ describe('lifecycle integration', { timeout: 30000 }, () => {
'echo offline-test-data; exec cat',
]);

await sleep(1000);
const events = join(testHome, 'sessions', sessionId, 'events.jsonl');
const deadline = Date.now() + 10_000;
while (!(await readFile(events, 'utf8')).includes('offline-test-data')) {
expect(Date.now()).toBeLessThan(deadline);
await sleep(50);
}

crashSession(testHome, sessionId);
await sleep(500);
expect(await processEnds(crashSession(testHome, sessionId))).toBe(true);

const session = inspectSession(testHome, sessionId);
expect(session.status).toBe('failed');
Expand Down
Loading