From 09d2026735c9ea1b12dcfe980a419a6d9c4631c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 21 May 2026 13:53:28 +0200 Subject: [PATCH] fix: harden iOS simulator recording cleanup --- .../__tests__/recording-provider.test.ts | 3 +- .../request-recording-health.test.ts | 26 ++- .../request-router-recording-health.test.ts | 71 ++++++ .../handlers/__tests__/record-trace.test.ts | 116 +++++++++- .../handlers/record-trace-ios-simulator.ts | 204 ++++++++++++++++++ src/daemon/handlers/record-trace-recording.ts | 111 +--------- src/daemon/recording-provider.ts | 2 +- src/daemon/request-recording-health.ts | 1 + src/daemon/types.ts | 3 +- 9 files changed, 427 insertions(+), 110 deletions(-) create mode 100644 src/daemon/handlers/record-trace-ios-simulator.ts diff --git a/src/daemon/__tests__/recording-provider.test.ts b/src/daemon/__tests__/recording-provider.test.ts index 7a63b47a4..cb2bc4d70 100644 --- a/src/daemon/__tests__/recording-provider.test.ts +++ b/src/daemon/__tests__/recording-provider.test.ts @@ -4,7 +4,7 @@ import { IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts'; const { runCmdBackgroundMock } = vi.hoisted(() => ({ runCmdBackgroundMock: vi.fn(() => ({ - child: { kill: () => true }, + child: { kill: () => true, pid: 1234 }, wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), })), })); @@ -31,6 +31,7 @@ test('local recording provider starts iOS simulator recordVideo through simctl', }); assert.equal(result.child.kill('SIGINT'), true); + assert.equal(result.child.pid, 1234); assert.deepEqual(mockRunCmdBackground.mock.calls, [ [ 'xcrun', diff --git a/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index 66de91568..45112a03c 100644 --- a/src/daemon/__tests__/request-recording-health.test.ts +++ b/src/daemon/__tests__/request-recording-health.test.ts @@ -53,7 +53,7 @@ test('raw iOS simulator recordings do not depend on runner health', () => { expect(session.recording?.invalidatedReason).toBeUndefined(); }); -test('touch-overlay iOS simulator recordings are invalidated by runner restarts', () => { +test('touch-overlay iOS simulator recordings tolerate runner restarts', () => { const session = makeIosSimulatorSession(true); mockGetRunnerSessionSnapshot.mockReturnValue({ alive: true, @@ -62,6 +62,30 @@ test('touch-overlay iOS simulator recordings are invalidated by runner restarts' refreshRecordingHealth(session); + expect(mockGetRunnerSessionSnapshot).not.toHaveBeenCalled(); + expect(session.recording?.runnerSessionId).toBe('runner-before'); + expect(session.recording?.invalidatedReason).toBeUndefined(); +}); + +test('runner-backed iOS recordings still invalidate on runner restarts', () => { + const session = makeIosSimulatorSession(true); + session.device.kind = 'device'; + session.recording = { + platform: 'ios-device-runner', + outPath: '/tmp/demo.mp4', + remotePath: '/tmp/demo.mp4', + startedAt: Date.now() - 1_000, + showTouches: true, + gestureEvents: [], + runnerSessionId: 'runner-before', + }; + mockGetRunnerSessionSnapshot.mockReturnValue({ + alive: true, + sessionId: 'runner-after', + }); + + refreshRecordingHealth(session); + expect(mockGetRunnerSessionSnapshot).toHaveBeenCalledWith('sim-1'); expect(session.recording?.invalidatedReason).toBe( 'iOS runner session restarted during recording', diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index c6c601dd7..53caaf8fa 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -7,17 +7,24 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; }); +vi.mock('../../platforms/ios/runner-client.ts', () => ({ + getRunnerSessionSnapshot: vi.fn(), +})); + import { dispatchCommand } from '../../core/dispatch.ts'; +import { getRunnerSessionSnapshot } from '../../platforms/ios/runner-client.ts'; import { createRequestHandler } from '../request-router.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; const mockDispatch = vi.mocked(dispatchCommand); +const mockGetRunnerSessionSnapshot = vi.mocked(getRunnerSessionSnapshot); beforeEach(() => { mockDispatch.mockReset(); mockDispatch.mockResolvedValue({}); + mockGetRunnerSessionSnapshot.mockReset(); }); test('router blocks non-record commands when recording was invalidated', async () => { @@ -72,3 +79,67 @@ test('router blocks non-record commands when recording was invalidated', async ( expect(response.error.message).toBe('iOS runner session restarted during recording'); expect(mockDispatch).not.toHaveBeenCalled(); }); + +test('router allows iOS simulator gestures during overlay recording after runner restart', async () => { + const sessionStore = makeSessionStore('agent-device-router-recording-health-'); + const session: SessionState = { + name: 'default', + createdAt: Date.now(), + actions: [], + appBundleId: 'com.apple.Preferences', + device: { + platform: 'ios', + target: 'mobile', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }, + recording: { + platform: 'ios', + outPath: '/tmp/demo.mp4', + startedAt: Date.now() - 1_000, + showTouches: true, + gestureEvents: [], + runnerSessionId: 'runner-before', + child: { kill: () => {} } as any, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }; + sessionStore.set('default', session); + mockGetRunnerSessionSnapshot.mockReturnValue({ + alive: true, + sessionId: 'runner-after', + }); + mockDispatch.mockResolvedValue({ + action: 'pinch', + scale: 1.2, + x: 100, + y: 200, + durationMs: 280, + }); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'pinch', + positionals: ['1.2', '100', '200'], + meta: { requestId: 'req-simulator-runner-restart' }, + }); + + expect(response.ok).toBe(true); + expect(mockGetRunnerSessionSnapshot).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalled(); + const recording = sessionStore.get('default')?.recording; + expect(recording?.invalidatedReason).toBeUndefined(); + expect(recording?.gestureEvents).toHaveLength(1); + expect(recording?.gestureEvents[0]?.kind).toBe('pinch'); +}); diff --git a/src/daemon/handlers/__tests__/record-trace.test.ts b/src/daemon/handlers/__tests__/record-trace.test.ts index 55ba71dba..5a6c79cb9 100644 --- a/src/daemon/handlers/__tests__/record-trace.test.ts +++ b/src/daemon/handlers/__tests__/record-trace.test.ts @@ -596,7 +596,109 @@ test('record stop leaves a short visual tail after iOS simulator gestures', asyn expect(kill).toHaveBeenCalledWith('SIGINT'); }); -test('record stop escalates stale iOS simulator recordVideo processes', async () => { +test('record start stores iOS simulator recorder pid for scoped cleanup', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-sim-recorder-pid'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'ios', + id: 'sim-1', + name: 'Simulator', + kind: 'simulator', + booted: true, + }), + ); + mockRunCmdBackground.mockImplementation(() => ({ + child: { kill: () => {}, pid: 5151 } as any, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + })); + + const response = await runRecordCommand({ + sessionStore, + sessionName, + positionals: ['start', './sim-recorder-pid.mp4'], + flags: { hideTouches: true }, + }); + + expect(response?.ok).toBe(true); + const recording = sessionStore.get(sessionName)?.recording; + expect(recording?.platform).toBe('ios'); + if (recording?.platform === 'ios') { + expect(recording.recorderPid).toBe(5151); + } +}); + +test('record stop prefers session-owned iOS recorder processes before path fallback', async () => { + vi.useFakeTimers(); + const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true); + const sessionStore = makeSessionStore(); + const sessionName = 'ios-sim-owned-recorder'; + const kill = vi.fn(); + const session = makeSession(sessionName, { + platform: 'ios', + id: 'sim-1', + name: 'Simulator', + kind: 'simulator', + booted: true, + }); + session.recording = { + platform: 'ios', + outPath: '/tmp/owned-recorder.mp4', + startedAt: Date.now(), + showTouches: true, + gestureEvents: [], + recorderPid: 1111, + child: { kill, pid: 1111 }, + wait: new Promise(() => {}), + }; + sessionStore.set(sessionName, session); + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'pgrep' && args[0] === '-P') { + expect(args).toEqual(['-P', '1111']); + return { stdout: '2222\n', stderr: '', exitCode: 0 }; + } + if (cmd === 'pgrep' && args[0] === '-f') { + throw new Error('path fallback should not run when owned recorder cleanup matches'); + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + try { + const responsePromise = runRecordCommand({ + sessionStore, + sessionName, + positionals: ['stop'], + }); + + await vi.advanceTimersByTimeAsync(12_000); + const response = await responsePromise; + + expect(response?.ok).toBe(false); + expect((response as any).error?.message).toMatch(/did not exit/); + expect(kill.mock.calls.map((call) => call[0])).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']); + expect(mockRunCmd.mock.calls.map((call) => call[1])).toEqual([ + ['-P', '1111'], + ['-P', '1111'], + ['-P', '1111'], + ]); + expect(processKill.mock.calls.map((call) => call[0])).toEqual([ + 1111, 2222, 1111, 2222, 1111, 2222, + ]); + expect(processKill.mock.calls.map((call) => call[1])).toEqual([ + 'SIGINT', + 'SIGINT', + 'SIGTERM', + 'SIGTERM', + 'SIGKILL', + 'SIGKILL', + ]); + } finally { + processKill.mockRestore(); + } +}); + +test('record stop falls back to path matching for stale iOS simulator recordVideo processes', async () => { vi.useFakeTimers(); const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true); const sessionStore = makeSessionStore(); @@ -1229,7 +1331,7 @@ test('record stop force-kills Android screenrecord when SIGINT fails but process ).toBe(true); }); -test('record stop reports invalidated recording after cleanup', async () => { +test('record stop keeps iOS simulator video when touch overlay recording was invalidated', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-invalidated-recording'; const session = makeSession(sessionName, { @@ -1257,10 +1359,12 @@ test('record stop reports invalidated recording after cleanup', async () => { positionals: ['stop'], }); - expect(response?.ok).toBe(false); - if (response?.ok === false) { - expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toBe('iOS runner session exited during recording'); + expect(response?.ok).toBe(true); + if (response?.ok === true) { + expect(response.data?.outPath).toBe(path.resolve('./invalidated.mp4')); + expect(response.data?.overlayWarning).toBe( + 'overlay unavailable: iOS runner session exited during recording', + ); } expect(sessionStore.get(sessionName)?.recording).toBeUndefined(); }); diff --git a/src/daemon/handlers/record-trace-ios-simulator.ts b/src/daemon/handlers/record-trace-ios-simulator.ts new file mode 100644 index 000000000..6ffa4dfb0 --- /dev/null +++ b/src/daemon/handlers/record-trace-ios-simulator.ts @@ -0,0 +1,204 @@ +import { sleep } from '../../utils/timeouts.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import type { ExecResult } from '../../utils/exec.ts'; +import { formatRecordTraceError } from '../record-trace-errors.ts'; +import type { SessionState } from '../types.ts'; +import type { RecordTraceDeps } from './record-trace-types.ts'; + +export const IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS = 5_000; + +const IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS = 2_000; + +type IosSimulatorRecording = Extract, { platform: 'ios' }>; + +export async function stopIosSimulatorRecordingProcess(params: { + deps: RecordTraceDeps; + recording: IosSimulatorRecording; +}): Promise { + const { deps, recording } = params; + // First signal the direct ChildProcess handle. If it does not exit, retry through + // session-owned PID metadata so cleanup still works when the process tree outlives the handle. + recording.child.kill('SIGINT'); + let result = await waitForRecordingProcessExit( + recording.wait, + IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS, + ); + if (result) return result; + + await signalIosSimulatorRecorderCleanup(deps, recording, 'SIGINT'); + result = await waitForRecordingProcessExit( + recording.wait, + IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS, + ); + if (result) return result; + + recording.child.kill('SIGTERM'); + await signalIosSimulatorRecorderCleanup(deps, recording, 'SIGTERM'); + result = await waitForRecordingProcessExit( + recording.wait, + IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS, + ); + if (result) return result; + + recording.child.kill('SIGKILL'); + await signalIosSimulatorRecorderCleanup(deps, recording, 'SIGKILL'); + return await waitForRecordingProcessExit( + recording.wait, + IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS, + ); +} + +async function waitForRecordingProcessExit( + wait: Promise, + timeoutMs: number, +): Promise { + return await Promise.race([wait, sleep(timeoutMs).then(() => null)]); +} + +async function signalIosSimulatorRecorderCleanup( + deps: RecordTraceDeps, + recording: IosSimulatorRecording, + signal: NodeJS.Signals, +): Promise { + if (await signalSessionOwnedIosSimulatorRecorders(deps, recording, signal)) { + return; + } + await signalMatchingIosSimulatorRecorders(deps, recording.outPath, signal); +} + +async function signalMatchingIosSimulatorRecorders( + deps: RecordTraceDeps, + outPath: string, + signal: NodeJS.Signals, +): Promise { + const pattern = `simctl.*recordVideo.*${escapeProcessRegex(outPath)}`; + let result: ExecResult; + try { + result = await deps.runCmd('pgrep', ['-f', pattern], { allowFailure: true }); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'record_stop_ios_simulator_pgrep_failed', + data: { + outPath, + signal, + error: formatRecordTraceError(error), + }, + }); + return; + } + + const pids = uniquePositivePids(parseProcessIds(result.stdout)); + const signaled = signalProcessIds(pids, signal); + + emitDiagnostic({ + level: signaled > 0 ? 'warn' : 'debug', + phase: 'record_stop_ios_simulator_signal_recorders', + data: { + outPath, + signal, + matchedPidCount: pids.length, + signaled, + pgrepExitCode: result.exitCode, + }, + }); +} + +async function signalSessionOwnedIosSimulatorRecorders( + deps: RecordTraceDeps, + recording: IosSimulatorRecording, + signal: NodeJS.Signals, +): Promise { + const recorderPid = recording.recorderPid ?? recording.child.pid; + if (typeof recorderPid !== 'number' || !Number.isInteger(recorderPid) || recorderPid <= 0) { + emitDiagnostic({ + level: 'debug', + phase: 'record_stop_ios_simulator_owned_recorder_unavailable', + data: { + outPath: recording.outPath, + signal, + reason: 'missing_recorder_pid', + }, + }); + return false; + } + + const childResult = await findChildProcessIds(deps, recorderPid, recording.outPath, signal); + const pids = uniquePositivePids([recorderPid, ...childResult.pids]); + const signaled = signalProcessIds(pids, signal); + + emitDiagnostic({ + level: signaled > 0 ? 'warn' : 'debug', + phase: 'record_stop_ios_simulator_signal_owned_recorder', + data: { + outPath: recording.outPath, + signal, + recorderPid, + childPidCount: childResult.pids.length, + matchedPidCount: pids.length, + signaled, + pgrepExitCode: childResult.exitCode, + }, + }); + + return signaled > 0; +} + +async function findChildProcessIds( + deps: RecordTraceDeps, + parentPid: number, + outPath: string, + signal: NodeJS.Signals, +): Promise<{ pids: number[]; exitCode?: number }> { + let result: ExecResult; + try { + result = await deps.runCmd('pgrep', ['-P', String(parentPid)], { allowFailure: true }); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'record_stop_ios_simulator_owned_pgrep_failed', + data: { + outPath, + signal, + parentPid, + error: formatRecordTraceError(error), + }, + }); + return { pids: [] }; + } + + return { + pids: parseProcessIds(result.stdout), + exitCode: result.exitCode, + }; +} + +function uniquePositivePids(values: number[]): number[] { + return Array.from(new Set(values)).filter( + (pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid, + ); +} + +function signalProcessIds(pids: number[], signal: NodeJS.Signals): number { + let signaled = 0; + for (const pid of pids) { + try { + process.kill(pid, signal); + signaled += 1; + } catch { + // Process already exited or cannot be signaled; cleanup remains best-effort. + } + } + return signaled; +} + +function parseProcessIds(stdout: string): number[] { + return stdout + .split(/\s+/) + .map((value) => Number(value)) + .filter((pid) => Number.isInteger(pid) && pid > 0); +} + +function escapeProcessRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index fae22a0ed..19b19b98a 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -6,7 +6,7 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonArtifact, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { runCmd, type ExecResult } from '../../utils/exec.ts'; +import { runCmd } from '../../utils/exec.ts'; import { isPlayableVideo, waitForStableFile } from '../../utils/video.ts'; import { deriveRecordingTelemetryPath } from '../recording-telemetry.ts'; import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; @@ -21,7 +21,7 @@ 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 { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; +import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { getIosRunnerOptions, normalizeAppBundleId, @@ -31,6 +31,10 @@ import { stopIosDeviceRecording, warmIosSimulatorRunner, } from './record-trace-ios.ts'; +import { + IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS, + stopIosSimulatorRecordingProcess, +} from './record-trace-ios-simulator.ts'; const IOS_DEVICE_RECORD_MIN_FPS = 1; const IOS_DEVICE_RECORD_MAX_FPS = 120; @@ -39,8 +43,6 @@ const RECORDING_MAX_QUALITY = 10; 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_RECORDING_STOP_TIMEOUT_MS = 5_000; -const IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS = 2_000; const IOS_SIMULATOR_VIDEO_READY_POLL_MS = 150; const IOS_SIMULATOR_VIDEO_READY_ATTEMPTS = 12; @@ -159,6 +161,7 @@ async function startIosSimulatorRecording(params: { child, wait, ...recordingBase, + recorderPid: child.pid, startedAt: readyAt, gestureClockOriginAtMs: gestureClockOriginUptimeMs === undefined ? undefined : gestureClockOriginAtMs, @@ -168,6 +171,7 @@ async function startIosSimulatorRecording(params: { // --- Start recording orchestrator --- +// fallow-ignore-next-line complexity async function startRecording(params: { req: DaemonRequest; sessionName: string; @@ -393,101 +397,6 @@ async function stopNonRunnerRecording(params: { return null; } -async function stopIosSimulatorRecordingProcess(params: { - deps: RecordTraceDeps; - recording: Extract, { platform: 'ios' }>; -}): Promise { - const { deps, recording } = params; - recording.child.kill('SIGINT'); - let result = await waitForRecordingProcessExit( - recording.wait, - IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS, - ); - if (result) return result; - - await signalMatchingIosSimulatorRecorders(deps, recording.outPath, 'SIGINT'); - result = await waitForRecordingProcessExit( - recording.wait, - IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS, - ); - if (result) return result; - - recording.child.kill('SIGTERM'); - await signalMatchingIosSimulatorRecorders(deps, recording.outPath, 'SIGTERM'); - result = await waitForRecordingProcessExit( - recording.wait, - IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS, - ); - if (result) return result; - - recording.child.kill('SIGKILL'); - await signalMatchingIosSimulatorRecorders(deps, recording.outPath, 'SIGKILL'); - return await waitForRecordingProcessExit( - recording.wait, - IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS, - ); -} - -async function waitForRecordingProcessExit( - wait: Promise, - timeoutMs: number, -): Promise { - return await Promise.race([wait, sleep(timeoutMs).then(() => null)]); -} - -async function signalMatchingIosSimulatorRecorders( - deps: RecordTraceDeps, - outPath: string, - signal: NodeJS.Signals, -): Promise { - const pattern = `simctl.*recordVideo.*${escapeProcessRegex(outPath)}`; - let result: ExecResult; - try { - result = await deps.runCmd('pgrep', ['-f', pattern], { allowFailure: true }); - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'record_stop_ios_simulator_pgrep_failed', - data: { - outPath, - signal, - error: formatRecordTraceError(error), - }, - }); - return; - } - - const pids = result.stdout - .split(/\s+/) - .map((value) => Number(value)) - .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid); - let signaled = 0; - for (const pid of pids) { - try { - process.kill(pid, signal); - signaled += 1; - } catch { - // Process already exited or cannot be signaled; continue best-effort cleanup. - } - } - - emitDiagnostic({ - level: signaled > 0 ? 'warn' : 'debug', - phase: 'record_stop_ios_simulator_signal_recorders', - data: { - outPath, - signal, - matchedPidCount: pids.length, - signaled, - pgrepExitCode: result.exitCode, - }, - }); -} - -function escapeProcessRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - async function stopRecording(params: { req: DaemonRequest; activeSession: SessionState; @@ -515,7 +424,9 @@ async function stopRecording(params: { return stopError; } - if (invalidatedReason) { + if (invalidatedReason && recording.platform === 'ios' && recording.showTouches) { + recording.overlayWarning ??= `overlay unavailable: ${invalidatedReason}`; + } else if (invalidatedReason) { return errorResponse('COMMAND_FAILED', invalidatedReason); } diff --git a/src/daemon/recording-provider.ts b/src/daemon/recording-provider.ts index 58c4b98c1..2d420c5e9 100644 --- a/src/daemon/recording-provider.ts +++ b/src/daemon/recording-provider.ts @@ -4,7 +4,7 @@ import { runCmdBackground, type ExecBackgroundResult, type ExecResult } from '.. import { createScopedProvider } from '../utils/scoped-provider.ts'; export type RecordingProcess = { - child: Pick; + child: Pick; wait: Promise; }; diff --git a/src/daemon/request-recording-health.ts b/src/daemon/request-recording-health.ts index d27918fc3..0dfa3ca51 100644 --- a/src/daemon/request-recording-health.ts +++ b/src/daemon/request-recording-health.ts @@ -32,5 +32,6 @@ export function shouldBlockForInvalidRecording(command: string): boolean { function recordingRequiresRunnerHealth(session: SessionState): boolean { const recording = session.recording; if (!recording || session.device.platform !== 'ios') return false; + if (recording.platform === 'ios') return false; return recording.showTouches !== false; } diff --git a/src/daemon/types.ts b/src/daemon/types.ts index ad1b76f95..d5f3720bd 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -161,7 +161,7 @@ type SessionRecordingBase = { invalidatedReason?: string; }; -type SessionRecordingProcessChild = Pick; +type SessionRecordingProcessChild = Pick; export type SessionState = { name: string; @@ -187,6 +187,7 @@ export type SessionState = { platform: 'ios'; child: SessionRecordingProcessChild; wait: Promise; + recorderPid?: number; remotePath?: string; }) | (SessionRecordingBase & {