diff --git a/src/daemon/handlers/__tests__/record-trace.test.ts b/src/daemon/handlers/__tests__/record-trace.test.ts index a1216d07b..148fd9bba 100644 --- a/src/daemon/handlers/__tests__/record-trace.test.ts +++ b/src/daemon/handlers/__tests__/record-trace.test.ts @@ -54,6 +54,7 @@ vi.mock('../../device-ready.ts', () => ({ })); import { handleRecordTraceCommands } from '../record-trace.ts'; +import { stopSessionRecordingForTeardown } from '../record-trace-recording.ts'; import { deriveRecordingTelemetryPath } from '../../recording-telemetry.ts'; import { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; @@ -986,6 +987,46 @@ test('record stop leaves a short visual tail after iOS simulator gestures', asyn expect(kill).toHaveBeenCalledWith('SIGINT'); }); +test('stopSessionRecordingForTeardown finalizes an active iOS simulator recording and clears session state', async () => { + const outPath = path.join(os.tmpdir(), `agent-device-teardown-${Date.now()}.mp4`); + fs.writeFileSync(outPath, 'recorded-bytes'); + const session = makeIosSimulatorRecordingSession('ios-sim-teardown', { outPath }); + const recording = session.recording; + const kill = recording?.platform === 'ios' ? recording.child.kill : undefined; + + await stopSessionRecordingForTeardown(session); + + // Teardown must SIGINT the recorder (which finalizes the mp4) rather than + // orphaning the simctl child, and must detach the recording from the session. + expect(kill).toHaveBeenCalledWith('SIGINT'); + expect(session.recording).toBeUndefined(); +}); + +test('stopSessionRecordingForTeardown is a no-op when the session has no active recording', async () => { + const session = makeIosSimulatorSession('ios-sim-teardown-no-recording'); + + await expect(stopSessionRecordingForTeardown(session)).resolves.toBeUndefined(); + + expect(session.recording).toBeUndefined(); +}); + +test('stopSessionRecordingForTeardown rethrows a typed stop failure for the cleanup-failure channel', async () => { + const session = makeIosSimulatorRecordingSession('ios-sim-teardown-stop-failure', { + startedAt: Date.now() - 5_000, + }); + const recording = session.recording; + if (recording?.platform === 'ios') { + recording.wait = Promise.resolve({ stdout: '', stderr: 'recorder crashed', exitCode: 1 }); + } + + await expect(stopSessionRecordingForTeardown(session)).rejects.toThrow( + /failed to stop recording/, + ); + + // The recording is still detached so a retry cannot double-stop the recorder. + expect(session.recording).toBeUndefined(); +}); + test('record stop reports too-short iOS simulator recordings without leaving invalid output', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-too-short'; diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index aba701c80..f709d33e6 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -39,6 +39,10 @@ vi.mock('../../../platforms/apple/os/macos/helper.ts', async (importOriginal) => await importOriginal(); return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) }; }); +vi.mock('../../../utils/video.ts', () => ({ + waitForStableFile: vi.fn(async () => {}), + isPlayableVideo: vi.fn(async () => true), +})); vi.mock('../../runtime-hints.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, clearRuntimeHintsFromApp: vi.fn(async () => {}) }; @@ -91,6 +95,40 @@ function makeSession(name: string, device: SessionState['device']): SessionState }; } +function makeIosSimulatorRecordingSession( + name: string, + options: { recorderExitCode?: number } = {}, +): SessionState { + const session = makeSession(name, { + platform: 'apple', + id: 'sim-udid-recording', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }); + session.appBundleId = 'com.example.app'; + session.recording = { + platform: 'ios', + outPath: path.join(os.tmpdir(), `${name}.mp4`), + startedAt: Date.now() - 5_000, + showTouches: false, + gestureEvents: [], + child: { kill: vi.fn(), pid: 4242 }, + wait: Promise.resolve({ + stdout: '', + stderr: options.recorderExitCode ? 'recorder crashed' : '', + exitCode: options.recorderExitCode ?? 0, + }), + }; + return session; +} + +function recordingKillMock(session: SessionState): ReturnType { + const recording = session.recording; + if (recording?.platform !== 'ios') throw new Error('expected an iOS simulator recording'); + return recording.child.kill as ReturnType; +} + beforeEach(() => { vi.clearAllMocks(); setActiveProviderDeviceRuntimes([]); @@ -393,6 +431,91 @@ test('daemon session teardown stops active Apple xctrace perf capture', async () expect(session.applePerf?.active).toBeUndefined(); }); +test('close finalizes an active iOS simulator recording before deleting the session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-active-recording-close-session'; + const session = makeIosSimulatorRecordingSession(sessionName); + const kill = recordingKillMock(session); + sessionStore.set(sessionName, session); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + // The recorder was signaled (SIGINT finalizes the simctl mp4), the recording + // was detached, and the session was deleted — no orphaned recordVideo child. + expect(kill).toHaveBeenCalledWith('SIGINT'); + expect(session.recording).toBeUndefined(); + expect(sessionStore.get(sessionName)).toBeUndefined(); + // An active recording at close time still defeats iOS runner retention even + // though the recording is finalized (and cleared) before the retention step. + expect(mockStopIosRunnerSession).toHaveBeenCalledWith(session.device.id); +}); + +test('close surfaces a recording finalization failure through the cleanup-failure channel', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-recording-close-failure-session'; + const session = makeIosSimulatorRecordingSession(sessionName, { recorderExitCode: 1 }); + const kill = recordingKillMock(session); + sessionStore.set(sessionName, session); + + await expect( + handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toThrow(/recording: .*failed to stop recording/); + + // Cleanup failure is reported, later cleanup still ran, session still deleted. + expect(kill).toHaveBeenCalledWith('SIGINT'); + expect(mockStopIosRunnerSession).toHaveBeenCalledWith(session.device.id); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('daemon session teardown finalizes an active iOS simulator recording', async () => { + const sessionName = 'ios-active-recording-teardown-session'; + const session = makeIosSimulatorRecordingSession(sessionName); + const kill = recordingKillMock(session); + + await teardownSessionResources(session, sessionName); + + expect(kill).toHaveBeenCalledWith('SIGINT'); + expect(session.recording).toBeUndefined(); +}); + +test('daemon session teardown surfaces a recording finalization failure', async () => { + const sessionName = 'ios-recording-teardown-failure-session'; + const session = makeIosSimulatorRecordingSession(sessionName, { recorderExitCode: 1 }); + const kill = recordingKillMock(session); + + await expect(teardownSessionResources(session, sessionName)).rejects.toThrow( + /recording: .*failed to stop recording/, + ); + + expect(kill).toHaveBeenCalledWith('SIGINT'); + expect(session.recording).toBeUndefined(); +}); + test('close stops active Android native perf capture before deleting session', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-active-native-perf-session'; diff --git a/src/daemon/handlers/record-trace-ios-simulator.ts b/src/daemon/handlers/record-trace-ios-simulator.ts index 13811b47d..4fff00866 100644 --- a/src/daemon/handlers/record-trace-ios-simulator.ts +++ b/src/daemon/handlers/record-trace-ios-simulator.ts @@ -10,6 +10,18 @@ export const IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS = 5_000; const IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS = 2_000; +/** + * Worst-case wall-clock time for {@link stopIosSimulatorRecordingProcess} to + * conclude: the direct child-handle SIGINT wait plus the three escalating + * PID-based retries (SIGINT, SIGTERM, SIGKILL). Any teardown path that bounds + * recording finalization with its own timeout (daemon shutdown's per-session + * teardown race) must budget at least this long, or a recorder stuck past the + * direct-handle wait is abandoned mid-escalation and the simctl child orphans + * with an unfinalized 0-byte mp4. + */ +export const IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS = + IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS + 3 * IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS; + type IosSimulatorRecording = Extract, { platform: 'ios' }>; export async function stopIosSimulatorRecordingProcess(params: { diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index c3aee2503..b04e12afb 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { AppError, toAppErrorCode } from '../../kernel/errors.ts'; import { sleep } from '../../utils/timeouts.ts'; import { resolveTargetDevice } from '../../core/dispatch.ts'; import { ensureDeviceReady } from '../device-ready.ts'; @@ -521,6 +522,55 @@ async function releaseRecordOnlySession( sessionStore.delete(sessionName); } +/** + * Best-effort finalization of a session's still-active recording during + * teardown (session close or daemon shutdown). The normal `test --record-video` + * and `record stop` flows stop the recorder explicitly, but a session torn down + * while a recording is still active — e.g. the daemon is signalled/reaped or the + * session is closed before an explicit stop — otherwise leaks its recorder + * process. On the iOS simulator the `simctl io … recordVideo` child then + * reparents to launchd (PPID 1) and, because simctl only finalizes the mp4 on + * SIGINT, leaves a 0-byte file that also holds the device's single host + * recording slot (later attempts fail with "Host recording is already in + * progress"). Routing through the normal {@link stopActiveRecording} path sends + * SIGINT to the recorder and awaits the finalized file on every platform. + * + * The recording is detached from the session first so a late explicit + * `record stop` (or a second teardown pass) cannot double-stop the same + * recorder. A typed stop failure (the recorder could not be finalized) is + * rethrown as an {@link AppError} so both callers' isolated cleanup channels + * (`runIsolatedSessionCleanup` / `attemptCleanup`) record it as a `recording` + * cleanup failure instead of silently reporting successful cleanup; later + * cleanup steps still run because those channels isolate per-step failures. + */ +export async function stopSessionRecordingForTeardown( + session: SessionState, + logPath?: string, +): Promise { + const recording = session.recording; + if (!recording) return; + session.recording = undefined; + const req: DaemonRequest = { + token: '', + session: session.name, + command: 'record', + positionals: ['stop'], + flags: {}, + }; + const stopFailure = await stopActiveRecording({ + req, + activeSession: session, + device: session.device, + logPath, + deps: buildRecordTraceDeps(), + recording, + stopRequestedAt: Date.now(), + }); + if (stopFailure && stopFailure.ok === false) { + throw new AppError(toAppErrorCode(stopFailure.error.code), stopFailure.error.message); + } +} + // --- Main command handler --- export async function handleRecordCommand(params: { diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index c5b2ae24a..df9999eea 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -24,6 +24,7 @@ import { import { errorResponse } from './response.ts'; import { expireRefFrame } from '../ref-frame.ts'; import { recordSessionAction } from './handler-utils.ts'; +import { stopSessionRecordingForTeardown } from './record-trace-recording.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { releaseSessionLease } from '../lease-lifecycle.ts'; import type { LeaseLifecycleProvider } from './lease.ts'; @@ -195,6 +196,10 @@ async function runSessionCloseTeardown(params: { cleanupFailures.push({ step, error }); } }; + // Decide runner retention from the PRE-teardown state: an active recording + // defeats retention, and `stopBestEffortSessionResources` below finalizes (and + // clears) that recording, so the decision must be captured before it runs. + const retainAppleRunner = shouldRetainAppleRunnerAfterClose(req, session); await stopBestEffortSessionResources(session, sessionStore, attemptCleanup); // The targeted platform close is the primary operation, not best-effort cleanup: // its AppError (code/details/hint) is preserved and returned for the caller to @@ -203,7 +208,7 @@ async function runSessionCloseTeardown(params: { const platformCloseError = params.skipPlatformClose ? undefined : await dispatchTargetedPlatformClose({ req, session, logPath }); - await stopOrRetainAppleRunnerAfterClose(req, session, attemptCleanup); + await stopOrRetainAppleRunnerAfterClose(retainAppleRunner, session, attemptCleanup); await clearSessionRuntimeHints(session, sessionStore, sessionName); // ADR 0012 decision 6 (BLOCKER 2): a repair-armed session already recorded its // finalize `close` and committed (or aborted) its healed `.ad` BEFORE this @@ -237,6 +242,12 @@ async function stopBestEffortSessionResources( sessionStore: SessionStore, attemptCleanup: CleanupRunner, ): Promise { + // Finalize any still-active recording first so closing a session mid-recording + // (without an explicit `record stop`) cannot leak the recorder process; must + // run before the Apple runner is stopped below since overlay finalization + // consults the runner. `shouldRetainAppleRunnerAfterClose` then observes the + // now-cleared `session.recording`. + await attemptCleanup('recording', () => stopSessionRecordingForTeardown(session)); await attemptCleanup('app_log', () => stopSessionAppLog(session)); await attemptCleanup('audio_probe', async () => { await stopSessionAudioProbe(session, 'session-close'); @@ -379,12 +390,12 @@ async function clearSessionRuntimeHints( } async function stopOrRetainAppleRunnerAfterClose( - req: DaemonRequest, + retainAppleRunner: boolean, session: SessionState, attemptCleanup: CleanupRunner, ): Promise { if (!isApplePlatform(session.device.platform)) return; - if (!shouldRetainAppleRunnerAfterClose(req, session)) { + if (!retainAppleRunner) { // The targeted close path stops before dispatch to avoid runner/app races. // Stop again here for idempotent cleanup, and keep cleanup-sensitive closes explicit. await attemptCleanup('apple_runner', () => stopAppleRunnerForClose(session)); diff --git a/src/daemon/server/daemon-runtime-recording-teardown.test.ts b/src/daemon/server/daemon-runtime-recording-teardown.test.ts new file mode 100644 index 000000000..d407b0ce1 --- /dev/null +++ b/src/daemon/server/daemon-runtime-recording-teardown.test.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; + +vi.mock('../../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 1 })), + }; +}); +vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) }; +}); + +import { SessionStore } from '../session-store.ts'; +import type { SessionState } from '../types.ts'; +import { IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS } from '../handlers/record-trace-ios-simulator.ts'; +import { + resolveDaemonSessionTeardownTimeoutMs, + teardownDaemonSessionForShutdown, +} from './daemon-runtime.ts'; + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +function makeRecordingSession(name: string): SessionState { + const session: SessionState = { + name, + device: { + platform: 'apple', + id: 'sim-udid-shutdown', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }, + createdAt: Date.now(), + actions: [], + }; + session.recording = { + platform: 'ios', + outPath: path.join(os.tmpdir(), `${name}.mp4`), + startedAt: Date.now() - 5_000, + showTouches: false, + gestureEvents: [], + recorderPid: 4242, + // Slow direct-handle path: the recorder never exits on its own, so the stop + // must run the full SIGINT -> SIGTERM -> SIGKILL escalation. + child: { kill: vi.fn(), pid: 4242 }, + wait: new Promise(() => {}), + }; + return session; +} + +test('daemon session teardown budget extends past the recorder-stop escalation for recording sessions', () => { + const session = makeRecordingSession('budget-session'); + const withRecording = resolveDaemonSessionTeardownTimeoutMs(session); + session.recording = undefined; + const withoutRecording = resolveDaemonSessionTeardownTimeoutMs(session); + + // The base budget alone is shorter than the recorder-stop escalation, so a + // recording session must get the base budget PLUS the full escalation. + expect(withoutRecording).toBeLessThan(IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS); + expect(withRecording - withoutRecording).toBeGreaterThanOrEqual( + IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS, + ); +}); + +test('daemon shutdown lets a slow recorder run its full stop escalation instead of timing out', async () => { + vi.useFakeTimers(); + const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-shutdown-recording-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'shutdown-slow-recorder-session'; + const session = makeRecordingSession(sessionName); + const recording = session.recording; + const kill = recording?.platform === 'ios' ? vi.mocked(recording.child.kill) : undefined; + sessionStore.set(sessionName, session); + const stderrChunks: string[] = []; + const stderr = { write: (chunk: string) => stderrChunks.push(chunk) }; + + try { + const teardownPromise = teardownDaemonSessionForShutdown({ + session, + sessionStore, + stateDir: root, + stderr, + }); + // Advance past the full escalation (direct 5s wait + 3 x 2s retries) but + // NOT past the extended per-session budget: the teardown must win the race. + await vi.advanceTimersByTimeAsync(12_000); + await teardownPromise; + + // The recorder was escalated all the way to SIGKILL before shutdown moved on. + expect(kill?.mock.calls.map((call) => call[0])).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']); + expect(processKill.mock.calls.map((call) => call[1])).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']); + // The extended budget covered the escalation: teardown completed (surfacing + // the recorder-stop failure) rather than being abandoned by the timeout. + expect(stderrChunks.join('')).toMatch(/Daemon session teardown error .*recording/); + expect(stderrChunks.join('')).not.toMatch(/timed out/); + expect(sessionStore.get(sessionName)).toBeUndefined(); + } finally { + processKill.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 01df28da0..601747c1f 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -18,6 +18,7 @@ import { LeaseRegistry } from '../lease-registry.ts'; import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; import { createRequestHandler } from '../request-router.ts'; import { teardownSessionResources } from '../session-teardown.ts'; +import { IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS } from '../handlers/record-trace-ios-simulator.ts'; import { closeDaemonServers } from './server-shutdown.ts'; import type { DaemonInvokeFn, SessionState } from '../types.ts'; import { createDaemonIdleReap } from './daemon-idle-reap.ts'; @@ -61,6 +62,57 @@ type WritableOutput = { write: (chunk: string) => unknown; }; +/** + * Per-session teardown budget for daemon shutdown. The base budget is enough + * for ordinary resource cleanup, but a session with an active recording must be + * allowed to run the full recorder-stop escalation (direct-handle SIGINT wait + * plus PID-based SIGINT/SIGTERM/SIGKILL retries), which alone exceeds the base + * budget — racing that against the base 5s would let shutdown advance to + * process exit exactly when fallback cleanup begins, orphaning the recorder + * with an unfinalized mp4. The recording budget EXTENDS the base one so the + * session's remaining cleanup steps keep their usual allowance. + */ +export function resolveDaemonSessionTeardownTimeoutMs(session: SessionState): number { + if (!session.recording) return DAEMON_SESSION_TEARDOWN_TIMEOUT_MS; + return DAEMON_SESSION_TEARDOWN_TIMEOUT_MS + IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS; +} + +/** + * Daemon-shutdown teardown of one session: bounded resource cleanup (budget + * from {@link resolveDaemonSessionTeardownTimeoutMs}, resolved BEFORE cleanup + * starts since finalizing the recording detaches `session.recording`), then the + * repair-commit finalization and session deletion. Cleanup failures — including + * a recorder that could not be finalized — surface on stderr instead of being + * silently swallowed. + */ +export async function teardownDaemonSessionForShutdown(params: { + session: SessionState; + sessionStore: SessionStore; + stateDir?: string; + stderr: WritableOutput; +}): Promise { + const { session, sessionStore, stateDir, stderr } = params; + const timeoutMs = resolveDaemonSessionTeardownTimeoutMs(session); + const teardown = teardownSessionResources(session, session.name, stateDir).catch((error) => { + stderr.write( + `Daemon session teardown error (${session.name}): ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + }); + await Promise.race([ + teardown, + sleep(timeoutMs).then(() => { + stderr.write(`Daemon session teardown timed out (${session.name}).\n`); + }), + ]); + // ADR 0012 decision 6, R7 + commit semantics (C2/C5a): commit the healed + // `.ad` iff the repair transaction completed, else leave a bounded + // `REPAIR_SESSION_EXPIRED` tombstone for the reaped-before-finalize case. + sessionStore.finalizeRepairTeardown(session); + sessionStore.delete(session.name); +} + export type DaemonRuntimeOptions = { env?: NodeJS.ProcessEnv; stdout?: WritableOutput; @@ -154,26 +206,8 @@ export async function startDaemonRuntime( ); }; - const teardownDaemonSession = async (session: SessionState): Promise => { - const teardown = teardownSessionResources(session, session.name, baseDir).catch((error) => { - stderr.write( - `Daemon session teardown error (${session.name}): ${ - error instanceof Error ? error.message : String(error) - }\n`, - ); - }); - await Promise.race([ - teardown, - sleep(DAEMON_SESSION_TEARDOWN_TIMEOUT_MS).then(() => { - stderr.write(`Daemon session teardown timed out (${session.name}).\n`); - }), - ]); - // ADR 0012 decision 6, R7 + commit semantics (C2/C5a): commit the healed - // `.ad` iff the repair transaction completed, else leave a bounded - // `REPAIR_SESSION_EXPIRED` tombstone for the reaped-before-finalize case. - sessionStore.finalizeRepairTeardown(session); - sessionStore.delete(session.name); - }; + const teardownDaemonSession = async (session: SessionState): Promise => + await teardownDaemonSessionForShutdown({ session, sessionStore, stateDir: baseDir, stderr }); const teardownDaemonSessions = async (): Promise => { const sessionsToStop = sessionStore.toArray(); diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index fc42866f7..2f82811e9 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -10,6 +10,7 @@ import { stopAndroidSnapshotHelperSessionForDevice } from '../platforms/android/ import { restoreAndroidTestIme } from '../platforms/android/ime-lifecycle.ts'; import { cleanupRetainedMaterializedPathsForSession } from './materialized-path-registry.ts'; import { stopSessionAudioProbe } from './audio-probe.ts'; +import { stopSessionRecordingForTeardown } from './handlers/record-trace-recording.ts'; import type { SessionState } from './types.ts'; export { stopSessionAudioProbe } from './audio-probe.ts'; @@ -138,6 +139,12 @@ export async function teardownSessionResources( stateDir?: string, ): Promise { const steps: SessionCleanupStep[] = [ + // Finalize any still-active recording BEFORE the Apple runner is stopped + // below: the runner supplies gesture-telemetry for overlay finalization, and + // signalling the recorder first prevents a leaked `simctl recordVideo` child + // (and its 0-byte, slot-holding mp4) when a session is torn down — including + // on daemon shutdown — without an explicit `record stop`. + { step: 'recording', run: () => stopSessionRecordingForTeardown(session) }, { step: 'app_log', run: () => stopSessionAppLog(session) }, { step: 'audio_probe',