Skip to content
61 changes: 61 additions & 0 deletions test/integration/daemon-leak-oracle-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runCmdSync } from '../../src/utils/exec.ts';

// #1781 B1: the oracle's `after-close` checkpoint is only meaningful when it can
// name the sessions that closed — without them it would accept every unfinalized
// capture handle and report clean, which is the vacuous mode the type system now
// refuses in code. The standalone CLI is the one caller that builds options from
// strings rather than types, so it must refuse the same invocation at runtime
// instead of silently degrading to that mode.

const ORACLE_PATH = 'test/integration/support/daemon-leak-oracle.ts';

function runOracle(args: string[]) {
return runCmdSync(process.execPath, ['--experimental-strip-types', ORACLE_PATH, ...args], {
allowFailure: true,
timeoutMs: 60_000,
});
}

test('the leak oracle CLI refuses an after-close checkpoint that names no session', (t) => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-leak-oracle-cli-'));
// A closed session that left an unfinalized capture handle: the exact residue
// the refused invocation would have reported clean.
const sessionDir = path.join(stateDir, 'sessions', 'closed-one');
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(
path.join(sessionDir, 'screen-recording.resource.json'),
`${JSON.stringify({ lifecycle: 'open' })}\n`,
);
t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }));

const refused = runOracle(['--state-dir', stateDir, '--phase', 'after-close']);
assert.notEqual(refused.exitCode, 0, `expected a refusal, got:\n${refused.stdout}`);
assert.match(refused.stderr, /--phase after-close requires at least one --closed-session/);
// A refusal, not a leak report: the checkpoint never ran.
assert.doesNotMatch(refused.stdout, /daemon leak oracle:/);

// The same invocation, once it names the session, runs and finds the handle.
const named = runOracle([
'--state-dir',
stateDir,
'--phase',
'after-close',
'--closed-session',
'closed-one',
'--settle-ms',
'0',
]);
assert.equal(named.exitCode, 1, `expected a leak report, got:\n${named.stdout}${named.stderr}`);
assert.match(named.stdout, /LEAK \(after-close\)/);
assert.match(named.stdout, /sessions\/closed-one\/screen-recording\.resource\.json/);

// `after-shutdown` needs no session identity and is unaffected by the guard.
const shutdown = runOracle(['--state-dir', stateDir, '--settle-ms', '0']);
assert.equal(shutdown.exitCode, 1, shutdown.stderr);
assert.match(shutdown.stdout, /LEAK \(after-shutdown\)/);
});
16 changes: 16 additions & 0 deletions test/integration/daemon-replace-exit-flush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from 'node:path';
import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loopback.ts';
import { stopProcessForTakeover } from '../../src/daemon/daemon-process.ts';
import { isProcessAlive } from '../../src/utils/host-process.ts';
import { assertNoDaemonLeaks } from './support/daemon-leak-oracle.ts';
import { runCliJson } from './test-helpers.ts';

// #1596: a CLI command that finds its recorded daemon unreachable replaces it
Expand All @@ -28,6 +29,7 @@ test('daemon replace mid-command returns a structured, parseable error and exits

const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replace-exit-flush-'));
let info: DaemonInfo | null = null;
const daemonPids: number[] = [];
try {
// A real daemon, started by this codebase, so its recorded version/code
// signature legitimately match — the only way to reach the "unreachable"
Expand All @@ -36,6 +38,7 @@ test('daemon replace mid-command returns a structured, parseable error and exits
assert.equal(started.status, 0, `${started.stderr}\n${started.stdout}`);

info = readDaemonInfo(stateDir);
daemonPids.push(info.pid);
assert.equal(isProcessAlive(info.pid), true, 'expected the started daemon to be alive');

// Kill it out from under its own metadata: daemon.json stays put and
Expand Down Expand Up @@ -69,6 +72,19 @@ test('daemon replace mid-command returns a structured, parseable error and exits
);

info = readDaemonInfo(stateDir);
daemonPids.push(info.pid);
await stopProcessForTakeover(info.pid, {
termTimeoutMs: 1_500,
killTimeoutMs: 1_500,
expectedStartTime: info.processStartTime,
});
// #1781 B1: neither the SIGKILLed daemon nor its replacement may leave owned
// processes or unclassified state-dir residue once both are gone. `info`
// stays set until this passes: `stopProcessForTakeover` is best-effort, so a
// failed stop must still reach the `finally` retry below rather than have
// the state dir removed out from under a daemon that is still running.
await assertNoDaemonLeaks({ stateDir, daemonPids, phase: 'after-shutdown' });
info = null;
} finally {
if (info) {
await stopProcessForTakeover(info.pid, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import { expect, test } from 'vitest';
import { assertRpcOk } from './assertions.ts';
import {
createAndroidRecordingScenarioHarness,
withAndroidRecordingScenario,
} from './android-recording-fixtures.ts';
import { createAndroidRecordingProvider } from './android-recording-provider-fixtures.ts';
import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts';
import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from './test-timeouts.ts';
import { screenRecordingResourceStore } from '../../../src/daemon/screen-recording-resource-store.ts';
import { assertNoDaemonLeaks } from '../support/daemon-leak-oracle.ts';

// The `after-close` half of the #1781 B1 leak oracle, on a real daemon route.
// The three CLI daemon lanes only ever shut a daemon down, so this is the one
// place a closing session actually owns a resource: `record start` opens a
// record-only session with a live screen-recording handle, and the session-close
// teardown that #1325 added must finalize it. Removing that teardown step leaves
// the descriptor `lifecycle: "open"` and turns this test red.
test(
'Provider-backed integration closing a recording session leaves no unfinalized capture handle',
async () => {
await withAndroidRecordingScenario(
'agent-device-provider-scenario-close-leak-',
async (tmpDir) => {
const calls: string[][] = [];
const outputPath = path.join(tmpDir, 'close-leak.mp4');
const daemon = await createAndroidRecordingScenarioHarness({
androidAdbProvider: () => createAndroidRecordingProvider({ calls }),
deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID],
});
try {
const started = await daemon.callCommand('record', ['start', outputPath], {
platform: 'android',
serial: PROVIDER_SCENARIO_ANDROID.id,
recordingScope: 'device',
});
assert.equal(assertRpcOk<{ recording?: unknown }>(started).recording, 'started');

const sessionDir = daemon.sessionDir('default');
// The live handle is durable state: the oracle must be able to see the
// unfinalized descriptor it will later refuse.
assert.equal(
screenRecordingResourceStore.read(screenRecordingResourceStore.resolvePath(sessionDir))
.status,
'decoded',
);

await daemon.callCommand('close', [], {
platform: 'android',
serial: PROVIDER_SCENARIO_ANDROID.id,
});

// `daemonPids: []` on purpose: this harness runs the daemon route
// in-process, so there is no daemon process to own children and the
// state-dir arm is the whole check here. The process arm is exercised by
// the CLI lanes and pinned by daemon-leak-model.test.ts.
await expect(
assertNoDaemonLeaks({
stateDir: path.dirname(sessionDir),
sessionsDir: path.dirname(sessionDir),
daemonPids: [],
phase: 'after-close',
closedSessions: [path.basename(sessionDir)],
settleMs: 0,
}),
).resolves.toBeUndefined();
} finally {
await daemon.close();
}
},
);
},
PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS,
);
4 changes: 4 additions & 0 deletions test/integration/smoke-daemon-clean.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loop
import { runCmdSync } from '../../src/utils/exec.ts';
import { stopProcessForTakeover } from '../../src/daemon/daemon-process.ts';
import { isProcessAlive } from '../../src/utils/host-process.ts';
import { assertNoDaemonLeaks } from './support/daemon-leak-oracle.ts';
import { runCliJson } from './test-helpers.ts';

type DaemonInfo = {
Expand Down Expand Up @@ -41,6 +42,9 @@ test('clean daemon script stops a live daemon before removing metadata', async (
assert.equal(isProcessAlive(info.pid), false);
assert.equal(fs.existsSync(path.join(stateDir, 'daemon.json')), false);
assert.equal(fs.existsSync(path.join(stateDir, 'daemon.lock')), false);
// #1781 B1: the stopped daemon must take every process it owned with it and
// leave only classified artifacts in its state dir.
await assertNoDaemonLeaks({ stateDir, daemonPids: [info.pid], phase: 'after-shutdown' });
} finally {
if (info) {
await stopProcessForTakeover(info.pid, {
Expand Down
33 changes: 18 additions & 15 deletions test/integration/smoke-daemon-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { DAEMON_RPC_PROTOCOL_VERSION } from '../../src/daemon/http-health.ts';
import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loopback.ts';
import { stopProcessForTakeover } from '../../src/daemon/daemon-process.ts';
import { formatResultDebug } from './cli-json.ts';
import { assertNoDaemonLeaks } from './support/daemon-leak-oracle.ts';
import { runCliJson } from './test-helpers.ts';

type DaemonInfo = {
Expand Down Expand Up @@ -65,8 +66,17 @@ test('daemon HTTP transport starts from CLI and accepts a command RPC', async (t
const unauthorized = await callCommandRpc({ ...info, token: 'wrong-token' }, 'session_list');
assert.equal(unauthorized.status, 401);
assert.equal(unauthorized.body.error?.data?.code, 'UNAUTHORIZED');
// #1781 B1: the HTTP-mode daemon must exit — leaving nothing it owns and no
// unclassified state-dir residue. Asserted on the success path so the
// oracle's settle window can never replace a primary assertion's
// diagnostic; the `finally` below stays best-effort cleanup.
await stopDaemon(info);
await assertNoDaemonLeaks({ stateDir, daemonPids: [info.pid], phase: 'after-shutdown' });
} finally {
await stopDaemonForStateDir(stateDir);
if (fs.existsSync(path.join(stateDir, 'daemon.json'))) {
await stopDaemon(readDaemonInfo(stateDir));
}
fs.rmSync(stateDir, { recursive: true, force: true });
}
});

Expand Down Expand Up @@ -101,18 +111,11 @@ async function callCommandRpc(
};
}

async function stopDaemonForStateDir(stateDir: string): Promise<void> {
try {
const infoPath = path.join(stateDir, 'daemon.json');
if (!fs.existsSync(infoPath)) return;
const info = readDaemonInfo(stateDir);
if (!Number.isInteger(info.pid) || info.pid <= 0) return;
await stopProcessForTakeover(info.pid, {
termTimeoutMs: 1500,
killTimeoutMs: 1500,
expectedStartTime: info.processStartTime,
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
async function stopDaemon(info: DaemonInfo): Promise<void> {
if (!Number.isInteger(info.pid) || info.pid <= 0) return;
await stopProcessForTakeover(info.pid, {
termTimeoutMs: 1500,
killTimeoutMs: 1500,
expectedStartTime: info.processStartTime,
});
}
Loading
Loading