Skip to content

Commit 777c7af

Browse files
authored
fix: persist daemon-owned child process records (#2019)
* fix: record daemon-owned child processes (#1882) * fix: harden owned child cleanup identities
1 parent 07217c5 commit 777c7af

30 files changed

Lines changed: 1733 additions & 226 deletions

packages/contracts/src/facades/platform.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,9 @@ export type {
565565
HostTemporaryTextFile,
566566
ManagedProcessIdentity,
567567
ManagedProcessOwnership,
568+
OwnedProcessRecord,
569+
OwnedProcessRecordScope,
570+
OwnedProcessRecordWriter,
568571
DeviceInventoryFileHost,
569572
DeviceInventoryHost,
570573
DeviceInventoryHostByFamily,

packages/contracts/src/platform-runtime-host.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ export type ManagedProcessIdentity = Readonly<{
2626

2727
export type ManagedProcessOwnership = 'missing' | 'owned-alive' | 'ownership-lost';
2828

29+
/**
30+
* A daemon-owned process identity that may outlive the request which spawned
31+
* it. The purpose is bounded recovery metadata, not a command selector.
32+
*/
33+
export type OwnedProcessRecord = ManagedProcessIdentity &
34+
Readonly<{
35+
purpose: string;
36+
}>;
37+
38+
export type OwnedProcessRecordScope =
39+
| Readonly<{ kind: 'daemon' }>
40+
| Readonly<{ kind: 'session'; sessionId: string }>;
41+
42+
/** Host-owned persistence seam for process records; platform code never owns the file format. */
43+
export type OwnedProcessRecordWriter = Readonly<{
44+
replace(scope: OwnedProcessRecordScope, records: readonly OwnedProcessRecord[]): void;
45+
clear(scope: OwnedProcessRecordScope): void;
46+
}>;
47+
2948
/** Generic process-execution port; focused Apple foreground tools use AppleToolHost. */
3049
export type HostCommandRunner = Readonly<{
3150
which(executable: string): Promise<string | undefined>;

packages/contracts/src/screen-recording-runtime-host.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
HostCommandResult,
55
ManagedProcessIdentity,
66
ManagedProcessOwnership,
7+
OwnedProcessRecordWriter,
78
} from './platform-runtime-host.ts';
89
import type { RecordingGestureEvent } from './screen-recording-runtime.ts';
910

@@ -211,4 +212,5 @@ export type ScreenRecordingRuntimeHost = Readonly<{
211212
web: WebScreenRecordingHost;
212213
outputs: ScreenRecordingOutputHost;
213214
finalize: ScreenRecordingFinalizer;
215+
ownedProcesses: OwnedProcessRecordWriter;
214216
}>;

packages/platform-apple/src/recording/recovery.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
CleanupOutcome,
44
DurableDescriptorCodec,
55
ManagedProcessIdentity,
6+
OwnedProcessRecordScope,
67
RuntimeOwnerRef,
78
ScreenRecordingRuntimeHost,
89
ScreenRecordingStartInput,
@@ -11,7 +12,10 @@ import { SCREEN_RECORDING_RESOURCE_KIND } from '@agent-device/contracts/screen-r
1112
import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit';
1213

1314
export type AppleScreenRecordingOperationHost = Readonly<{
14-
screenRecording: Pick<ScreenRecordingRuntimeHost, 'apple' | 'finalize' | 'outputs'>;
15+
screenRecording: Pick<
16+
ScreenRecordingRuntimeHost,
17+
'apple' | 'finalize' | 'outputs' | 'ownedProcesses'
18+
>;
1519
}>;
1620

1721
export type AppleRecordingDescriptor =
@@ -83,40 +87,55 @@ export async function cleanupAppleRecording(
8387
host: AppleScreenRecordingOperationHost,
8488
device: DeviceInfo,
8589
body: Parameters<AppleRecordingDescriptorCodec['decode']>[0],
90+
sessionId?: string,
8691
): Promise<CleanupOutcome> {
8792
const decoded = descriptorCodec.decode(body);
8893
if (decoded.status !== 'decoded' || !descriptorMatchesAppleDevice(device, decoded.descriptor)) {
8994
return { status: 'cleanup-pending', reason: 'manual-recovery-required' };
9095
}
9196
if (decoded.descriptor.backend === 'simctl') {
92-
return await cleanupSimulator(host, decoded.descriptor.processes);
97+
return await cleanupSimulator(host, decoded.descriptor.processes, sessionId);
9398
}
9499
return await cleanupRunner(host, device, decoded.descriptor);
95100
}
96101

97102
async function cleanupSimulator(
98103
host: AppleScreenRecordingOperationHost,
99104
processes: readonly ManagedProcessIdentity[],
105+
sessionId?: string,
100106
): Promise<CleanupOutcome> {
101107
const ownership = await Promise.all(
102108
processes.map(async (marker) => await host.screenRecording.apple.inspectProcess(marker)),
103109
);
104110
if (ownership.includes('ownership-lost')) {
105111
return { status: 'cleanup-pending', reason: 'ownership-fence-lost' };
106112
}
107-
if (ownership.every((value) => value === 'missing')) return { status: 'already-missing' };
113+
if (ownership.every((value) => value === 'missing')) {
114+
if (sessionId !== undefined) {
115+
host.screenRecording.ownedProcesses.clear(sessionScope(sessionId));
116+
}
117+
return { status: 'already-missing' };
118+
}
108119
const outcomes = await Promise.all(
109120
processes.flatMap((marker, index) =>
110121
ownership[index] === 'owned-alive'
111122
? [host.screenRecording.apple.terminateProcess(marker)]
112123
: [],
113124
),
114125
);
115-
return outcomes.includes('ownership-lost')
116-
? { status: 'cleanup-pending', reason: 'ownership-fence-lost' }
117-
: outcomes.every((outcome) => outcome === 'already-missing')
118-
? { status: 'already-missing' }
119-
: { status: 'cleaned' };
126+
if (outcomes.includes('ownership-lost')) {
127+
return { status: 'cleanup-pending', reason: 'ownership-fence-lost' };
128+
}
129+
if (sessionId !== undefined) {
130+
host.screenRecording.ownedProcesses.clear(sessionScope(sessionId));
131+
}
132+
return outcomes.every((outcome) => outcome === 'already-missing')
133+
? { status: 'already-missing' }
134+
: { status: 'cleaned' };
135+
}
136+
137+
function sessionScope(sessionId: string): OwnedProcessRecordScope {
138+
return { kind: 'session', sessionId };
120139
}
121140

122141
async function cleanupRunner(

packages/platform-apple/src/recording/runtime.fixtures.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function appleRecordingHost(
6565
apple?: Partial<ScreenRecordingRuntimeHost['apple']>;
6666
complete?: ScreenRecordingFinalizer['complete'];
6767
prepare?: ScreenRecordingRuntimeHost['outputs']['prepare'];
68+
ownedProcesses?: ScreenRecordingRuntimeHost['ownedProcesses'];
6869
} = {},
6970
): AppleScreenRecordingOperationHost {
7071
const apple = Object.assign(
@@ -89,6 +90,7 @@ export function appleRecordingHost(
8990
apple,
9091
outputs: { prepare: options.prepare ?? (async () => {}) },
9192
finalize: { complete: options.complete ?? (async () => ({})) },
93+
ownedProcesses: options.ownedProcesses ?? { replace: () => {}, clear: () => {} },
9294
},
9395
};
9496
}

packages/platform-apple/src/recording/runtime.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ test('declares the exact XCTest backend failure before exposing operations', ()
7878

7979
test('uses simctl on simulators and retains the macOS runner path', async () => {
8080
const calls: string[] = [];
81+
const ownedProcesses = { replace: vi.fn(), clear: vi.fn() };
8182
const host = appleHost({
8283
apple: {
8384
captureClockAnchor: async () => ({ wallClockAtMs: 100, uptimeMs: 50 }),
@@ -97,6 +98,7 @@ test('uses simctl on simulators and retains the macOS runner path', async () =>
9798
calls.push(`finalize:${targetLabel}`);
9899
return {};
99100
},
101+
ownedProcesses,
100102
});
101103
for (const runtimeDevice of [
102104
{
@@ -150,6 +152,10 @@ test('uses simctl on simulators and retains the macOS runner path', async () =>
150152
'runner:stop',
151153
'finalize:macOS recording',
152154
]);
155+
expect(ownedProcesses.replace).toHaveBeenCalledWith({ kind: 'session', sessionId: 'sim' }, [
156+
{ ...processIdentity, purpose: 'simctl-screen-recording' },
157+
]);
158+
expect(ownedProcesses.clear).toHaveBeenCalledWith({ kind: 'session', sessionId: 'sim' });
153159
});
154160

155161
test('simulator cleanup waits for confirmed process exit and nonzero finish fails', async () => {

packages/platform-apple/src/recording/runtime.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ export function createAppleScreenRecordingOperations(params: {
4949
screenRecordingReattach: async (input) =>
5050
await reattachAppleRecording(host, device, input.envelope.descriptor.body),
5151
screenRecordingCleanup: async (input) =>
52-
await cleanupAppleRecording(host, device, input.envelope.descriptor.body),
52+
await cleanupAppleRecording(
53+
host,
54+
device,
55+
input.envelope.descriptor.body,
56+
input.envelope.sessionId,
57+
),
5358
} satisfies ScreenRecordingRuntimeOperations);
5459
}
5560

@@ -91,7 +96,16 @@ async function startAppleSimulatorRecording(params: AppleRecordingStartParams) {
9196
}
9297
try {
9398
signal.throwIfAborted();
99+
host.screenRecording.ownedProcesses.replace(
100+
{ kind: 'session', sessionId: input.sessionId },
101+
processes.map((process) => ({ ...process, purpose: 'simctl-screen-recording' })),
102+
);
94103
} catch (error) {
104+
try {
105+
host.screenRecording.ownedProcesses.clear({ kind: 'session', sessionId: input.sessionId });
106+
} catch {
107+
// Preserve the spawn/publication error; startup cleanup still has the process handle.
108+
}
95109
await settleAppleSimulatorProcess(nativeProcess).catch(() => {});
96110
throw error;
97111
}
@@ -104,12 +118,22 @@ async function startAppleSimulatorRecording(params: AppleRecordingStartParams) {
104118
finish: async (current) => {
105119
await nativeProcess.terminate();
106120
const result = await nativeProcess.wait;
121+
host.screenRecording.ownedProcesses.clear({ kind: 'session', sessionId: input.sessionId });
107122
if (result.exitCode !== 0) {
108123
throw new Error(`simctl recordVideo exited with code ${result.exitCode}`);
109124
}
110125
return await completion(host, current, 'iOS recording');
111126
},
112-
cleanup: async () => await cleanupAppleSimulatorProcess(nativeProcess),
127+
cleanup: async () => {
128+
const result = await cleanupAppleSimulatorProcess(nativeProcess);
129+
if (result.status === 'cleaned' || result.status === 'already-missing') {
130+
host.screenRecording.ownedProcesses.clear({
131+
kind: 'session',
132+
sessionId: input.sessionId,
133+
});
134+
}
135+
return result;
136+
},
113137
});
114138
}
115139

packages/platform-apple/src/runtime.fixtures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export function platformRuntimeHostFixture(): PlatformRuntimeHost {
9191
web: { resolve: async () => undefined },
9292
outputs: { prepare: async () => {} },
9393
finalize: { complete: async () => ({}) },
94+
ownedProcesses: { replace: () => {}, clear: () => {} },
9495
},
9596
} as unknown as PlatformRuntimeHost;
9697
}

src/daemon/request-router.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
type ScreenRecordingAdmissionLedger,
7070
} from './screen-recording-admission-ledger.ts';
7171
import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts';
72+
import type { OwnedProcessRecordStore } from '../utils/owned-process-record.ts';
7273

7374
// ---------------------------------------------------------------------------
7475
// Request handler API
@@ -77,6 +78,7 @@ import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts';
7778
export type RequestRouterDeps = {
7879
logPath: string;
7980
stateDir?: string;
81+
ownedProcessRecords?: OwnedProcessRecordStore;
8082
token: string;
8183
sessionStore: SessionStore;
8284
leaseRegistry: LeaseRegistry;
@@ -109,6 +111,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
109111
const {
110112
logPath,
111113
stateDir,
114+
ownedProcessRecords,
112115
token,
113116
androidAdbProvider,
114117
appleRunnerProvider,
@@ -244,7 +247,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
244247
webProvider:
245248
webProvider ??
246249
(shouldUseDefaultWebProvider(lockedScope)
247-
? createDefaultWebProvider(stateDir, sessionStore)
250+
? createDefaultWebProvider(stateDir, sessionStore, ownedProcessRecords)
248251
: undefined),
249252
appleSimulatorScreenRecordingTransport,
250253
},
@@ -343,12 +346,17 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
343346
}
344347

345348
const createDefaultWebProvider =
346-
(stateDir: string | undefined, sessionStore: SessionStore): WebProviderResolver =>
349+
(
350+
stateDir: string | undefined,
351+
sessionStore: SessionStore,
352+
ownedProcessRecords: OwnedProcessRecordStore | undefined,
353+
): WebProviderResolver =>
347354
({ req, session }) =>
348355
createAgentBrowserWebProvider({
349356
session: session?.name ?? req.session,
350357
stateDir,
351358
openWebSessionNames: () => openWebSessionNames(sessionStore),
359+
ownedProcessRecords,
352360
});
353361

354362
function shouldUseDefaultWebProvider(scope: LockedRequestScope): boolean {

src/daemon/server/daemon-runtime.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ import {
6363
import { createDaemonRecoveryPlatformScope } from '../platform-request-scope.ts';
6464
import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts';
6565
import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts';
66+
import {
67+
createOwnedProcessRecordStore,
68+
type OwnedProcessRecordStore,
69+
} from '../../utils/owned-process-record.ts';
70+
import { reapOwnedProcessRecordsAtStartup } from '../../utils/owned-process-reaper.ts';
6671

6772
const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000;
6873
export const SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS = 11_000;
@@ -241,6 +246,11 @@ export async function startDaemonRuntime(
241246
setRunnerLeaseOwnerStateDir(baseDir);
242247

243248
const sessionStore = new SessionStore(sessionsDir);
249+
const ownedProcessRecords = createOwnedProcessRecordStore({
250+
stateDir: baseDir,
251+
sessionsDir,
252+
resolveSessionDir: (sessionId) => sessionStore.resolveSessionDir(sessionId),
253+
});
244254
const appLogAdmissionLedger = createAppLogAdmissionLedger();
245255
const screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger();
246256
const version = readVersion();
@@ -253,6 +263,7 @@ export async function startDaemonRuntime(
253263
providerRuntimes: providerDeviceRuntimes,
254264
providerModules: providerComposition.platformModules,
255265
sessionsDir,
266+
ownedProcesses: ownedProcessRecords,
256267
resolveSessionArtifacts: (sessionId) => ({
257268
outputPath: sessionStore.resolveAppLogPath(sessionId),
258269
pidPath: sessionStore.resolveAppLogPidPath(sessionId),
@@ -293,6 +304,7 @@ export async function startDaemonRuntime(
293304
const dispatchRequest = createRequestHandler({
294305
logPath,
295306
stateDir: baseDir,
307+
ownedProcessRecords,
296308
token,
297309
sessionStore,
298310
leaseRegistry,
@@ -487,7 +499,15 @@ export async function startDaemonRuntime(
487499
scope: createDaemonRecoveryPlatformScope(),
488500
onDiagnostic: (diagnostic) => startupAppLogDiagnostics.push(diagnostic),
489501
});
490-
await cleanupWebBrowserOrphansForDaemonStartup({ stateDir: baseDir, sessionStore });
502+
await reapOwnedProcessRecordsAtStartup(ownedProcessRecords, {
503+
openWebSessionNames: openWebSessionNames(sessionStore),
504+
purposes: ['simctl-screen-recording'],
505+
});
506+
await cleanupWebBrowserOrphansForDaemonStartup({
507+
stateDir: baseDir,
508+
sessionStore,
509+
ownedProcessRecords,
510+
});
491511
// Marker-gated lifecycle recovery owns test-IME orphan repair. Its implementation remains
492512
// lazy until the marker exists, so a normal daemon startup does not load or probe adb.
493513
void applicationLifecycle.recoverStartupResources({ stateDir: baseDir }).catch((error) => {
@@ -651,12 +671,16 @@ async function reconcileDeviceClaimsForDaemonStartup(
651671
export async function cleanupWebBrowserOrphansForDaemonStartup(params: {
652672
stateDir: string;
653673
sessionStore: SessionStore;
674+
ownedProcessRecords?: OwnedProcessRecordStore;
654675
}): Promise<void> {
655676
const status = getManagedAgentBrowserStatus({ stateDir: params.stateDir });
656677
if (!status.installed) return;
657678
try {
658679
await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', {
659680
openWebSessionNames: openWebSessionNames(params.sessionStore),
681+
...(params.ownedProcessRecords === undefined
682+
? {}
683+
: { ownedProcessRecords: params.ownedProcessRecords }),
660684
});
661685
} catch (error) {
662686
emitDiagnostic({

0 commit comments

Comments
 (0)