Skip to content

Commit f48d5d6

Browse files
committed
test: make the leak oracle's after-close phase name the session that closed
Re-review of #1859: `after-close` accepted every capture descriptor and legacy marker, so the closed session's unfinalized handle was indistinguishable from another session's legitimately live one — the phase could not fail, the same shape as the surviving-daemon blocker, and it left half of B1's stated scope undelivered. - the observation carries the session directories closed at the checkpoint; a capture handle must be finalized once its owning session is gone (every session after shutdown, only the closed ones after close), while another session's live handle stays expected and a legacy pid marker is never a finish record - the oracle accepts `closedSessions` and a `sessionsDir` override, normalizing entries to the canonical `sessions/<name>/…` shape so an in-process harness rooted directly at its own sessions dir is classified the same way - add a real route regression: a provider-backed session with a live screen recording is closed through the daemon route, and the oracle refuses a descriptor left `lifecycle: "open"`. Reverting the close-route finalization (session-close-lifecycle-teardown.ts, the #1325 fix) turns it red naming sessions/default/screen-recording.resource.json; with the pre-fix model it stays green, which is the P1 in one line Refs #1781 #1431
1 parent 09ac16d commit f48d5d6

4 files changed

Lines changed: 191 additions & 18 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import assert from 'node:assert/strict';
2+
import path from 'node:path';
3+
import { expect, test } from 'vitest';
4+
import { assertRpcOk } from './assertions.ts';
5+
import {
6+
createAndroidRecordingScenarioHarness,
7+
withAndroidRecordingScenario,
8+
} from './android-recording-fixtures.ts';
9+
import { createAndroidRecordingProvider } from './android-recording-provider-fixtures.ts';
10+
import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts';
11+
import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from './test-timeouts.ts';
12+
import { screenRecordingResourceStore } from '../../../src/daemon/screen-recording-resource-store.ts';
13+
import { assertNoDaemonLeaks } from '../support/daemon-leak-oracle.ts';
14+
15+
// The `after-close` half of the #1781 B1 leak oracle, on a real daemon route.
16+
// The three CLI daemon lanes only ever shut a daemon down, so this is the one
17+
// place a closing session actually owns a resource: `record start` opens a
18+
// record-only session with a live screen-recording handle, and the session-close
19+
// teardown that #1325 added must finalize it. Removing that teardown step leaves
20+
// the descriptor `lifecycle: "open"` and turns this test red.
21+
test(
22+
'Provider-backed integration closing a recording session leaves no unfinalized capture handle',
23+
async () => {
24+
await withAndroidRecordingScenario(
25+
'agent-device-provider-scenario-close-leak-',
26+
async (tmpDir) => {
27+
const calls: string[][] = [];
28+
const outputPath = path.join(tmpDir, 'close-leak.mp4');
29+
const daemon = await createAndroidRecordingScenarioHarness({
30+
androidAdbProvider: () => createAndroidRecordingProvider({ calls }),
31+
deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID],
32+
});
33+
try {
34+
const started = await daemon.callCommand('record', ['start', outputPath], {
35+
platform: 'android',
36+
serial: PROVIDER_SCENARIO_ANDROID.id,
37+
recordingScope: 'device',
38+
});
39+
assert.equal(assertRpcOk<{ recording?: unknown }>(started).recording, 'started');
40+
41+
const sessionDir = daemon.sessionDir('default');
42+
// The live handle is durable state: the oracle must be able to see the
43+
// unfinalized descriptor it will later refuse.
44+
assert.equal(
45+
screenRecordingResourceStore.read(screenRecordingResourceStore.resolvePath(sessionDir))
46+
.status,
47+
'decoded',
48+
);
49+
50+
await daemon.callCommand('close', [], {
51+
platform: 'android',
52+
serial: PROVIDER_SCENARIO_ANDROID.id,
53+
});
54+
55+
// `daemonPids: []` on purpose: this harness runs the daemon route
56+
// in-process, so there is no daemon process to own children and the
57+
// state-dir arm is the whole check here. The process arm is exercised by
58+
// the CLI lanes and pinned by daemon-leak-model.test.ts.
59+
await expect(
60+
assertNoDaemonLeaks({
61+
stateDir: path.dirname(sessionDir),
62+
sessionsDir: path.dirname(sessionDir),
63+
daemonPids: [],
64+
phase: 'after-close',
65+
closedSessions: [path.basename(sessionDir)],
66+
settleMs: 0,
67+
}),
68+
).resolves.toBeUndefined();
69+
} finally {
70+
await daemon.close();
71+
}
72+
},
73+
);
74+
},
75+
PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS,
76+
);

test/integration/support/daemon-leak-model.test.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ function observe(overrides: Partial<DaemonLeakObservation> = {}): DaemonLeakObse
3030
phase: 'after-shutdown',
3131
processes: [],
3232
excludedPids: [OBSERVER_PID],
33+
closedSessions: [],
3334
stateEntries: [],
3435
...overrides,
3536
};
@@ -177,8 +178,8 @@ describe('state-dir residue rules', () => {
177178
'expected',
178179
],
179180
[
180-
'open capture during close',
181-
file('sessions/d/screen-recording.resource.json', 'open'),
181+
"another session's open capture during close",
182+
file('sessions/other/screen-recording.resource.json', 'open'),
182183
'after-close',
183184
'expected',
184185
],
@@ -200,6 +201,52 @@ describe('state-dir residue rules', () => {
200201
});
201202
});
202203

204+
// The phase only means something if it can name the session that closed: without
205+
// that, the closed session's unfinalized capture handle is indistinguishable
206+
// from another session's legitimately live one, and `after-close` certifies
207+
// nothing. Wired through by the session-close route regression in
208+
// test/integration/provider-scenarios/session-close-leak-oracle.test.ts.
209+
describe('closed-session capture handles', () => {
210+
const closedSessionCapture = (lifecycle: string) =>
211+
file('sessions/closed-one/screen-recording.resource.json', lifecycle);
212+
const afterClose = (stateEntries: StateEntry[]): DaemonLeakObservation =>
213+
observe({
214+
phase: 'after-close',
215+
livePids: [DAEMON_PID],
216+
closedSessions: ['closed-one'],
217+
stateEntries,
218+
});
219+
220+
test('the closed session must have finalized its capture handle', () => {
221+
const snapshot = evaluateDaemonLeaks(afterClose([closedSessionCapture('open')]));
222+
223+
expect(snapshot.strayStateEntries).toEqual([
224+
'sessions/closed-one/screen-recording.resource.json',
225+
]);
226+
expect(hasDaemonLeaks(snapshot)).toBe(true);
227+
});
228+
229+
test('a session that did not close may still hold a live capture handle', () => {
230+
const snapshot = evaluateDaemonLeaks(
231+
afterClose([file('sessions/still-open/screen-recording.resource.json', 'open')]),
232+
);
233+
234+
expect(hasDaemonLeaks(snapshot)).toBe(false);
235+
});
236+
237+
test('a finalized handle from the closed session is its finish record', () => {
238+
const snapshot = evaluateDaemonLeaks(afterClose([closedSessionCapture('completed')]));
239+
240+
expect(hasDaemonLeaks(snapshot)).toBe(false);
241+
});
242+
243+
test('a legacy pid marker is never a finish record for the closed session', () => {
244+
const snapshot = evaluateDaemonLeaks(afterClose([file('sessions/closed-one/app-log.pid')]));
245+
246+
expect(snapshot.strayStateEntries).toEqual(['sessions/closed-one/app-log.pid']);
247+
});
248+
});
249+
203250
test('a clean shutdown reports no leak', () => {
204251
const snapshot = evaluateDaemonLeaks(
205252
observe({

test/integration/support/daemon-leak-model.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ export type DaemonLeakObservation = {
9696
processes: readonly HostProcess[];
9797
/** Pids never treated as owned: the observer and its own ancestors. */
9898
excludedPids: readonly number[];
99+
/**
100+
* Session directory names closed at this checkpoint. Their capture handles
101+
* must be finalized; other sessions may still hold live ones. A checkpoint
102+
* that cannot say which session closed cannot tell an unfinalized handle from
103+
* a legitimately live one, so `after-close` callers always pass them.
104+
*/
105+
closedSessions: readonly string[];
99106
stateEntries: readonly StateEntry[];
100107
};
101108

@@ -140,7 +147,11 @@ export function evaluateDaemonLeaks(observation: DaemonLeakObservation): DaemonL
140147
strayStateEntries: observation.stateEntries
141148
.filter(
142149
(entry) =>
143-
classifyStateEntry(entry, observation.phase, daemonLegitimatelyAlive) === 'stray',
150+
classifyStateEntry(entry, {
151+
phase: observation.phase,
152+
daemonLegitimatelyAlive,
153+
closedSessions: observation.closedSessions,
154+
}) === 'stray',
144155
)
145156
.map((entry) => entry.path)
146157
.sort(),
@@ -213,33 +224,44 @@ function directOwnershipReasons(
213224
return reasons;
214225
}
215226

216-
function classifyStateEntry(
217-
entry: StateEntry,
218-
phase: DaemonLeakPhase,
219-
daemonLegitimatelyAlive: boolean,
220-
): 'expected' | 'stray' {
227+
type StateEntryContext = {
228+
phase: DaemonLeakPhase;
229+
daemonLegitimatelyAlive: boolean;
230+
closedSessions: readonly string[];
231+
};
232+
233+
function classifyStateEntry(entry: StateEntry, context: StateEntryContext): 'expected' | 'stray' {
221234
if (MANAGED_TOOLS_ENTRY.test(entry.path)) return 'expected';
222235
if (entry.kind === 'empty-directory') return 'stray';
223236
if (entry.path.endsWith('.tmp')) return 'stray';
224237
if (DAEMON_LIVENESS_ENTRY.test(entry.path)) {
225-
return daemonLegitimatelyAlive ? 'expected' : 'stray';
238+
return context.daemonLegitimatelyAlive ? 'expected' : 'stray';
226239
}
227240
if (CAPTURE_DESCRIPTOR_ENTRY.test(entry.path) || LEGACY_APP_LOG_MARKER_ENTRY.test(entry.path)) {
228-
return classifyCaptureEntry(entry, phase);
241+
return classifyCaptureEntry(entry, context);
229242
}
230243
return EXPECTED_STATE_DIR_ENTRIES.some((matcher) => matcher.test(entry.path))
231244
? 'expected'
232245
: 'stray';
233246
}
234247

235-
// Another session may still hold a live capture while this one closes; after
236-
// shutdown only a completed capture record may remain (never a pid marker).
237-
function classifyCaptureEntry(entry: StateEntry, phase: DaemonLeakPhase): 'expected' | 'stray' {
238-
if (phase === 'after-close') return 'expected';
248+
// A capture handle must be finalized once its owning session is gone: after
249+
// shutdown that is every session, after close only the sessions that closed.
250+
// Another session's live capture stays expected, and a legacy pid marker is
251+
// never a finish record, so it is stray whenever its session is gone.
252+
function classifyCaptureEntry(entry: StateEntry, context: StateEntryContext): 'expected' | 'stray' {
253+
const owningSessionGone =
254+
context.phase === 'after-shutdown' ||
255+
context.closedSessions.includes(sessionDirectoryOf(entry.path) ?? '');
256+
if (!owningSessionGone) return 'expected';
239257
if (!CAPTURE_DESCRIPTOR_ENTRY.test(entry.path)) return 'stray';
240258
return entry.descriptorLifecycle === 'completed' ? 'expected' : 'stray';
241259
}
242260

261+
function sessionDirectoryOf(entryPath: string): string | undefined {
262+
return /^sessions\/([^/]+)\//.exec(entryPath)?.[1];
263+
}
264+
243265
export function formatDaemonLeakReport(snapshot: DaemonLeakSnapshot): string {
244266
const surviving = survivingDaemonPids(snapshot);
245267
return [

test/integration/support/daemon-leak-oracle.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ export type DaemonLeakOracleOptions = {
3737
/** Every daemon pid the lane observed for this state dir (from daemon.json). */
3838
daemonPids: readonly number[];
3939
phase: DaemonLeakPhase;
40+
/**
41+
* Session directory names (not paths) closed at this checkpoint — required at
42+
* `after-close`, where only they identify whose capture handle must be
43+
* finalized. `sessionStore.resolveSessionDir(name)`'s basename, or the
44+
* `sessionStateDir` an `open`/`close` response reports.
45+
*/
46+
closedSessions?: readonly string[];
47+
/**
48+
* Where sessions live, when that is not `<stateDir>/sessions` — an in-process
49+
* scenario harness roots its SessionStore directly at its own temp dir.
50+
* Entries below it are normalized to the canonical `sessions/<name>/…` shape
51+
* so the rules stay layout-independent.
52+
*/
53+
sessionsDir?: string;
4054
/** How long stragglers may take to exit before they count as leaked. */
4155
settleMs?: number;
4256
};
@@ -53,7 +67,8 @@ async function captureDaemonLeakSnapshot(
5367
phase: options.phase,
5468
processes,
5569
excludedPids: [...ancestorsOf(process.pid, processes)],
56-
stateEntries: readStateEntries(stateDir),
70+
closedSessions: options.closedSessions ?? [],
71+
stateEntries: readStateEntries(stateDir, options.sessionsDir),
5772
});
5873
}
5974

@@ -82,12 +97,13 @@ export async function assertNoDaemonLeaks(options: DaemonLeakOracleOptions): Pro
8297
// Files, plus empty directories as their own entries (an unswept session
8398
// scaffold leaves no file behind), plus the `lifecycle` of durable capture
8499
// descriptors so the rules stay free of filesystem access.
85-
function readStateEntries(stateDir: string): StateEntry[] {
100+
function readStateEntries(stateDir: string, sessionsDir?: string): StateEntry[] {
86101
if (!fs.existsSync(stateDir)) return [];
102+
const sessionsRoot = path.resolve(sessionsDir ?? path.join(stateDir, 'sessions'));
87103
const entries: StateEntry[] = [];
88104
for (const dirent of fs.readdirSync(stateDir, { recursive: true, withFileTypes: true })) {
89105
const absolute = path.join(dirent.parentPath, dirent.name);
90-
const relative = path.relative(stateDir, absolute).split(path.sep).join('/');
106+
const relative = toCanonicalEntryPath(absolute, stateDir, sessionsRoot);
91107
if (!dirent.isDirectory()) {
92108
entries.push({
93109
path: relative,
@@ -103,6 +119,16 @@ function readStateEntries(stateDir: string): StateEntry[] {
103119
return entries;
104120
}
105121

122+
// `sessions/<name>/…` regardless of where the SessionStore is rooted.
123+
function toCanonicalEntryPath(absolute: string, stateDir: string, sessionsRoot: string): string {
124+
const withinSessions = path.relative(sessionsRoot, absolute);
125+
const relative =
126+
withinSessions.startsWith('..') || path.isAbsolute(withinSessions)
127+
? path.relative(stateDir, absolute)
128+
: path.join('sessions', withinSessions);
129+
return relative.split(path.sep).join('/');
130+
}
131+
106132
function isEmptyDirectory(directoryPath: string): boolean {
107133
try {
108134
return fs.readdirSync(directoryPath).length === 0;
@@ -184,7 +210,8 @@ function ancestorsOf(pid: number, processes: readonly HostProcess[]): Set<number
184210
// Standalone use (red-proofs, manual triage):
185211
// node --experimental-strip-types test/integration/support/daemon-leak-oracle.ts \
186212
// --state-dir <dir> --daemon-pid <pid> [--daemon-pid <pid>…] \
187-
// [--phase after-shutdown|after-close] [--settle-ms <n>]
213+
// [--phase after-shutdown|after-close] [--closed-session <dir-name>…]
214+
// [--settle-ms <n>]
188215
// Prints the report and exits 1 on a leak.
189216
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
190217
const args = process.argv.slice(2);
@@ -196,6 +223,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
196223
stateDir,
197224
daemonPids: readFlag('--daemon-pid').map(Number),
198225
phase: (readFlag('--phase')[0] as DaemonLeakPhase | undefined) ?? 'after-shutdown',
226+
closedSessions: readFlag('--closed-session'),
199227
settleMs: Number(readFlag('--settle-ms')[0] ?? DEFAULT_SETTLE_MS),
200228
});
201229
process.stdout.write(`${formatDaemonLeakReport(snapshot)}\n`);

0 commit comments

Comments
 (0)