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
3 changes: 3 additions & 0 deletions packages/contracts/src/facades/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,9 @@ export type {
HostTemporaryTextFile,
ManagedProcessIdentity,
ManagedProcessOwnership,
OwnedProcessRecord,
OwnedProcessRecordScope,
OwnedProcessRecordWriter,
DeviceInventoryFileHost,
DeviceInventoryHost,
DeviceInventoryHostByFamily,
Expand Down
19 changes: 19 additions & 0 deletions packages/contracts/src/platform-runtime-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ export type ManagedProcessIdentity = Readonly<{

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

/**
* A daemon-owned process identity that may outlive the request which spawned
* it. The purpose is bounded recovery metadata, not a command selector.
*/
export type OwnedProcessRecord = ManagedProcessIdentity &
Readonly<{
purpose: string;
}>;

export type OwnedProcessRecordScope =
| Readonly<{ kind: 'daemon' }>
| Readonly<{ kind: 'session'; sessionId: string }>;

/** Host-owned persistence seam for process records; platform code never owns the file format. */
export type OwnedProcessRecordWriter = Readonly<{
replace(scope: OwnedProcessRecordScope, records: readonly OwnedProcessRecord[]): void;
clear(scope: OwnedProcessRecordScope): void;
}>;

/** Generic process-execution port; focused Apple foreground tools use AppleToolHost. */
export type HostCommandRunner = Readonly<{
which(executable: string): Promise<string | undefined>;
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/screen-recording-runtime-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
HostCommandResult,
ManagedProcessIdentity,
ManagedProcessOwnership,
OwnedProcessRecordWriter,
} from './platform-runtime-host.ts';
import type { RecordingGestureEvent } from './screen-recording-runtime.ts';

Expand Down Expand Up @@ -211,4 +212,5 @@ export type ScreenRecordingRuntimeHost = Readonly<{
web: WebScreenRecordingHost;
outputs: ScreenRecordingOutputHost;
finalize: ScreenRecordingFinalizer;
ownedProcesses: OwnedProcessRecordWriter;
}>;
35 changes: 27 additions & 8 deletions packages/platform-apple/src/recording/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CleanupOutcome,
DurableDescriptorCodec,
ManagedProcessIdentity,
OwnedProcessRecordScope,
RuntimeOwnerRef,
ScreenRecordingRuntimeHost,
ScreenRecordingStartInput,
Expand All @@ -11,7 +12,10 @@ import { SCREEN_RECORDING_RESOURCE_KIND } from '@agent-device/contracts/screen-r
import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit';

export type AppleScreenRecordingOperationHost = Readonly<{
screenRecording: Pick<ScreenRecordingRuntimeHost, 'apple' | 'finalize' | 'outputs'>;
screenRecording: Pick<
ScreenRecordingRuntimeHost,
'apple' | 'finalize' | 'outputs' | 'ownedProcesses'
>;
}>;

export type AppleRecordingDescriptor =
Expand Down Expand Up @@ -83,40 +87,55 @@ export async function cleanupAppleRecording(
host: AppleScreenRecordingOperationHost,
device: DeviceInfo,
body: Parameters<AppleRecordingDescriptorCodec['decode']>[0],
sessionId?: string,
): Promise<CleanupOutcome> {
const decoded = descriptorCodec.decode(body);
if (decoded.status !== 'decoded' || !descriptorMatchesAppleDevice(device, decoded.descriptor)) {
return { status: 'cleanup-pending', reason: 'manual-recovery-required' };
}
if (decoded.descriptor.backend === 'simctl') {
return await cleanupSimulator(host, decoded.descriptor.processes);
return await cleanupSimulator(host, decoded.descriptor.processes, sessionId);
}
return await cleanupRunner(host, device, decoded.descriptor);
}

async function cleanupSimulator(
host: AppleScreenRecordingOperationHost,
processes: readonly ManagedProcessIdentity[],
sessionId?: string,
): Promise<CleanupOutcome> {
const ownership = await Promise.all(
processes.map(async (marker) => await host.screenRecording.apple.inspectProcess(marker)),
);
if (ownership.includes('ownership-lost')) {
return { status: 'cleanup-pending', reason: 'ownership-fence-lost' };
}
if (ownership.every((value) => value === 'missing')) return { status: 'already-missing' };
if (ownership.every((value) => value === 'missing')) {
if (sessionId !== undefined) {
host.screenRecording.ownedProcesses.clear(sessionScope(sessionId));
}
return { status: 'already-missing' };
}
const outcomes = await Promise.all(
processes.flatMap((marker, index) =>
ownership[index] === 'owned-alive'
? [host.screenRecording.apple.terminateProcess(marker)]
: [],
),
);
return outcomes.includes('ownership-lost')
? { status: 'cleanup-pending', reason: 'ownership-fence-lost' }
: outcomes.every((outcome) => outcome === 'already-missing')
? { status: 'already-missing' }
: { status: 'cleaned' };
if (outcomes.includes('ownership-lost')) {
return { status: 'cleanup-pending', reason: 'ownership-fence-lost' };
}
if (sessionId !== undefined) {
host.screenRecording.ownedProcesses.clear(sessionScope(sessionId));
}
return outcomes.every((outcome) => outcome === 'already-missing')
? { status: 'already-missing' }
: { status: 'cleaned' };
}

function sessionScope(sessionId: string): OwnedProcessRecordScope {
return { kind: 'session', sessionId };
}

async function cleanupRunner(
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-apple/src/recording/runtime.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function appleRecordingHost(
apple?: Partial<ScreenRecordingRuntimeHost['apple']>;
complete?: ScreenRecordingFinalizer['complete'];
prepare?: ScreenRecordingRuntimeHost['outputs']['prepare'];
ownedProcesses?: ScreenRecordingRuntimeHost['ownedProcesses'];
} = {},
): AppleScreenRecordingOperationHost {
const apple = Object.assign(
Expand All @@ -89,6 +90,7 @@ export function appleRecordingHost(
apple,
outputs: { prepare: options.prepare ?? (async () => {}) },
finalize: { complete: options.complete ?? (async () => ({})) },
ownedProcesses: options.ownedProcesses ?? { replace: () => {}, clear: () => {} },
},
};
}
6 changes: 6 additions & 0 deletions packages/platform-apple/src/recording/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ test('declares the exact XCTest backend failure before exposing operations', ()

test('uses simctl on simulators and retains the macOS runner path', async () => {
const calls: string[] = [];
const ownedProcesses = { replace: vi.fn(), clear: vi.fn() };
const host = appleHost({
apple: {
captureClockAnchor: async () => ({ wallClockAtMs: 100, uptimeMs: 50 }),
Expand All @@ -97,6 +98,7 @@ test('uses simctl on simulators and retains the macOS runner path', async () =>
calls.push(`finalize:${targetLabel}`);
return {};
},
ownedProcesses,
});
for (const runtimeDevice of [
{
Expand Down Expand Up @@ -150,6 +152,10 @@ test('uses simctl on simulators and retains the macOS runner path', async () =>
'runner:stop',
'finalize:macOS recording',
]);
expect(ownedProcesses.replace).toHaveBeenCalledWith({ kind: 'session', sessionId: 'sim' }, [
{ ...processIdentity, purpose: 'simctl-screen-recording' },
]);
expect(ownedProcesses.clear).toHaveBeenCalledWith({ kind: 'session', sessionId: 'sim' });
});

test('simulator cleanup waits for confirmed process exit and nonzero finish fails', async () => {
Expand Down
28 changes: 26 additions & 2 deletions packages/platform-apple/src/recording/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ export function createAppleScreenRecordingOperations(params: {
screenRecordingReattach: async (input) =>
await reattachAppleRecording(host, device, input.envelope.descriptor.body),
screenRecordingCleanup: async (input) =>
await cleanupAppleRecording(host, device, input.envelope.descriptor.body),
await cleanupAppleRecording(
host,
device,
input.envelope.descriptor.body,
input.envelope.sessionId,
),
} satisfies ScreenRecordingRuntimeOperations);
}

Expand Down Expand Up @@ -91,7 +96,16 @@ async function startAppleSimulatorRecording(params: AppleRecordingStartParams) {
}
try {
signal.throwIfAborted();
host.screenRecording.ownedProcesses.replace(
{ kind: 'session', sessionId: input.sessionId },
processes.map((process) => ({ ...process, purpose: 'simctl-screen-recording' })),
);
} catch (error) {
try {
host.screenRecording.ownedProcesses.clear({ kind: 'session', sessionId: input.sessionId });
} catch {
// Preserve the spawn/publication error; startup cleanup still has the process handle.
}
await settleAppleSimulatorProcess(nativeProcess).catch(() => {});
throw error;
}
Expand All @@ -104,12 +118,22 @@ async function startAppleSimulatorRecording(params: AppleRecordingStartParams) {
finish: async (current) => {
await nativeProcess.terminate();
const result = await nativeProcess.wait;
host.screenRecording.ownedProcesses.clear({ kind: 'session', sessionId: input.sessionId });
if (result.exitCode !== 0) {
throw new Error(`simctl recordVideo exited with code ${result.exitCode}`);
}
return await completion(host, current, 'iOS recording');
},
cleanup: async () => await cleanupAppleSimulatorProcess(nativeProcess),
cleanup: async () => {
const result = await cleanupAppleSimulatorProcess(nativeProcess);
if (result.status === 'cleaned' || result.status === 'already-missing') {
host.screenRecording.ownedProcesses.clear({
kind: 'session',
sessionId: input.sessionId,
});
}
return result;
},
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/platform-apple/src/runtime.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function platformRuntimeHostFixture(): PlatformRuntimeHost {
web: { resolve: async () => undefined },
outputs: { prepare: async () => {} },
finalize: { complete: async () => ({}) },
ownedProcesses: { replace: () => {}, clear: () => {} },
},
} as unknown as PlatformRuntimeHost;
}
12 changes: 10 additions & 2 deletions src/daemon/request-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
type ScreenRecordingAdmissionLedger,
} from './screen-recording-admission-ledger.ts';
import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts';
import type { OwnedProcessRecordStore } from '../utils/owned-process-record.ts';

// ---------------------------------------------------------------------------
// Request handler API
Expand All @@ -77,6 +78,7 @@ import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts';
export type RequestRouterDeps = {
logPath: string;
stateDir?: string;
ownedProcessRecords?: OwnedProcessRecordStore;
token: string;
sessionStore: SessionStore;
leaseRegistry: LeaseRegistry;
Expand Down Expand Up @@ -109,6 +111,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
const {
logPath,
stateDir,
ownedProcessRecords,
token,
androidAdbProvider,
appleRunnerProvider,
Expand Down Expand Up @@ -244,7 +247,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
webProvider:
webProvider ??
(shouldUseDefaultWebProvider(lockedScope)
? createDefaultWebProvider(stateDir, sessionStore)
? createDefaultWebProvider(stateDir, sessionStore, ownedProcessRecords)
: undefined),
appleSimulatorScreenRecordingTransport,
},
Expand Down Expand Up @@ -343,12 +346,17 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
}

const createDefaultWebProvider =
(stateDir: string | undefined, sessionStore: SessionStore): WebProviderResolver =>
(
stateDir: string | undefined,
sessionStore: SessionStore,
ownedProcessRecords: OwnedProcessRecordStore | undefined,
): WebProviderResolver =>
({ req, session }) =>
createAgentBrowserWebProvider({
session: session?.name ?? req.session,
stateDir,
openWebSessionNames: () => openWebSessionNames(sessionStore),
ownedProcessRecords,
});

function shouldUseDefaultWebProvider(scope: LockedRequestScope): boolean {
Expand Down
26 changes: 25 additions & 1 deletion src/daemon/server/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ import {
import { createDaemonRecoveryPlatformScope } from '../platform-request-scope.ts';
import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts';
import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts';
import {
createOwnedProcessRecordStore,
type OwnedProcessRecordStore,
} from '../../utils/owned-process-record.ts';
import { reapOwnedProcessRecordsAtStartup } from '../../utils/owned-process-reaper.ts';

const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000;
export const SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS = 11_000;
Expand Down Expand Up @@ -241,6 +246,11 @@ export async function startDaemonRuntime(
setRunnerLeaseOwnerStateDir(baseDir);

const sessionStore = new SessionStore(sessionsDir);
const ownedProcessRecords = createOwnedProcessRecordStore({
stateDir: baseDir,
sessionsDir,
resolveSessionDir: (sessionId) => sessionStore.resolveSessionDir(sessionId),
});
const appLogAdmissionLedger = createAppLogAdmissionLedger();
const screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger();
const version = readVersion();
Expand All @@ -253,6 +263,7 @@ export async function startDaemonRuntime(
providerRuntimes: providerDeviceRuntimes,
providerModules: providerComposition.platformModules,
sessionsDir,
ownedProcesses: ownedProcessRecords,
resolveSessionArtifacts: (sessionId) => ({
outputPath: sessionStore.resolveAppLogPath(sessionId),
pidPath: sessionStore.resolveAppLogPidPath(sessionId),
Expand Down Expand Up @@ -293,6 +304,7 @@ export async function startDaemonRuntime(
const dispatchRequest = createRequestHandler({
logPath,
stateDir: baseDir,
ownedProcessRecords,
token,
sessionStore,
leaseRegistry,
Expand Down Expand Up @@ -487,7 +499,15 @@ export async function startDaemonRuntime(
scope: createDaemonRecoveryPlatformScope(),
onDiagnostic: (diagnostic) => startupAppLogDiagnostics.push(diagnostic),
});
await cleanupWebBrowserOrphansForDaemonStartup({ stateDir: baseDir, sessionStore });
await reapOwnedProcessRecordsAtStartup(ownedProcessRecords, {
openWebSessionNames: openWebSessionNames(sessionStore),
purposes: ['simctl-screen-recording'],
});
await cleanupWebBrowserOrphansForDaemonStartup({
stateDir: baseDir,
sessionStore,
ownedProcessRecords,
});
// Marker-gated lifecycle recovery owns test-IME orphan repair. Its implementation remains
// lazy until the marker exists, so a normal daemon startup does not load or probe adb.
void applicationLifecycle.recoverStartupResources({ stateDir: baseDir }).catch((error) => {
Expand Down Expand Up @@ -651,12 +671,16 @@ async function reconcileDeviceClaimsForDaemonStartup(
export async function cleanupWebBrowserOrphansForDaemonStartup(params: {
stateDir: string;
sessionStore: SessionStore;
ownedProcessRecords?: OwnedProcessRecordStore;
}): Promise<void> {
const status = getManagedAgentBrowserStatus({ stateDir: params.stateDir });
if (!status.installed) return;
try {
await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', {
openWebSessionNames: openWebSessionNames(params.sessionStore),
...(params.ownedProcessRecords === undefined
? {}
: { ownedProcessRecords: params.ownedProcessRecords }),
});
} catch (error) {
emitDiagnostic({
Expand Down
4 changes: 3 additions & 1 deletion src/platform-runtime-operation-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AppLogSessionArtifacts,
DeviceShutdownRuntimeLoaders,
HostCommandRequest,
OwnedProcessRecordWriter,
PlatformRuntimeHost,
} from '@agent-device/contracts/platform';
import type { DeviceInfo } from '@agent-device/kernel/device';
Expand Down Expand Up @@ -33,6 +34,7 @@ export function createPlatformRuntimeHost(options: {
sessionsDir: string;
resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts;
shutdownLoaders: DeviceShutdownRuntimeLoaders;
ownedProcesses?: OwnedProcessRecordWriter;
}): PlatformRuntimeHost {
const processes = createManagedAppLogProcesses(options.sessionsDir);
const localProcessTransport = Object.freeze({ mode: 'local' as const, start: processes.start });
Expand Down Expand Up @@ -104,7 +106,7 @@ export function createPlatformRuntimeHost(options: {
{ appleTools, commands },
options.shutdownLoaders,
),
screenRecording: createScreenRecordingRuntimeHost(),
screenRecording: createScreenRecordingRuntimeHost({ ownedProcesses: options.ownedProcesses }),
snapshot: createSnapshotRuntimeHost(),
localInteractors: createLocalApplicationInteractorHost(),
appleApplications,
Expand Down
Loading
Loading