diff --git a/src/daemon/server/daemon-runtime-recording-reaper.test.ts b/src/daemon/server/daemon-runtime-recording-reaper.test.ts new file mode 100644 index 000000000..a816b129a --- /dev/null +++ b/src/daemon/server/daemon-runtime-recording-reaper.test.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import { afterEach, expect, test, vi } from 'vitest'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +const reapCalls = vi.hoisted(() => [] as Array>); + +vi.mock('@agent-device/host-kit/process', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + reapOwnedProcessRecordsAtStartup: (_store: unknown, options?: Record) => { + reapCalls.push(options ?? {}); + return Promise.resolve({ terminated: [], failed: [] }); + }, + }; +}); + +vi.mock('../../platform-runtime.ts', () => ({ + androidObservation: {}, + createRequestPlatformProviders: () => ({ + run: async (_context: unknown, task: () => Promise) => await task(), + }), + createPlatformRuntimeGateway: () => ({ + applicationLifecycle: { + recoverStartupResources: async () => {}, + detachForDaemonShutdown: async () => {}, + finalizeDaemonShutdown: async () => {}, + }, + inspectFacts: async () => { + throw new Error('unused'); + }, + bind: async () => { + throw new Error('unused'); + }, + shutdown: async () => {}, + }), + createPlatformDeviceInventoryGateways: () => ({}), +})); + +vi.mock('../../provider-device-runtimes.ts', () => ({ + DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], + createDefaultProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), +})); + +import { startDaemonRuntime } from './daemon-runtime.ts'; + +afterEach(() => { + reapCalls.length = 0; +}); + +test('daemon startup reaps orphaned simctl recorders with a graceful finalize window', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-recording-reaper-'); + try { + const runtime = await startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + }); + expect(runtime).not.toBeNull(); + + const recordingReap = reapCalls.find( + (call) => JSON.stringify(call.purposes) === JSON.stringify(['simctl-screen-recording']), + ); + expect(recordingReap, 'startup should reap the simctl recorder purpose').toBeDefined(); + // An orphaned recorder must get the same graceful finalize window the live stop path allows, + // so it releases the host recording lock instead of leaving it dangling for the next session. + expect(Number(recordingReap!.termTimeoutMs)).toBeGreaterThanOrEqual(5_000); + + await runtime?.shutdown(); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 604eb9322..7735bebc7 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -83,6 +83,10 @@ import { createScreenRecordingAdmissionLedger } from '../screen-recording-admiss const DAEMON_SESSION_LEASE_RELEASE_TIMEOUT_MS = 1_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; const DAEMON_PROVIDER_RELEASE_DRAIN_TIMEOUT_MS = 2_000; +// An orphaned `simctl recordVideo` releases the host-wide recording lock only after it finishes +// finalizing on SIGINT; force-killing it sooner leaves every later recording failing with EBUSY +// (#2170). Bound the grace to the recorder purpose, so daemon startup stays under the client budget. +const DAEMON_RECORDING_REAP_TERM_TIMEOUT_MS = 5_000; type WritableOutput = { write: (chunk: string) => unknown; @@ -515,6 +519,7 @@ export async function startDaemonRuntime( await reapOwnedProcessRecordsAtStartup(ownedProcessRecords, { openWebSessionNames: openWebSessionNames(sessionStore), purposes: ['simctl-screen-recording'], + termTimeoutMs: DAEMON_RECORDING_REAP_TERM_TIMEOUT_MS, }); await cleanupWebBrowserOrphansForDaemonStartup({ stateDir: baseDir,