From 4086f9b24664eedf22dbd98dc40e871941394557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 16:06:18 +0200 Subject: [PATCH 1/2] refactor: model recording backends --- CONTEXT.md | 1 + .../record-trace-ios-simulator-recording.ts | 230 ++++++++++++ .../record-trace-recording-backends.ts | 203 +++++++++++ src/daemon/handlers/record-trace-recording.ts | 340 +++--------------- 4 files changed, 476 insertions(+), 298 deletions(-) create mode 100644 src/daemon/handlers/record-trace-ios-simulator-recording.ts create mode 100644 src/daemon/handlers/record-trace-recording-backends.ts diff --git a/CONTEXT.md b/CONTEXT.md index 5295238f0..cf19f19e4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,7 @@ - Target: selected automation destination, such as mobile, tv, or desktop. - Modality: broad supported device family, such as mobile, tv, or desktop. - Session: daemon-owned state for a selected target and opened app or surface. +- Recording backend: daemon-internal module interface selected per recording target that owns platform recording validation, output path policy, start/stop execution, and record-only cleanup below the daemon recording lifecycle. - Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints. - Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution. - Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior, distinct from the public command surface and daemon command registry. The Swift runner traits classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the alert command as read-only only for its `get` action. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type. diff --git a/src/daemon/handlers/record-trace-ios-simulator-recording.ts b/src/daemon/handlers/record-trace-ios-simulator-recording.ts new file mode 100644 index 000000000..9f15bf0de --- /dev/null +++ b/src/daemon/handlers/record-trace-ios-simulator-recording.ts @@ -0,0 +1,230 @@ +import fs from 'node:fs'; +import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; +import { sleep } from '../../utils/timeouts.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { + buildRecordStopFailure, + formatRecordTraceError, + formatRecordTraceExecFailure, +} from '../record-trace-errors.ts'; +import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; +import { + getIosRunnerOptions, + normalizeAppBundleId, + warmIosSimulatorRunner, +} from './record-trace-ios.ts'; +import { + IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS, + stopIosSimulatorRecordingProcess, +} from './record-trace-ios-simulator.ts'; +import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; +import { errorResponse } from './response.ts'; + +const LOCAL_RECORDING_READY_POLL_MS = 250; +const LOCAL_RECORDING_READY_SETTLE_POLLS = 2; +const IOS_SIMULATOR_VIDEO_READY_POLL_MS = 150; +const IOS_SIMULATOR_VIDEO_READY_ATTEMPTS = 12; + +type ActiveRecording = NonNullable; +type IosSimulatorRecording = Extract; + +export async function startIosSimulatorRecording(params: { + req: DaemonRequest; + activeSession: SessionState; + device: SessionState['device']; + logPath?: string; + deps: RecordTraceDeps; + recordingBase: RecordingBase; + resolvedOut: string; +}): Promise { + const { req, activeSession, device, logPath, deps, recordingBase, resolvedOut } = params; + + // The warm-up carries the gesture-clock anchor on its snapshot response when the runner + // stamps it, letting us skip a standalone uptime command. The anchor is a pure clock pair + // (origin uptime + daemon receipt time), so capturing it before the recorder spawn/settle + // window is equivalent to capturing it after: recordingStartedAt stays readyAt below. + const warmAnchor = recordingBase.showTouches + ? await warmIosSimulatorRunner({ req, activeSession, device, logPath, deps }) + : undefined; + const { child, wait } = deps.startIosSimulatorRecording({ device, outPath: resolvedOut }); + const readyAt = await waitForLocalRecordingSettleWindow(resolvedOut); + let gestureClockOriginAtMs: number | undefined; + let gestureClockOriginUptimeMs: number | undefined; + if (warmAnchor) { + gestureClockOriginAtMs = warmAnchor.gestureClockOriginAtMs; + gestureClockOriginUptimeMs = warmAnchor.gestureClockOriginUptimeMs; + } else if (recordingBase.showTouches) { + // Fallback for older runner builds (or a failed/unavailable warm anchor): issue a + // standalone uptime command and pair it at the request midpoint. + try { + const uptimeRequestStartedAtMs = Date.now(); + const uptimeResult = await deps.runIosRunnerCommand( + device, + { + command: 'uptime', + appBundleId: normalizeAppBundleId(activeSession), + }, + getIosRunnerOptions(req, logPath, activeSession), + ); + const uptimeRequestFinishedAtMs = Date.now(); + gestureClockOriginAtMs = Math.round( + (uptimeRequestStartedAtMs + uptimeRequestFinishedAtMs) / 2, + ); + gestureClockOriginUptimeMs = + typeof uptimeResult.currentUptimeMs === 'number' ? uptimeResult.currentUptimeMs : undefined; + } catch { + // Best effort only; wall-clock fallback remains available. + } + } + return { + platform: 'ios', + child, + wait, + ...recordingBase, + recorderPid: child.pid, + startedAt: readyAt, + gestureClockOriginAtMs: + gestureClockOriginUptimeMs === undefined ? undefined : gestureClockOriginAtMs, + gestureClockOriginUptimeMs, + }; +} + +export async function stopIosSimulatorRecording(params: { + deps: RecordTraceDeps; + recording: IosSimulatorRecording; + stopRequestedAt: number; +}): Promise { + const { deps, recording, stopRequestedAt } = params; + + await withDiagnosticTimer('record_stop_tail_settle', () => deps.waitForRecordingTail(recording), { + platform: recording.platform, + gestureEventCount: recording.gestureEvents.length, + }); + const stopResult = await withDiagnosticTimer( + 'record_stop_ios_simulator_process', + () => stopIosSimulatorRecordingProcess({ deps, recording }), + { + outPath: recording.outPath, + }, + ); + if (!stopResult) { + return buildIosSimulatorRecordingStopFailure( + `failed to stop recording: simctl recordVideo did not exit after ${IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS}ms and forced cleanup`, + recording, + stopRequestedAt, + ); + } + if (stopResult.exitCode !== 0) { + return buildIosSimulatorRecordingStopFailure( + `failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'simctl recordVideo')}`, + recording, + stopRequestedAt, + ); + } + + await withDiagnosticTimer( + 'record_stop_video_stable', + () => + deps.waitForStableFile(recording.outPath, { + pollMs: IOS_SIMULATOR_VIDEO_READY_POLL_MS, + attempts: IOS_SIMULATOR_VIDEO_READY_ATTEMPTS, + }), + { + outPath: recording.outPath, + }, + ); + const playable = await withDiagnosticTimer( + 'record_stop_video_playable_check', + () => deps.isPlayableVideo(recording.outPath), + { + outPath: recording.outPath, + }, + ); + if (!playable) { + return buildIosSimulatorRecordingStopFailure( + `failed to stop recording: ${recording.outPath} was not finalized into a playable video`, + recording, + stopRequestedAt, + ); + } + + if (recording.maxSize !== undefined) { + try { + await withDiagnosticTimer( + 'record_stop_resize', + () => + deps.resizeRecording({ + videoPath: recording.outPath, + maxSize: recording.maxSize!, + exportQuality: recording.exportQuality, + targetLabel: 'iOS recording', + }), + { + outPath: recording.outPath, + maxSize: recording.maxSize, + }, + ); + } catch (error) { + recording.overlayWarning = `failed to resize recording: ${formatRecordTraceError(error)}`; + } + } + + await withDiagnosticTimer( + 'record_stop_finalize_overlay', + () => + finalizeRecordingOverlay({ + recording, + deps, + targetLabel: 'iOS recording', + }), + { + outPath: recording.outPath, + showTouches: recording.showTouches, + gestureEventCount: recording.gestureEvents.length, + }, + ); + + return null; +} + +async function waitForLocalRecordingSettleWindow(outPath: string): Promise { + // simctl recordVideo can take a beat to open its output even though recording has already + // started. This is a short settle window, not a strict readiness guarantee. We prefer a + // close recorder anchor over blocking start indefinitely waiting for non-zero bytes. + for (let attempt = 0; attempt < LOCAL_RECORDING_READY_SETTLE_POLLS; attempt += 1) { + try { + const stat = fs.statSync(outPath); + if (stat.size > 0) { + return Date.now(); + } + } catch { + // Wait for the recorder to create the output file. + } + + if (attempt + 1 >= LOCAL_RECORDING_READY_SETTLE_POLLS) { + return Date.now(); + } + + await sleep(LOCAL_RECORDING_READY_POLL_MS); + } + + return Date.now(); +} + +function buildIosSimulatorRecordingStopFailure( + message: string, + recording: IosSimulatorRecording, + stopRequestedAt: number, +): DaemonResponse { + const failure = buildRecordStopFailure(message, recording, stopRequestedAt); + removeInvalidRecordingOutput(recording.outPath); + return errorResponse('COMMAND_FAILED', failure.message); +} + +function removeInvalidRecordingOutput(outPath: string): void { + try { + fs.rmSync(outPath, { force: true }); + } catch { + // Best effort: the error response still reports the failed finalization. + } +} diff --git a/src/daemon/handlers/record-trace-recording-backends.ts b/src/daemon/handlers/record-trace-recording-backends.ts new file mode 100644 index 000000000..909241b31 --- /dev/null +++ b/src/daemon/handlers/record-trace-recording-backends.ts @@ -0,0 +1,203 @@ +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import { errorResponse } from './response.ts'; +import { startAndroidRecording, stopAndroidRecording } from './record-trace-android.ts'; +import { + normalizeAppBundleId, + startIosDeviceRecording, + startMacOsRecording, + stopIosDeviceRecording, + stopMacOsRecording, +} from './record-trace-ios.ts'; +import { + startIosSimulatorRecording, + stopIosSimulatorRecording, +} from './record-trace-ios-simulator-recording.ts'; +import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; + +type ActiveRecording = NonNullable; + +type RecordingOutputPathResult = + | { ok: true; path: string } + | { ok: false; response: DaemonResponse }; + +type RecordingOutputPathContext = { + req: DaemonRequest; + device: SessionState['device']; +}; + +type RecordingStartContext = { + req: DaemonRequest; + activeSession: SessionState; + sessionStore: SessionStore; + device: SessionState['device']; + logPath?: string; + deps: RecordTraceDeps; + fpsFlag: number | undefined; + recordingBase: RecordingBase; + resolvedOut: string; +}; + +type RecordingStopContext = { + req: DaemonRequest; + activeSession: SessionState; + device: SessionState['device']; + logPath?: string; + deps: RecordTraceDeps; + recording: ActiveRecording; + stopRequestedAt: number; +}; + +export type RecordingBackend = { + validateStart?: (req: DaemonRequest) => DaemonResponse | null; + resolveOutputPath: (context: RecordingOutputPathContext) => RecordingOutputPathResult; + start: (context: RecordingStartContext) => Promise; + stop: (context: RecordingStopContext) => Promise; + cleanupRecordOnlySession?: (session: SessionState) => Promise; +}; + +export function resolveRecordingBackendForDevice(device: SessionState['device']): RecordingBackend { + if (device.platform === 'android') return androidRecordingBackend; + if (device.platform === 'macos') return macOsRecordingBackend; + if (device.platform === 'ios' && device.kind === 'device') return iosDeviceRecordingBackend; + if (device.platform === 'ios') return iosSimulatorRecordingBackend; + return unsupportedRecordingBackend; +} + +export function resolveRecordingBackendForRecording(recording: ActiveRecording): RecordingBackend { + switch (recording.platform) { + case 'android': + return androidRecordingBackend; + case 'ios': + return iosSimulatorRecordingBackend; + case 'ios-device-runner': + return iosDeviceRecordingBackend; + case 'macos-runner': + return macOsRecordingBackend; + } + + const exhaustive: never = recording; + return exhaustive; +} + +function resolveNativeRecordingOutputPath({ + req, +}: RecordingOutputPathContext): RecordingOutputPathResult { + const requestedPath = req.positionals?.[1]; + return { ok: true, path: requestedPath ?? `./recording-${Date.now()}.mp4` }; +} + +const iosDeviceRecordingBackend: RecordingBackend = { + resolveOutputPath: resolveNativeRecordingOutputPath, + start: async ({ + req, + activeSession, + sessionStore, + device, + logPath, + deps, + fpsFlag, + recordingBase, + }) => { + const appBundleId = normalizeAppBundleId(activeSession); + if (!appBundleId) { + return errorResponse( + 'INVALID_ARGS', + 'record on physical iOS devices requires an active app session; run open first', + ); + } + return await startIosDeviceRecording({ + req, + activeSession, + sessionStore, + device, + logPath, + deps, + fpsFlag, + recordingBase, + appBundleId, + }); + }, + stop: async ({ req, activeSession, device, logPath, deps, recording }) => + await stopIosDeviceRecording({ + req, + activeSession, + device, + logPath, + deps, + recording: recording as Extract, + }), +}; + +const macOsRecordingBackend: RecordingBackend = { + resolveOutputPath: resolveNativeRecordingOutputPath, + start: async ({ req, activeSession, device, logPath, deps, fpsFlag, recordingBase }) => { + const appBundleId = normalizeAppBundleId(activeSession); + if (!appBundleId) { + return errorResponse( + 'INVALID_ARGS', + 'record on macOS requires an active app session; run open first', + ); + } + return await startMacOsRecording({ + req, + activeSession, + device, + logPath, + deps, + fpsFlag, + recordingBase, + appBundleId, + }); + }, + stop: async ({ req, activeSession, device, logPath, deps, recording }) => + await stopMacOsRecording({ + req, + activeSession, + device, + logPath, + deps, + recording: recording as Extract, + }), +}; + +const iosSimulatorRecordingBackend: RecordingBackend = { + resolveOutputPath: resolveNativeRecordingOutputPath, + start: async ({ req, activeSession, device, logPath, deps, recordingBase, resolvedOut }) => + await startIosSimulatorRecording({ + req, + activeSession, + device, + logPath, + deps, + recordingBase, + resolvedOut, + }), + stop: async ({ deps, recording, stopRequestedAt }) => + await stopIosSimulatorRecording({ + deps, + recording: recording as Extract, + stopRequestedAt, + }), +}; + +const androidRecordingBackend: RecordingBackend = { + resolveOutputPath: resolveNativeRecordingOutputPath, + start: async ({ device, recordingBase }) => + await startAndroidRecording({ device, recordingBase }), + stop: async ({ deps, device, recording, stopRequestedAt }) => + await stopAndroidRecording({ + deps, + device, + recording: recording as Extract, + stopRequestedAt, + }), +}; + +const unsupportedRecordingBackend: RecordingBackend = { + resolveOutputPath: resolveNativeRecordingOutputPath, + start: async () => + errorResponse('UNSUPPORTED_OPERATION', 'record is not supported on this device'), + stop: async () => + errorResponse('UNSUPPORTED_OPERATION', 'record is not supported on this device'), +}; diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index 0fd0a93e8..526074ef9 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -21,41 +21,19 @@ import { RECORDING_EXPORT_QUALITIES, recordingQualityInputToExportQuality, } from '../../core/recording-export-quality.ts'; -import { - buildRecordStopFailure, - formatRecordTraceError, - formatRecordTraceExecFailure, -} from '../record-trace-errors.ts'; import { resolveRecordingProvider } from '../recording-provider.ts'; -import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; import { errorResponse } from './response.ts'; -import { startAndroidRecording, stopAndroidRecording } from './record-trace-android.ts'; import { deriveAndroidChunkOutPath } from './record-trace-android-chunks.ts'; -import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; -import { - getIosRunnerOptions, - normalizeAppBundleId, - startIosDeviceRecording, - startMacOsRecording, - stopMacOsRecording, - stopIosDeviceRecording, - warmIosSimulatorRunner, -} from './record-trace-ios.ts'; import { - IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS, - stopIosSimulatorRecordingProcess, -} from './record-trace-ios-simulator.ts'; + resolveRecordingBackendForDevice, + resolveRecordingBackendForRecording, +} from './record-trace-recording-backends.ts'; +import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; import { resolveImplicitSessionScope, resolvePublicSessionName } from '../session-routing.ts'; const IOS_DEVICE_RECORD_MIN_FPS = 1; const IOS_DEVICE_RECORD_MAX_FPS = 120; -const LOCAL_RECORDING_READY_POLL_MS = 250; -const LOCAL_RECORDING_READY_SETTLE_POLLS = 2; const IOS_SIMULATOR_RECORDING_TAIL_SETTLE_MS = 350; -const IOS_SIMULATOR_VIDEO_READY_POLL_MS = 150; -const IOS_SIMULATOR_VIDEO_READY_ATTEMPTS = 12; - -import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; export type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; @@ -96,93 +74,6 @@ function buildRecordingBase(req: DaemonRequest, outPath: string): RecordingBase }; } -async function waitForLocalRecordingSettleWindow(outPath: string): Promise { - // simctl recordVideo can take a beat to open its output even though recording has already - // started. This is a short settle window, not a strict readiness guarantee. We prefer a - // close recorder anchor over blocking start indefinitely waiting for non-zero bytes. - for (let attempt = 0; attempt < LOCAL_RECORDING_READY_SETTLE_POLLS; attempt += 1) { - try { - const stat = fs.statSync(outPath); - if (stat.size > 0) { - return Date.now(); - } - } catch { - // Wait for the recorder to create the output file. - } - - if (attempt + 1 >= LOCAL_RECORDING_READY_SETTLE_POLLS) { - return Date.now(); - } - - await sleep(LOCAL_RECORDING_READY_POLL_MS); - } - - return Date.now(); -} - -// --- Per-platform start helpers --- - -async function startIosSimulatorRecording(params: { - req: DaemonRequest; - activeSession: SessionState; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; - recordingBase: RecordingBase; - resolvedOut: string; -}): Promise> { - const { req, activeSession, device, logPath, deps, recordingBase, resolvedOut } = params; - - // The warm-up carries the gesture-clock anchor on its snapshot response when the runner - // stamps it, letting us skip a standalone uptime command. The anchor is a pure clock pair - // (origin uptime + daemon receipt time), so capturing it before the recorder spawn/settle - // window is equivalent to capturing it after: recordingStartedAt stays readyAt below. - const warmAnchor = recordingBase.showTouches - ? await warmIosSimulatorRunner({ req, activeSession, device, logPath, deps }) - : undefined; - const { child, wait } = deps.startIosSimulatorRecording({ device, outPath: resolvedOut }); - const readyAt = await waitForLocalRecordingSettleWindow(resolvedOut); - let gestureClockOriginAtMs: number | undefined; - let gestureClockOriginUptimeMs: number | undefined; - if (warmAnchor) { - gestureClockOriginAtMs = warmAnchor.gestureClockOriginAtMs; - gestureClockOriginUptimeMs = warmAnchor.gestureClockOriginUptimeMs; - } else if (recordingBase.showTouches) { - // Fallback for older runner builds (or a failed/unavailable warm anchor): issue a - // standalone uptime command and pair it at the request midpoint. - try { - const uptimeRequestStartedAtMs = Date.now(); - const uptimeResult = await deps.runIosRunnerCommand( - device, - { - command: 'uptime', - appBundleId: normalizeAppBundleId(activeSession), - }, - getIosRunnerOptions(req, logPath, activeSession), - ); - const uptimeRequestFinishedAtMs = Date.now(); - gestureClockOriginAtMs = Math.round( - (uptimeRequestStartedAtMs + uptimeRequestFinishedAtMs) / 2, - ); - gestureClockOriginUptimeMs = - typeof uptimeResult.currentUptimeMs === 'number' ? uptimeResult.currentUptimeMs : undefined; - } catch { - // Best effort only; wall-clock fallback remains available. - } - } - return { - platform: 'ios', - child, - wait, - ...recordingBase, - recorderPid: child.pid, - startedAt: readyAt, - gestureClockOriginAtMs: - gestureClockOriginUptimeMs === undefined ? undefined : gestureClockOriginAtMs, - gestureClockOriginUptimeMs, - }; -} - // --- Start recording orchestrator --- // fallow-ignore-next-line complexity @@ -204,6 +95,11 @@ async function startRecording(params: { const fpsFlag = req.flags?.fps; const qualityFlag = req.flags?.quality; const maxSizeFlag = req.flags?.screenshotMaxSize; + const backend = resolveRecordingBackendForDevice(device); + const platformValidationError = backend.validateStart?.(req) ?? null; + if (platformValidationError) { + return platformValidationError; + } if ( fpsFlag !== undefined && (!Number.isInteger(fpsFlag) || @@ -231,63 +127,27 @@ async function startRecording(params: { return errorResponse('UNSUPPORTED_OPERATION', 'record is not supported on this device'); } - const outPath = req.positionals?.[1] ?? `./recording-${Date.now()}.mp4`; + const outputPath = backend.resolveOutputPath({ req, device }); + if (!outputPath.ok) { + return outputPath.response; + } + const outPath = outputPath.path; const resolvedOut = SessionStore.expandHome(outPath, req.meta?.cwd); const recordingBase = buildRecordingBase(req, resolvedOut); fs.mkdirSync(path.dirname(resolvedOut), { recursive: true }); fs.rmSync(resolvedOut, { force: true }); - let recording: NonNullable | DaemonResponse; - if (device.platform === 'ios' && device.kind === 'device') { - const appBundleId = normalizeAppBundleId(activeSession); - if (!appBundleId) { - return errorResponse( - 'INVALID_ARGS', - 'record on physical iOS devices requires an active app session; run open first', - ); - } - recording = await startIosDeviceRecording({ - req, - activeSession, - sessionStore, - device, - logPath, - deps, - fpsFlag, - recordingBase, - appBundleId, - }); - } else if (device.platform === 'macos') { - const appBundleId = normalizeAppBundleId(activeSession); - if (!appBundleId) { - return errorResponse( - 'INVALID_ARGS', - 'record on macOS requires an active app session; run open first', - ); - } - recording = await startMacOsRecording({ - req, - activeSession, - device, - logPath, - deps, - fpsFlag, - recordingBase, - appBundleId, - }); - } else if (device.platform === 'ios') { - recording = await startIosSimulatorRecording({ - req, - activeSession, - device, - logPath, - deps, - recordingBase, - resolvedOut, - }); - } else { - recording = await startAndroidRecording({ device, recordingBase }); - } + const recording = await backend.start({ + req, + activeSession, + sessionStore, + device, + logPath, + deps, + fpsFlag, + recordingBase, + resolvedOut, + }); if ('ok' in recording) { return recording; @@ -314,128 +174,6 @@ async function startRecording(params: { }; } -// --- Stop recording helpers --- - -async function stopNonRunnerRecording(params: { - deps: RecordTraceDeps; - device: SessionState['device']; - recording: Extract, { platform: 'ios' | 'android' }>; - stopRequestedAt: number; -}): Promise { - const { deps, device, recording, stopRequestedAt } = params; - if (recording.platform === 'android') { - return await stopAndroidRecording({ deps, device, recording, stopRequestedAt }); - } - - await withDiagnosticTimer('record_stop_tail_settle', () => deps.waitForRecordingTail(recording), { - platform: recording.platform, - gestureEventCount: recording.gestureEvents.length, - }); - const stopResult = await withDiagnosticTimer( - 'record_stop_ios_simulator_process', - () => stopIosSimulatorRecordingProcess({ deps, recording }), - { - outPath: recording.outPath, - }, - ); - if (!stopResult) { - return buildIosSimulatorRecordingStopFailure( - `failed to stop recording: simctl recordVideo did not exit after ${IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS}ms and forced cleanup`, - recording, - stopRequestedAt, - ); - } - if (stopResult.exitCode !== 0) { - return buildIosSimulatorRecordingStopFailure( - `failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'simctl recordVideo')}`, - recording, - stopRequestedAt, - ); - } - - await withDiagnosticTimer( - 'record_stop_video_stable', - () => - deps.waitForStableFile(recording.outPath, { - pollMs: IOS_SIMULATOR_VIDEO_READY_POLL_MS, - attempts: IOS_SIMULATOR_VIDEO_READY_ATTEMPTS, - }), - { - outPath: recording.outPath, - }, - ); - const playable = await withDiagnosticTimer( - 'record_stop_video_playable_check', - () => deps.isPlayableVideo(recording.outPath), - { - outPath: recording.outPath, - }, - ); - if (!playable) { - return buildIosSimulatorRecordingStopFailure( - `failed to stop recording: ${recording.outPath} was not finalized into a playable video`, - recording, - stopRequestedAt, - ); - } - - if (recording.maxSize !== undefined) { - try { - await withDiagnosticTimer( - 'record_stop_resize', - () => - deps.resizeRecording({ - videoPath: recording.outPath, - maxSize: recording.maxSize!, - exportQuality: recording.exportQuality, - targetLabel: 'iOS recording', - }), - { - outPath: recording.outPath, - maxSize: recording.maxSize, - }, - ); - } catch (error) { - recording.overlayWarning = `failed to resize recording: ${formatRecordTraceError(error)}`; - } - } - - await withDiagnosticTimer( - 'record_stop_finalize_overlay', - () => - finalizeRecordingOverlay({ - recording, - deps, - targetLabel: 'iOS recording', - }), - { - outPath: recording.outPath, - showTouches: recording.showTouches, - gestureEventCount: recording.gestureEvents.length, - }, - ); - - return null; -} - -function buildIosSimulatorRecordingStopFailure( - message: string, - recording: Extract, { platform: 'ios' }>, - stopRequestedAt: number, -): DaemonResponse { - const failure = buildRecordStopFailure(message, recording, stopRequestedAt); - removeInvalidRecordingOutput(recording.outPath); - return errorResponse('COMMAND_FAILED', failure.message); -} - -function removeInvalidRecordingOutput(outPath: string): void { - try { - fs.rmSync(outPath, { force: true }); - } catch { - // Best effort: the error response still reports the failed finalization. - } -} - async function stopRecording(params: { req: DaemonRequest; activeSession: SessionState; @@ -453,13 +191,17 @@ async function stopRecording(params: { const stopRequestedAt = Date.now(); const invalidatedReason = recording.invalidatedReason; activeSession.recording = undefined; - - const stopError = - recording.platform === 'ios-device-runner' - ? await stopIosDeviceRecording({ req, activeSession, device, logPath, deps, recording }) - : recording.platform === 'macos-runner' - ? await stopMacOsRecording({ req, activeSession, device, logPath, deps, recording }) - : await stopNonRunnerRecording({ deps, device, recording, stopRequestedAt }); + const backend = resolveRecordingBackendForRecording(recording); + + const stopError = await backend.stop({ + req, + activeSession, + device, + logPath, + deps, + recording, + stopRequestedAt, + }); if (stopError) { return stopError; } @@ -541,15 +283,17 @@ function deriveClientTelemetryPath( return deriveRecordingTelemetryPath(recording.clientOutPath); } -function releaseRecordOnlySession( +async function releaseRecordOnlySession( sessionStore: SessionStore, sessionName: string, session: SessionState, options: { writeLog?: boolean } = {}, -): void { +): Promise { if (!session.recordOnlySession) { return; } + const backend = resolveRecordingBackendForDevice(session.device); + await backend.cleanupRecordOnlySession?.(session); if (options.writeLog) { sessionStore.writeSessionLog(session); } @@ -594,7 +338,7 @@ export async function handleRecordCommand(params: { const response = await stopRecording({ req, activeSession, device, logPath, deps }); if (!response.ok) { - releaseRecordOnlySession(sessionStore, sessionName, activeSession); + await releaseRecordOnlySession(sessionStore, sessionName, activeSession); return response; } @@ -608,6 +352,6 @@ export async function handleRecordCommand(params: { showTouches: response.data?.showTouches, }, }); - releaseRecordOnlySession(sessionStore, sessionName, activeSession, { writeLog: true }); + await releaseRecordOnlySession(sessionStore, sessionName, activeSession, { writeLog: true }); return response; } From 897a0db97f1c144722eb2d008a75265d50c13c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 16:44:53 +0200 Subject: [PATCH 2/2] refactor: simplify recording backend seam --- .../record-trace-recording-backends.ts | 15 +++----------- src/daemon/handlers/record-trace-recording.ts | 20 +++++-------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/src/daemon/handlers/record-trace-recording-backends.ts b/src/daemon/handlers/record-trace-recording-backends.ts index 909241b31..7835b211e 100644 --- a/src/daemon/handlers/record-trace-recording-backends.ts +++ b/src/daemon/handlers/record-trace-recording-backends.ts @@ -17,13 +17,8 @@ import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; type ActiveRecording = NonNullable; -type RecordingOutputPathResult = - | { ok: true; path: string } - | { ok: false; response: DaemonResponse }; - type RecordingOutputPathContext = { req: DaemonRequest; - device: SessionState['device']; }; type RecordingStartContext = { @@ -49,11 +44,9 @@ type RecordingStopContext = { }; export type RecordingBackend = { - validateStart?: (req: DaemonRequest) => DaemonResponse | null; - resolveOutputPath: (context: RecordingOutputPathContext) => RecordingOutputPathResult; + resolveOutputPath: (context: RecordingOutputPathContext) => string; start: (context: RecordingStartContext) => Promise; stop: (context: RecordingStopContext) => Promise; - cleanupRecordOnlySession?: (session: SessionState) => Promise; }; export function resolveRecordingBackendForDevice(device: SessionState['device']): RecordingBackend { @@ -80,11 +73,9 @@ export function resolveRecordingBackendForRecording(recording: ActiveRecording): return exhaustive; } -function resolveNativeRecordingOutputPath({ - req, -}: RecordingOutputPathContext): RecordingOutputPathResult { +function resolveNativeRecordingOutputPath({ req }: RecordingOutputPathContext): string { const requestedPath = req.positionals?.[1]; - return { ok: true, path: requestedPath ?? `./recording-${Date.now()}.mp4` }; + return requestedPath ?? `./recording-${Date.now()}.mp4`; } const iosDeviceRecordingBackend: RecordingBackend = { diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index 526074ef9..deee1af48 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -96,10 +96,6 @@ async function startRecording(params: { const qualityFlag = req.flags?.quality; const maxSizeFlag = req.flags?.screenshotMaxSize; const backend = resolveRecordingBackendForDevice(device); - const platformValidationError = backend.validateStart?.(req) ?? null; - if (platformValidationError) { - return platformValidationError; - } if ( fpsFlag !== undefined && (!Number.isInteger(fpsFlag) || @@ -127,11 +123,7 @@ async function startRecording(params: { return errorResponse('UNSUPPORTED_OPERATION', 'record is not supported on this device'); } - const outputPath = backend.resolveOutputPath({ req, device }); - if (!outputPath.ok) { - return outputPath.response; - } - const outPath = outputPath.path; + const outPath = backend.resolveOutputPath({ req }); const resolvedOut = SessionStore.expandHome(outPath, req.meta?.cwd); const recordingBase = buildRecordingBase(req, resolvedOut); fs.mkdirSync(path.dirname(resolvedOut), { recursive: true }); @@ -283,17 +275,15 @@ function deriveClientTelemetryPath( return deriveRecordingTelemetryPath(recording.clientOutPath); } -async function releaseRecordOnlySession( +function releaseRecordOnlySession( sessionStore: SessionStore, sessionName: string, session: SessionState, options: { writeLog?: boolean } = {}, -): Promise { +): void { if (!session.recordOnlySession) { return; } - const backend = resolveRecordingBackendForDevice(session.device); - await backend.cleanupRecordOnlySession?.(session); if (options.writeLog) { sessionStore.writeSessionLog(session); } @@ -338,7 +328,7 @@ export async function handleRecordCommand(params: { const response = await stopRecording({ req, activeSession, device, logPath, deps }); if (!response.ok) { - await releaseRecordOnlySession(sessionStore, sessionName, activeSession); + releaseRecordOnlySession(sessionStore, sessionName, activeSession); return response; } @@ -352,6 +342,6 @@ export async function handleRecordCommand(params: { showTouches: response.data?.showTouches, }, }); - await releaseRecordOnlySession(sessionStore, sessionName, activeSession, { writeLog: true }); + releaseRecordOnlySession(sessionStore, sessionName, activeSession, { writeLog: true }); return response; }