From 4b77eeefe32de5d6c237c162ac61deda53e8c74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 18 Sep 2026 18:05:03 +0200 Subject: [PATCH] refactor(ios-runner): give runner sessions a lifecycle state The session ready boolean mixed the runner answer with process existence, and three readers combined them three ways. A session now records whether it has answered and whether disposal has begun, while the process probe remains a separate fact. Both feed one liveness answer and one idempotence guard. (#2662) --- .../src/__tests__/session-observation.test.ts | 25 +- .../platform-apple/src/core/runner-client.ts | 4 +- .../src/runner-operations-facade.ts | 2 +- .../runner/__tests__/runner-adoption.test.ts | 4 +- .../runner/__tests__/runner-client.test.ts | 24 +- .../runner-close-finalization.test.ts | 14 +- .../__tests__/runner-command-recovery.test.ts | 2 +- .../__tests__/runner-command-retry.test.ts | 86 +-- .../runner/__tests__/runner-disposal.test.ts | 2 +- .../runner-early-exit-diagnosis.test.ts | 2 +- .../runner-lifecycle-prepare-artifact.test.ts | 8 +- ...nner-lifecycle-readiness-preflight.test.ts | 24 +- .../__tests__/runner-recovery-wiring.test.ts | 4 +- .../runner-request-cancellation.test.ts | 18 +- .../runner/__tests__/runner-response.test.ts | 9 +- .../__tests__/runner-session-close.test.ts | 18 +- .../__tests__/runner-session-fixtures.ts | 2 +- .../runner-session-lifecycle.test.ts | 411 +++++++++++++ .../runner-session-readiness.test.ts | 541 ++++++++++++++++++ .../runner-session-speculative.test.ts | 14 +- .../__tests__/runner-session-types.test.ts | 120 ++++ .../runner/__tests__/runner-session.test.ts | 415 +------------- .../runner-startup-transport.test.ts | 6 +- packages/platform-apple/src/runner/client.ts | 6 +- .../src/runner/runner-adoption.ts | 2 +- .../src/runner/runner-client.ts | 16 +- .../src/runner/runner-disposal.ts | 20 +- .../src/runner/runner-lifecycle.ts | 21 +- .../src/runner/runner-session-types.ts | 82 ++- .../src/runner/runner-session.ts | 79 ++- .../src/runner/runner-startup-transport.ts | 2 +- .../platform-apple/src/session-observation.ts | 10 +- ...n-recording-apple-runner-transport.test.ts | 8 +- ...screen-recording-apple-runner-transport.ts | 12 +- 34 files changed, 1430 insertions(+), 583 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts create mode 100644 packages/platform-apple/src/runner/__tests__/runner-session-readiness.test.ts create mode 100644 packages/platform-apple/src/runner/__tests__/runner-session-types.test.ts diff --git a/packages/platform-apple/src/__tests__/session-observation.test.ts b/packages/platform-apple/src/__tests__/session-observation.test.ts index 2a166a73d4..b694d7c0c1 100644 --- a/packages/platform-apple/src/__tests__/session-observation.test.ts +++ b/packages/platform-apple/src/__tests__/session-observation.test.ts @@ -6,7 +6,7 @@ const { snapshot, runningApp } = vi.hoisted(() => ({ snapshot: vi.fn(), runningApp: vi.fn(), })); -vi.mock('../core/runner-client.ts', () => ({ getRunnerSessionSnapshot: snapshot })); +vi.mock('../core/runner-client.ts', () => ({ readRunnerSessionLiveness: snapshot })); vi.mock('../core/app-resolution.ts', () => ({ detectSoleRunningIosSimulatorApp: runningApp })); const host = { @@ -19,22 +19,19 @@ beforeEach(() => { vi.resetAllMocks(); }); -test.each([true, false])( - 'runner observation preserves liveness %s and session identity', - async (alive) => { - snapshot.mockResolvedValue({ sessionId: 'runner-1', alive, ready: false }); +test('runner observation reports a registered runner, whatever lifecycle state it is in', async () => { + snapshot.mockReturnValue({ sessionId: 'runner-1', liveness: 'starting' }); - await expect(observation.observeRunnerSession('sim-1')).resolves.toEqual({ - alive, - sessionId: 'runner-1', - }); - expect(snapshot).toHaveBeenCalledWith('sim-1'); - expect(host.listLocalDevices).not.toHaveBeenCalled(); - }, -); + await expect(observation.observeRunnerSession('sim-1')).resolves.toEqual({ + alive: true, + sessionId: 'runner-1', + }); + expect(snapshot).toHaveBeenCalledWith('sim-1'); + expect(host.listLocalDevices).not.toHaveBeenCalled(); +}); test('a missing runner session has no observation', async () => { - snapshot.mockResolvedValue(null); + snapshot.mockReturnValue(null); await expect(observation.observeRunnerSession('sim-1')).resolves.toBeUndefined(); }); diff --git a/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index 23f13e01cc..390930d30a 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -35,8 +35,8 @@ export const hasCachedAppleRunnerArtifact: AppleRunnerClient['hasCachedAppleRunn client.hasCachedAppleRunnerArtifact; export const detachIosSimulatorRunnerSessionsForShutdown: AppleRunnerClient['detachIosSimulatorRunnerSessionsForShutdown'] = client.detachIosSimulatorRunnerSessionsForShutdown; -export const getRunnerSessionSnapshot: AppleRunnerClient['getRunnerSessionSnapshot'] = - client.getRunnerSessionSnapshot; +export const readRunnerSessionLiveness: AppleRunnerClient['readRunnerSessionLiveness'] = + client.readRunnerSessionLiveness; export const releaseIosRunnerOnClose: AppleRunnerClient['releaseIosRunnerOnClose'] = client.releaseIosRunnerOnClose; export const stopIosRunnerSession: AppleRunnerClient['stopIosRunnerSession'] = diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index f30e25d6a2..5eeaaf8387 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -1,12 +1,12 @@ export { applyXctestRunnerAppIconFromDerivedPath, detachIosSimulatorRunnerSessionsForShutdown, - getRunnerSessionSnapshot, hasLiveIosRunnerSession, notifyIosRunnerAppRelaunched, prepareIosRunner, prewarmAppleRunnerCache, prewarmIosRunnerSession, + readRunnerSessionLiveness, readStaleRunnerLease, releaseIosRunnerOnClose, releaseSpeculativeIosRunnerSessionFor, diff --git a/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts b/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts index 9e34383806..8364bf5533 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts @@ -140,7 +140,7 @@ test('adoption succeeds for a live, matching, probe-healthy runner', async () => expect(session).not.toBeNull(); expect(session?.port).toBe(lease.port); - expect(session?.ready).toBe(true); + expect(session?.state).toBe('ready'); expect(session?.child.pid).toBe(424242); expect(session?.sessionId).toBe(lease.sessionId); expect(session?.xctestrunArtifact?.reason).toBe('adopted_from_lease'); @@ -252,7 +252,7 @@ test('adoption accepts a legacy lease whose live pid is runner-shaped', async () const session = await tryAdoptRunnerSessionFromLease(simulator, {}); - expect(session?.ready).toBe(true); + expect(session?.state).toBe('ready'); expect(session?.child.pid).toBe(424242); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts index b758c967f3..82f0558505 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts @@ -497,7 +497,7 @@ test('parseRunnerResponse preserves runner unsupported-operation codes', async ( }, }), ); - const session = { ready: false }; + const session = { state: 'starting' } as const; await assert.rejects( () => parseRunnerResponse(response, session, '/tmp/runner.log'), @@ -523,7 +523,7 @@ test('parseRunnerResponse surfaces the keyboard-dismiss hint naming the occlusio }, }), ); - const session = { ready: false }; + const session = { state: 'starting' } as const; await assert.rejects( () => parseRunnerResponse(response, session, '/tmp/runner.log'), @@ -552,7 +552,7 @@ test('parseRunnerResponse preserves iOS AX snapshot failure code and hint', asyn }, }), ); - const session = { ready: true }; + const session = { state: 'ready' } as const; await assert.rejects( () => parseRunnerResponse(response, session, '/tmp/runner.log'), @@ -581,7 +581,7 @@ test('parseRunnerResponse preserves XCTest recorded failure code and hint', asyn }, }), ); - const session = { ready: true }; + const session = { state: 'ready' } as const; await assert.rejects( () => parseRunnerResponse(response, session, '/tmp/runner.log'), @@ -608,7 +608,7 @@ test('parseRunnerResponse maps RUNNER_BUSY to retriable command failure', async }, }), ); - const session = { ready: true }; + const session = { state: 'ready' } as const; await assert.rejects( () => parseRunnerResponse(response, session, '/tmp/runner.log'), @@ -635,7 +635,7 @@ test('parseRunnerResponse preserves RUNNER_WEDGED as a fatal runner code', async }, }), ); - const session = { ready: true }; + const session = { state: 'ready' } as const; await assert.rejects( () => parseRunnerResponse(response, session, '/tmp/runner.log'), @@ -667,7 +667,7 @@ Thread 0 Crashed:: Dispatch queue: com.apple.main-thread }, }), ); - const session = { ready: true }; + const session = { state: 'ready' } as const; await assert.rejects( () => parseRunnerResponse(response, session, logPath), @@ -697,7 +697,7 @@ The application under test terminated unexpectedly. }, }), ); - const session = { ready: true }; + const session = { state: 'ready' } as const; await assert.rejects( () => parseRunnerResponse(response, session, logPath), @@ -726,7 +726,7 @@ AGENT_DEVICE_RUNNER_COMMAND_FAILED command=snapshot error=fetch failed }, }), ); - const session = { ready: true }; + const session = { state: 'ready' } as const; await assert.rejects( () => parseRunnerResponse(response, session, logPath), @@ -754,7 +754,7 @@ test('parseRunnerResponse hints when XCTest main-thread execution times out', as }, }), ); - const session = { ready: true }; + const session = { state: 'ready' } as const; await assert.rejects( () => parseRunnerResponse(response, session, logPath), @@ -782,14 +782,14 @@ test('parseRunnerResponse emits diagnostics for runner gesture fallbacks', async }, }), ); - const session = { ready: false }; + const session = { state: 'starting' } as const; const diagnosticEvents: DiagnosticEventInput[] = []; appleRunnerTestHost.update({ emitDiagnostic: (event) => diagnosticEvents.push(event) }); const data = await parseRunnerResponse(response, session, '/tmp/runner.log'); assert.equal(data.gestureFallback, 'xctest-coordinate-drag'); - assert.equal(session.ready, true); + assert.equal(session.state, 'ready'); const diagnostics = JSON.stringify(diagnosticEvents); assert.match(diagnostics, /ios_runner_gesture_fallback/); assert.match(diagnostics, /xctest-coordinate-drag/); diff --git a/packages/platform-apple/src/runner/__tests__/runner-close-finalization.test.ts b/packages/platform-apple/src/runner/__tests__/runner-close-finalization.test.ts index 7aeb459865..e4a9f6d2f4 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-close-finalization.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-close-finalization.test.ts @@ -24,7 +24,7 @@ import { abortAllIosRunnerSessions, ensureRunnerSession, executeRunnerCommandWithSession, - getRunnerSessionSnapshot, + readRunnerSessionLiveness, releaseIosRunnerOnClose, stopIosRunnerSession, type RunnerSession, @@ -195,7 +195,7 @@ test('a retained close over an idle runner keeps that runner warm for the next o assert.deepEqual(events, ['release', 'released', 'alerts']); assert.match(diagnostics, /"phase":"ios_runner_idle_stop_scheduled"/); assert.doesNotMatch(diagnostics, /ios_runner_retain_skipped_busy/); - assert.equal(getRunnerSessionSnapshot(device.id)?.sessionId, session.sessionId); + assert.equal(readRunnerSessionLiveness(device.id)?.sessionId, session.sessionId); assert.ok(runnerLeaseExists(device.id)); // The next open gets the same runner and launches nothing. assert.equal((await ensureRunnerSession(device, {})).sessionId, session.sessionId); @@ -216,7 +216,7 @@ test('a retained close disposes a runner still draining so the next open boots a assert.deepEqual(events, ['release', 'released', 'alerts']); assert.match(diagnostics, /"phase":"ios_runner_retain_skipped_busy"/); assert.doesNotMatch(diagnostics, /"phase":"ios_runner_idle_stop_scheduled"/); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); assert.equal(runnerLeaseExists(device.id), false); assert.notEqual((await ensureRunnerSession(device, {})).sessionId, session.sessionId); assert.equal(mockRunCmdBackground.mock.calls.length, 2); @@ -238,7 +238,7 @@ test('a non-retained close stops the runner and cancels the idle stop a retained assert.match(diagnostics, /"phase":"ios_runner_idle_stop_scheduled"/); assert.doesNotMatch(diagnostics, /"phase":"ios_runner_idle_stop"/); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); assert.equal(runnerLeaseExists(device.id), false); }); @@ -267,7 +267,7 @@ test('a retained close with no runner in memory starts none and claims no lease await lifecycle.finalizeApplicationClose(closeInput(true)); assert.deepEqual(events, ['release', 'released', 'alerts']); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); assert.equal(mockRunCmdBackground.mock.calls.length, 0); assert.equal(runnerLeaseExists(device.id), false); }); @@ -281,7 +281,7 @@ test('a daemon-shutdown close never issues the ordinary close release (#2615)', await lifecycle.finalizeApplicationClose({ ...closeInput(true), daemonShutdown: true }); assert.deepEqual(events, ['alerts']); - assert.equal(getRunnerSessionSnapshot(device.id)?.sessionId, session.sessionId); + assert.equal(readRunnerSessionLiveness(device.id)?.sessionId, session.sessionId); assert.ok(runnerLeaseExists(device.id)); }); @@ -331,7 +331,7 @@ test('a drain that lands after close is issued cannot return a busy runner to re assert.equal(session.runnerMainThreadBusy, false); assert.match(diagnostics, /"phase":"ios_runner_retain_skipped_busy"/); assert.doesNotMatch(diagnostics, /"phase":"ios_runner_idle_stop_scheduled"/); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); }); /** The close path's Apple tools, composed exactly as the root's lazy tools compose them. */ diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts index fbbf235cb0..4f204366e0 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts @@ -34,7 +34,7 @@ function makeRunnerSession(port: number): RunnerSession { jsonPath: '/tmp/fake.json', testPromise: new Promise(() => {}), child: { pid: process.pid, exitCode: null }, - ready: true, + state: 'ready', }; } diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index 1713908ba2..56019ce0ea 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -11,14 +11,14 @@ const { mockEnsureRunnerSession, mockExecuteRunnerCommandWithSession, mockEmitDiagnostic, - mockGetRunnerSessionSnapshot, + mockReadRunnerSessionLiveness, mockInvalidateRunnerSession, mockMarkRunnerXctestrunArtifactBadForRun, } = vi.hoisted(() => ({ mockEnsureRunnerSession: vi.fn(), mockExecuteRunnerCommandWithSession: vi.fn(), mockEmitDiagnostic: vi.fn(), - mockGetRunnerSessionSnapshot: vi.fn(), + mockReadRunnerSessionLiveness: vi.fn(), mockInvalidateRunnerSession: vi.fn(), mockMarkRunnerXctestrunArtifactBadForRun: vi.fn(), })); @@ -30,7 +30,7 @@ vi.mock('../runner-session.ts', async () => { ...actual, ensureRunnerSession: mockEnsureRunnerSession, executeRunnerCommandWithSession: mockExecuteRunnerCommandWithSession, - getRunnerSessionSnapshot: mockGetRunnerSessionSnapshot, + readRunnerSessionLiveness: mockReadRunnerSessionLiveness, invalidateRunnerSession: mockInvalidateRunnerSession, }; }); @@ -54,7 +54,7 @@ const { markRequestCanceled, clearRequestCanceled, isRequestCanceled } = request beforeEach(() => { vi.resetAllMocks(); resetRunnerRecycleLedgerForTests(); - mockGetRunnerSessionSnapshot.mockReturnValue(null); + mockReadRunnerSessionLiveness.mockReturnValue(null); mockMarkRunnerXctestrunArtifactBadForRun.mockResolvedValue(undefined); requestCancellation.reset(); appleRunnerTestHost.update({ @@ -298,8 +298,8 @@ test('prepareIosRunner does not relaunch after request cancellation', async () = }); test('mutating commands restart stale ready sessions when the preflight probe never reaches the runner', async () => { - const staleSession = makeRunnerSession({ port: 8100, ready: true }); - const freshSession = makeRunnerSession({ port: 8101, ready: false }); + const staleSession = makeRunnerSession({ port: 8100, state: 'ready' }); + const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession @@ -321,8 +321,8 @@ test('mutating commands restart stale ready sessions when the preflight probe ne }); test('mutating commands retry startup sessions with stale bundle cleanup', async () => { - const startupSession = makeRunnerSession({ port: 8100, ready: false }); - const freshSession = makeRunnerSession({ port: 8101, ready: false }); + const startupSession = makeRunnerSession({ port: 8100, state: 'starting' }); + const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); mockEnsureRunnerSession.mockResolvedValueOnce(startupSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession @@ -343,8 +343,8 @@ test('mutating commands retry startup sessions with stale bundle cleanup', async }); test('mutating commands emit readiness recovery diagnostics after failed preflight restart succeeds', async () => { - const staleSession = makeRunnerSession({ port: 8100, ready: true }); - const freshSession = makeRunnerSession({ port: 8101, ready: false }); + const staleSession = makeRunnerSession({ port: 8100, state: 'ready' }); + const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession @@ -365,7 +365,7 @@ test('mutating commands emit readiness recovery diagnostics after failed preflig }); test('mutating commands do not restart or replay after command send failure', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -400,7 +400,7 @@ test('mutating commands do not restart or replay after command send failure', as }); test('mutating commands recover cached responses before invalidating after command send failure', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -427,7 +427,7 @@ test('mutating commands recover cached responses before invalidating after comma }); test('mutating commands keep invalidating when status cannot find the command', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -461,7 +461,7 @@ test('mutating commands keep invalidating when status cannot find the command', }); test('mutating commands keep invalidating when status recovery probe fails', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -490,7 +490,7 @@ test('mutating commands keep invalidating when status recovery probe fails', asy }); test('mutating commands keep invalidating when status reports an unknown lifecycle state', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -522,7 +522,7 @@ test('mutating commands keep invalidating when status reports an unknown lifecyc }); test('read-only commands retry when completed status has no retained response', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValue(session); mockExecuteRunnerCommandWithSession @@ -547,7 +547,7 @@ test('read-only commands retry when completed status has no retained response', test('read-only startup commands use the session startup timeout override', async () => { const session = makeRunnerSession({ port: 8100, - ready: false, + state: 'starting', startupTimeoutMs: 240_000, }); @@ -565,7 +565,7 @@ test('read-only startup commands use the session startup timeout override', asyn }); test('read-only commands retry when status shows in-flight work', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValue(session); mockExecuteRunnerCommandWithSession @@ -583,7 +583,7 @@ test('read-only commands retry when status shows in-flight work', async () => { }); test('mutating commands report recovery guidance when completed status has no retained response', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -614,7 +614,7 @@ test('mutating commands report recovery guidance when completed status has no re }); test('mutating commands run status recovery after transport failure when readiness preflight was skipped', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -645,7 +645,7 @@ test('mutating commands run status recovery after transport failure when readine }); test('mutating commands include skipped readiness context in lost-response guidance', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -674,7 +674,7 @@ test('mutating commands include skipped readiness context in lost-response guida }); test('mutating commands keep conservative invalidation for skipped-preflight failures with unknown lifecycle', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -711,7 +711,7 @@ test('mutating commands keep conservative invalidation for skipped-preflight fai }); test('mutating commands preserve runner failure details from status recovery', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -746,7 +746,7 @@ test('mutating commands preserve runner failure details from status recovery', a }); test('mutating commands use recovery guidance when failed status has no runner hint', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -776,7 +776,7 @@ test('mutating commands use recovery guidance when failed status has no runner h }); test('mutating commands report wait-and-inspect guidance when status shows in-flight work', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -806,8 +806,8 @@ test('mutating commands report wait-and-inspect guidance when status shows in-fl }); test('mutating commands invalidate the retry session without replaying again', async () => { - const staleSession = makeRunnerSession({ port: 8100, ready: true }); - const freshSession = makeRunnerSession({ port: 8101, ready: false }); + const staleSession = makeRunnerSession({ port: 8100, state: 'ready' }); + const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession @@ -843,7 +843,7 @@ test('mutating commands invalidate the retry session without replaying again', a }); test('sequence recovers retained per-step results without resending', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); const sequenceData = { message: 'sequence', completedSteps: 3, @@ -884,7 +884,7 @@ test('sequence recovers retained per-step results without resending', async () = }); test('sequence surfaces a lifecycle failure without replaying', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -922,7 +922,7 @@ test('sequence surfaces a lifecycle failure without replaying', async () => { }); test('sequence in-flight after lost response reports no-replay guidance', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -953,7 +953,7 @@ test('sequence in-flight after lost response reports no-replay guidance', async }); test('sequence invalidates the session when the status probe fails', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); mockEnsureRunnerSession.mockResolvedValueOnce(session); mockExecuteRunnerCommandWithSession @@ -1100,7 +1100,7 @@ function makeRunnerSession(overrides: Partial = {}): RunnerSessio jsonPath: '/tmp/runner.json', testPromise: Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }), child: { pid: 1234, exitCode: null }, - ready: true, + state: 'ready', ...overrides, } as RunnerSession; } @@ -1129,7 +1129,7 @@ test('a request pays for at most one runner recycle, then fails fast with a pres // request must stop after ONE recycle instead of stacking ~25s xcodebuild boots until the // client envelope kills the daemon. const requestId = 'req-recycle-cap'; - mockEnsureRunnerSession.mockImplementation(async () => makeRunnerSession({ ready: true })); + mockEnsureRunnerSession.mockImplementation(async () => makeRunnerSession({ state: 'ready' })); mockExecuteRunnerCommandWithSession.mockRejectedValue( new AppError('COMMAND_FAILED', 'fetch failed'), ); @@ -1152,9 +1152,9 @@ test('a request pays for at most one runner recycle, then fails fast with a pres test('a failed replacement boot does not consume the request recycle budget', async () => { const requestId = 'req-recycle-transient-boot-failure'; mockEnsureRunnerSession - .mockResolvedValueOnce(makeRunnerSession({ port: 8100, ready: true })) + .mockResolvedValueOnce(makeRunnerSession({ port: 8100, state: 'ready' })) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) - .mockResolvedValueOnce(makeRunnerSession({ port: 8101, ready: true })); + .mockResolvedValueOnce(makeRunnerSession({ port: 8101, state: 'ready' })); mockExecuteRunnerCommandWithSession .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) @@ -1166,16 +1166,16 @@ test('a failed replacement boot does not consume the request recycle budget', as assert.equal(mockEnsureRunnerSession.mock.calls.length, 3); }); -test('alive session reuse in the same request does not consume recycle budget', async () => { +test('a live session reused in the same request does not consume the recycle budget', async () => { const requestId = 'req-alive-reuse-before-recycle'; - mockGetRunnerSessionSnapshot + mockReadRunnerSessionLiveness .mockReturnValueOnce(null) - .mockReturnValueOnce({ alive: true }) + .mockReturnValueOnce({ sessionId: 'runner-1', liveness: 'ready' }) .mockReturnValueOnce(null); mockEnsureRunnerSession - .mockResolvedValueOnce(makeRunnerSession({ port: 8100, ready: true })) - .mockResolvedValueOnce(makeRunnerSession({ port: 8100, ready: true })) - .mockResolvedValueOnce(makeRunnerSession({ port: 8101, ready: true })); + .mockResolvedValueOnce(makeRunnerSession({ port: 8100, state: 'ready' })) + .mockResolvedValueOnce(makeRunnerSession({ port: 8100, state: 'ready' })) + .mockResolvedValueOnce(makeRunnerSession({ port: 8101, state: 'ready' })); mockExecuteRunnerCommandWithSession .mockResolvedValueOnce({ message: 'first tap' }) .mockResolvedValueOnce({ message: 'second tap' }) @@ -1199,8 +1199,8 @@ test('alive session reuse in the same request does not consume recycle budget', test('a later command in the same request cannot pay for a second recycle boot', async () => { const requestId = 'req-restart-cap'; - const staleSession = makeRunnerSession({ port: 8100, ready: true }); - const freshSession = makeRunnerSession({ port: 8101, ready: false }); + const staleSession = makeRunnerSession({ port: 8100, state: 'ready' }); + const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession diff --git a/packages/platform-apple/src/runner/__tests__/runner-disposal.test.ts b/packages/platform-apple/src/runner/__tests__/runner-disposal.test.ts index ceef5b6714..dd03ae9cf9 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-disposal.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-disposal.test.ts @@ -200,7 +200,7 @@ function makeRunnerSession( jsonPath: `/tmp/${device.id}.json`, testPromise, child: { pid: 42, exitCode: null }, - ready: true, + state: 'ready', ...overrides, }; } diff --git a/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts b/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts index 603e688418..4986176cd9 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts @@ -26,7 +26,7 @@ function sessionFailingWith(stdout: string, stderr: string): RunnerSession { jsonPath: '/tmp/runner.json', testPromise: Promise.resolve({ exitCode: 1, stdout, stderr }), child: { pid: 4242, exitCode: 1 } as ExecBackgroundResult['child'], - ready: false, + state: 'starting', }; } diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-prepare-artifact.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-prepare-artifact.test.ts index 3309284b0f..2876f3c3c5 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-prepare-artifact.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-prepare-artifact.test.ts @@ -11,14 +11,14 @@ const { mockEnsureRunnerSession, mockExecuteRunnerCommandWithSession, mockEmitDiagnostic, - mockGetRunnerSessionSnapshot, + mockReadRunnerSessionLiveness, mockInvalidateRunnerSession, mockMarkRunnerXctestrunArtifactBadForRun, } = vi.hoisted(() => ({ mockEnsureRunnerSession: vi.fn(), mockExecuteRunnerCommandWithSession: vi.fn(), mockEmitDiagnostic: vi.fn(), - mockGetRunnerSessionSnapshot: vi.fn(), + mockReadRunnerSessionLiveness: vi.fn(), mockInvalidateRunnerSession: vi.fn(), mockMarkRunnerXctestrunArtifactBadForRun: vi.fn(), })); @@ -30,7 +30,7 @@ vi.mock('../runner-session.ts', async () => { ...actual, ensureRunnerSession: mockEnsureRunnerSession, executeRunnerCommandWithSession: mockExecuteRunnerCommandWithSession, - getRunnerSessionSnapshot: mockGetRunnerSessionSnapshot, + readRunnerSessionLiveness: mockReadRunnerSessionLiveness, invalidateRunnerSession: mockInvalidateRunnerSession, }; }); @@ -67,7 +67,7 @@ const { isRequestCanceled } = requestCancellation; beforeEach(() => { vi.resetAllMocks(); resetRunnerRecycleLedgerForTests(); - mockGetRunnerSessionSnapshot.mockReturnValue(null); + mockReadRunnerSessionLiveness.mockReturnValue(null); mockMarkRunnerXctestrunArtifactBadForRun.mockResolvedValue(undefined); requestCancellation.reset(); appleRunnerTestHost.update({ diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts index c105197a6a..dd28f4152a 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts @@ -14,14 +14,14 @@ const { mockEnsureRunnerSession, mockExecuteRunnerCommandWithSession, mockEmitDiagnostic, - mockGetRunnerSessionSnapshot, + mockReadRunnerSessionLiveness, mockInvalidateRunnerSession, mockMarkRunnerXctestrunArtifactBadForRun, } = vi.hoisted(() => ({ mockEnsureRunnerSession: vi.fn(), mockExecuteRunnerCommandWithSession: vi.fn(), mockEmitDiagnostic: vi.fn(), - mockGetRunnerSessionSnapshot: vi.fn(), + mockReadRunnerSessionLiveness: vi.fn(), mockInvalidateRunnerSession: vi.fn(), mockMarkRunnerXctestrunArtifactBadForRun: vi.fn(), })); @@ -33,7 +33,7 @@ vi.mock('../runner-session.ts', async () => { ...actual, ensureRunnerSession: mockEnsureRunnerSession, executeRunnerCommandWithSession: mockExecuteRunnerCommandWithSession, - getRunnerSessionSnapshot: mockGetRunnerSessionSnapshot, + readRunnerSessionLiveness: mockReadRunnerSessionLiveness, invalidateRunnerSession: mockInvalidateRunnerSession, }; }); @@ -61,7 +61,7 @@ const { isRequestCanceled } = requestCancellation; beforeEach(() => { vi.resetAllMocks(); resetRunnerRecycleLedgerForTests(); - mockGetRunnerSessionSnapshot.mockReturnValue(null); + mockReadRunnerSessionLiveness.mockReturnValue(null); mockMarkRunnerXctestrunArtifactBadForRun.mockResolvedValue(undefined); requestCancellation.reset(); appleRunnerTestHost.update({ @@ -86,8 +86,8 @@ function makeRunnerArtifact( } test('mutating commands restart stale sessions when readiness preflight fails before command send', async () => { - const staleSession = makeRunnerSession({ port: 8100, ready: true }); - const freshSession = makeRunnerSession({ port: 8101, ready: false }); + const staleSession = makeRunnerSession({ port: 8100, state: 'ready' }); + const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession @@ -111,8 +111,8 @@ test('mutating commands restart stale sessions when readiness preflight fails be }); test('a readiness preflight that runs out a post deadline restarts the session and replays', async () => { - const staleSession = makeRunnerSession({ port: 8100, ready: true }); - const freshSession = makeRunnerSession({ port: 8101, ready: false }); + const staleSession = makeRunnerSession({ port: 8100, state: 'ready' }); + const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); // The simulator and usbmux routes post to the runner themselves, and `fetchWithTimeout` reports // an expiry as "Runner command deadline exceeded" with the budget it ran out. Neither of the two @@ -137,8 +137,8 @@ test('a readiness preflight that runs out a post deadline restarts the session a }); test('a readiness preflight refusal restarts the session like any other preflight failure', async () => { - const staleSession = makeRunnerSession({ port: 8100, ready: true }); - const freshSession = makeRunnerSession({ port: 8101, ready: false }); + const staleSession = makeRunnerSession({ port: 8100, state: 'ready' }); + const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); // This is the shape no message check could have been written for: the runner answered the probe // and the answer was no, which says nothing about whether the command was written. The marker @@ -162,7 +162,7 @@ test('a readiness preflight refusal restarts the session like any other prefligh }); test('a failed readiness probe without the marker does not restart the session', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); // Without the marker the failure is just a transport shape, and the one that says the command // was never written is the reason this restart is safe at all. @@ -183,7 +183,7 @@ test('a failed readiness probe without the marker does not restart the session', }); test('a cancellation during the readiness preflight does not restart the session it canceled', async () => { - const session = makeRunnerSession({ port: 8100, ready: true }); + const session = makeRunnerSession({ port: 8100, state: 'ready' }); // The preflight's catch marks whatever it was waiting on when it gave up, and one of the things // it waits on is a caller that stopped waiting. That mark describes a walkaway, not a wedged diff --git a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts index 61977ecb19..11a2f626b5 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts @@ -95,7 +95,7 @@ function makeRunnerSession(port: number, sessionId = `wiring:${port}`): RunnerSe jsonPath: '/tmp/wiring.json', testPromise: new Promise(() => {}), child: { pid: process.pid, exitCode: null }, - ready: true, + state: 'ready', }; return session; } @@ -281,7 +281,7 @@ test.each([undefined, 'get', 'accept', 'dismiss'] as const)( 'alert action %s selects startup readiness by mutation semantics', async (action) => { server = await startFakeRunnerServer({ alert: [{ kind: 'ok', data: {} }] }); - seedSession(server.port).ready = false; + seedSession(server.port).state = 'starting'; await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'alert', action }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts b/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts index 300fe306a6..20f0a770cd 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts @@ -73,7 +73,7 @@ vi.mock('../runner-xctestrun.ts', async () => { }); import { createRequestCanceledError, isRequestCanceledError } from '@agent-device/kernel/errors'; -import { abortAllIosRunnerSessions, getRunnerSessionSnapshot } from '../runner-session.ts'; +import { abortAllIosRunnerSessions, readRunnerSessionLiveness } from '../runner-session.ts'; import type { RunnerLease } from '../runner-lease.ts'; import { executeRunnerCommand, prepareLocalIosRunner } from '../runner-lifecycle.ts'; @@ -180,7 +180,7 @@ test('direct command cancellation reaches runner launch without a registered req (error: unknown) => isRequestCanceledError(error), ); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); }); test('prepare cancellation stops only its runner and preserves unrelated prep', async () => { @@ -197,7 +197,7 @@ test('prepare cancellation stops only its runner and preserves unrelated prep', logPath: '/tmp/runner.log', healthTimeoutMs: 30_000, }); - assert.ok(getRunnerSessionSnapshot(survivorDevice.id)?.alive); + assert.equal(readRunnerSessionLiveness(survivorDevice.id)?.liveness, 'ready'); mockWaitForRunner.mockImplementation(async () => { markRequestCanceled(canceledRequestId); @@ -215,8 +215,8 @@ test('prepare cancellation stops only its runner and preserves unrelated prep', const canceledSignal = getRequestSignal(canceledRequestId); assert.ok(mockRunCmdBackground.mock.calls.some((call) => call[2]?.signal === canceledSignal)); assert.equal(canceledSignal?.aborted, true); - assert.equal(getRunnerSessionSnapshot(canceledDevice.id), null); - assert.ok(getRunnerSessionSnapshot(survivorDevice.id)?.alive); + assert.equal(readRunnerSessionLiveness(canceledDevice.id), null); + assert.equal(readRunnerSessionLiveness(survivorDevice.id)?.liveness, 'ready'); } finally { clearRequestCanceled(survivorRequestId); clearRequestCanceled(canceledRequestId); @@ -243,7 +243,7 @@ test('normal command cancellation during launch or initial readiness retains no { command: 'snapshot', appBundleId: 'com.example.demo' }, { requestId: survivorRequestId, logPath: '/tmp/runner.log' }, ); - assert.ok(getRunnerSessionSnapshot(survivorDevice.id)?.alive); + assert.equal(readRunnerSessionLiveness(survivorDevice.id)?.liveness, 'ready'); const canceledSignal = getRequestSignal(launchCanceledRequestId); mockRunCmdBackground.mockImplementationOnce((_cmd, _args, options) => { @@ -275,9 +275,9 @@ test('normal command cancellation during launch or initial readiness retains no (error: unknown) => isRequestCanceledError(error), ); - assert.equal(getRunnerSessionSnapshot(launchCanceledDevice.id), null); - assert.equal(getRunnerSessionSnapshot(readinessCanceledDevice.id), null); - assert.ok(getRunnerSessionSnapshot(survivorDevice.id)?.alive); + assert.equal(readRunnerSessionLiveness(launchCanceledDevice.id), null); + assert.equal(readRunnerSessionLiveness(readinessCanceledDevice.id), null); + assert.equal(readRunnerSessionLiveness(survivorDevice.id)?.liveness, 'ready'); assert.deepEqual(readRetainedLeaseDeviceIds(), [survivorDevice.id]); } finally { clearRequestCanceled(survivorRequestId); diff --git a/packages/platform-apple/src/runner/__tests__/runner-response.test.ts b/packages/platform-apple/src/runner/__tests__/runner-response.test.ts index b0e4358a31..59b5c25276 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-response.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-response.test.ts @@ -8,6 +8,7 @@ import { readRunnerResponseData, } from '../runner-contract.ts'; import { parseRunnerResponse } from '../runner-session.ts'; +import type { RunnerSessionState } from '../runner-session-types.ts'; // A body cut off mid-write: the shape a runner that died while answering leaves behind. const TRUNCATED_BODY = '{"ok":true,"data":{"nodes":[{"label":"Sign In"'; @@ -104,7 +105,7 @@ describe('buildRunnerResponseError', () => { describe('parseRunnerResponse', () => { test('refuses a body whose ok is not the boolean true', async () => { - const session = { ready: false }; + const session: { state: RunnerSessionState } = { state: 'starting' }; await assert.rejects( () => parseRunnerResponse(new Response(STRINGLY_TYPED_BODY), session), @@ -116,11 +117,11 @@ describe('parseRunnerResponse', () => { }, ); - assert.equal(session.ready, false); + assert.equal(session.state, 'starting'); }); test('refuses a body that is not readable JSON', async () => { - const session = { ready: false }; + const session: { state: RunnerSessionState } = { state: 'starting' }; await assert.rejects( () => parseRunnerResponse(new Response(TRUNCATED_BODY), session), @@ -134,6 +135,6 @@ describe('parseRunnerResponse', () => { }, ); - assert.equal(session.ready, false); + assert.equal(session.state, 'starting'); }); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-close.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-close.test.ts index 5010fdea26..40f86597bb 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-close.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-close.test.ts @@ -104,7 +104,7 @@ import { abortAllIosRunnerSessions, ensureRunnerSession, executeRunnerCommandWithSession, - getRunnerSessionSnapshot, + readRunnerSessionLiveness, releaseIosRunnerOnClose, } from '../runner-session.ts'; @@ -164,7 +164,7 @@ beforeEach(async () => { }); test('a RUNNER_BUSY refusal records the runner as still draining main-thread work', async () => { - const session = makeRunnerSession({ ready: true }); + const session = makeRunnerSession({ state: 'ready' }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); mockSendRunnerCommandOnce.mockResolvedValueOnce( runnerError({ @@ -187,7 +187,7 @@ test('a RUNNER_BUSY refusal records the runner as still draining main-thread wor }); test('a healthy response stamped with busy main-thread work keeps the session marked busy', async () => { - const session = makeRunnerSession({ ready: true }); + const session = makeRunnerSession({ state: 'ready' }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); mockSendRunnerCommandOnce.mockResolvedValueOnce( runnerResponse({ tapped: true, runnerMainThreadBusy: true }), @@ -219,7 +219,7 @@ test('a healthy response stamped with busy main-thread work keeps the session ma test('an unstamped healthy response leaves a busy main-thread report intact', async () => { // Recovered and journal-replayed responses are written without the occupancy stamp; their // absence must not be read as "the runner drained" (#2552). - const session = makeRunnerSession({ ready: true, runnerMainThreadBusy: true }); + const session = makeRunnerSession({ state: 'ready', runnerMainThreadBusy: true }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); @@ -237,7 +237,7 @@ test('an unstamped healthy response leaves a busy main-thread report intact', as test("the stalling command's MAIN_THREAD_TIMEOUT error records the runner as busy", async () => { // The direct #2552 repro: the command that stalls answers with the watchdog timeout, not // RUNNER_BUSY, so this is the only occupancy signal available before any refusal. - const session = makeRunnerSession({ ready: true }); + const session = makeRunnerSession({ state: 'ready' }); // A read-only snapshot on a ready session answers over the startup transport (uptime preflight, // then the command), both via waitForRunner. mockWaitForRunner @@ -262,7 +262,7 @@ test("the stalling command's MAIN_THREAD_TIMEOUT error records the runner as bus test('a served non-busy error clears a stale busy report so close keeps a drained runner', async () => { // After the abandoned work drains, a later failure that reached the main thread (element not // found) proves it drained; leaving the flag set would make close kill a healthy runner (#2552). - const session = makeRunnerSession({ ready: true, runnerMainThreadBusy: true }); + const session = makeRunnerSession({ state: 'ready', runnerMainThreadBusy: true }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); mockSendRunnerCommandOnce.mockResolvedValueOnce( runnerError({ code: 'ELEMENT_NOT_FOUND', message: 'element not found' }), @@ -292,7 +292,7 @@ test('releaseIosRunnerOnClose retains an idle runner, disposes a busy one, and t // Idle + retain: warm reuse, same session comes back for the next open. await releaseIosRunnerOnClose(device.id, { retain: true }); - assert.ok(getRunnerSessionSnapshot(device.id)); + assert.ok(readRunnerSessionLiveness(device.id)); assert.equal((await ensureRunnerSession(device, {})).sessionId, session.sessionId); // Busy + retain: a command that stalls answers MAIN_THREAD_TIMEOUT, so the stalled runner is @@ -312,10 +312,10 @@ test('releaseIosRunnerOnClose retains an idle runner, disposes a busy one, and t ), ); await releaseIosRunnerOnClose(device.id, { retain: true }); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); assert.notEqual((await ensureRunnerSession(device, {})).sessionId, session.sessionId); // Non-retained close: stops regardless of occupancy. await releaseIosRunnerOnClose(device.id, { retain: false }); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts index 6aef63ff4c..413b81b8c7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts @@ -24,7 +24,7 @@ export function makeRunnerSession(overrides: Partial = {}): Runne jsonPath: '/tmp/runner.json', testPromise: Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }), child: { pid: 1234, exitCode: null }, - ready: true, + state: 'ready', ...overrides, } as RunnerSession; } diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts new file mode 100644 index 0000000000..e6672efb35 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts @@ -0,0 +1,411 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test, vi } from 'vitest'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { + makeClassifyOwnerLivenessViaMocks, + assertRunnerCommand, + makeBackgroundRunner, + runnerResponse, + redirectHandle, +} from './runner-session-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +const { + mockAcquireXcodebuildSimulatorSetRedirect, + mockCleanupTempFile, + mockEnsureXctestrunArtifact, + mockGetFreePort, + mockIsProcessAlive, + mockIsProcessGroupAlive, + mockPrepareXctestrunWithEnv, + mockReadProcessCommand, + mockReadProcessStartTime, + mockResolveExpectedRunnerCacheMetadata, + mockResolveRunnerDerivedPath, + mockRunAppleToolCommand, + mockRunCmdBackground, + mockRunXcrun, + mockSendRunnerCommandOnce, + mockSignalPidsBestEffort, + mockSignalProcessGroupBestEffort, + mockWaitForRunner, + runnerStateTransitions, +} = vi.hoisted(() => ({ + mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), + mockCleanupTempFile: vi.fn(), + mockEnsureXctestrunArtifact: vi.fn(), + mockGetFreePort: vi.fn(), + mockIsProcessAlive: vi.fn(), + mockIsProcessGroupAlive: vi.fn(), + mockPrepareXctestrunWithEnv: vi.fn(), + mockReadProcessCommand: vi.fn((_pid: number) => null as string | null), + mockReadProcessStartTime: vi.fn((_pid: number) => 'fixed-test-owner-start-time' as string | null), + mockResolveExpectedRunnerCacheMetadata: vi.fn(), + mockResolveRunnerDerivedPath: vi.fn(), + mockRunAppleToolCommand: vi.fn(), + mockRunCmdBackground: vi.fn(), + mockRunXcrun: vi.fn(), + mockSendRunnerCommandOnce: vi.fn(), + mockSignalPidsBestEffort: vi.fn(), + mockSignalProcessGroupBestEffort: vi.fn(), + mockWaitForRunner: vi.fn(), + runnerStateTransitions: [] as string[], +})); + +const TEST_OWNER_START_TIME = 'fixed-test-owner-start-time'; + +vi.mock('../runner-io.ts', async () => { + const actual = await vi.importActual('../runner-io.ts'); + return { + ...actual, + cleanupTempFile: mockCleanupTempFile, + getFreePort: mockGetFreePort, + }; +}); + +vi.mock('../runner-disposal.ts', async () => { + const actual = + await vi.importActual('../runner-disposal.ts'); + return { + ...actual, + disposeRunnerSession: vi.fn(actual.disposeRunnerSession), + }; +}); + +vi.mock('../runner-startup-transport.ts', async () => { + const actual = await vi.importActual( + '../runner-startup-transport.ts', + ); + return { + ...actual, + waitForRunner: mockWaitForRunner, + }; +}); + +vi.mock('../runner-transport.ts', async () => { + const actual = + await vi.importActual('../runner-transport.ts'); + return { + ...actual, + sendRunnerCommandOnce: mockSendRunnerCommandOnce, + }; +}); + +vi.mock('../runner-xctestrun.ts', async () => { + const actual = + await vi.importActual('../runner-xctestrun.ts'); + return { + ...actual, + acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, + ensureXctestrunArtifact: mockEnsureXctestrunArtifact, + prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, + resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, + resolveRunnerDerivedPath: mockResolveRunnerDerivedPath, + }; +}); + +vi.mock('../runner-session-types.ts', async () => { + const actual = await vi.importActual( + '../runner-session-types.ts', + ); + return { + ...actual, + advanceRunnerSessionState( + session: Parameters[0], + next: Parameters[1], + ): void { + runnerStateTransitions.push(next); + actual.advanceRunnerSessionState(session, next); + }, + }; +}); + +import { disposeRunnerSession } from '../runner-disposal.ts'; +import { hasLiveIosRunnerSession } from '../runner-client.ts'; +import { + abortAllIosRunnerSessions, + detachIosSimulatorRunnerSessionsForShutdown, + ensureRunnerSession, + executeRunnerCommandWithSession, + invalidateRunnerSession, + readRunnerSessionLiveness, + scheduleIosRunnerIdleStop, + stopIosRunnerSession, +} from '../runner-session.ts'; + +let leaseOwnerStateDirOverride: string | undefined; +function setRunnerLeaseOwnerStateDir(stateDir: string | undefined): void { + leaseOwnerStateDirOverride = stateDir; +} + +beforeEach(async () => { + appleRunnerTestHost.update({ + runCmdBackground: mockRunCmdBackground, + isProcessAlive: mockIsProcessAlive, + isProcessGroupAlive: mockIsProcessGroupAlive, + readProcessCommand: mockReadProcessCommand, + readProcessStartTime: mockReadProcessStartTime, + signalPidsBestEffort: mockSignalPidsBestEffort, + signalProcessGroupBestEffort: mockSignalProcessGroupBestEffort, + runAppleToolCommand: mockRunAppleToolCommand, + runXcrun: mockRunXcrun, + leaseOwnerStateDir: () => leaseOwnerStateDirOverride, + classifyOwnerLiveness: makeClassifyOwnerLivenessViaMocks({ + isProcessAlive: (pid) => Boolean(mockIsProcessAlive(pid)), + readProcessStartTime: (pid) => (mockReadProcessStartTime(pid) as string | null) ?? null, + }), + }); + await abortAllIosRunnerSessions(); + vi.resetAllMocks(); + runnerStateTransitions.length = 0; + const actualDisposal = + await vi.importActual('../runner-disposal.ts'); + vi.mocked(disposeRunnerSession).mockImplementation(async (session, options) => + actualDisposal.disposeRunnerSession(session, options), + ); + setRunnerLeaseOwnerStateDir(undefined); + process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = mkdtempForTestSync( + 'agent-device-runner-lifecycle-test-', + ); + delete process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; + mockRunXcrun.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + mockEnsureXctestrunArtifact.mockResolvedValue({ + xctestrunPath: '/tmp/base-runner.xctestrun', + derived: '/tmp/derived', + cache: 'miss', + artifact: 'rebuilt', + buildMs: 12, + xctestrunPathSource: 'build', + }); + mockGetFreePort.mockResolvedValue(8123); + mockPrepareXctestrunWithEnv.mockResolvedValue({ + xctestrunPath: '/tmp/session-runner.xctestrun', + jsonPath: '/tmp/session-runner.json', + }); + mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); + mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); + mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); + mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); + mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + mockIsProcessAlive.mockReturnValue(true); + mockIsProcessGroupAlive.mockReturnValue(false); + mockReadProcessCommand.mockReturnValue(null); + mockReadProcessStartTime.mockImplementation((pid: number) => + pid === process.pid ? TEST_OWNER_START_TIME : null, + ); + mockWaitForRunner.mockResolvedValue(runnerResponse({ uptimeMs: 1 })); +}); + +test('a startup publishes starting and its first answer publishes ready', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-first-answer' }; + const session = await ensureRunnerSession(device, {}); + + assert.equal(session.state, 'starting'); + assert.deepEqual(readRunnerSessionLiveness(device.id), { + sessionId: session.sessionId, + liveness: 'starting', + }); + assert.equal(hasLiveIosRunnerSession(device), false); + + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ nodes: [], truncated: false })); + await executeRunnerCommandWithSession( + device, + session, + { command: 'snapshot', appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + + assert.equal(session.state, 'ready'); + assert.deepEqual(readRunnerSessionLiveness(device.id), { + sessionId: session.sessionId, + liveness: 'ready', + }); + assert.equal(hasLiveIosRunnerSession(device), true); +}); + +test('an idle stop moves a ready session through disposal to stopped', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-idle-stop' }; + process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS = '1'; + const session = await ensureRunnerSession(device, {}); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ nodes: [], truncated: false })); + await executeRunnerCommandWithSession( + device, + session, + { command: 'snapshot', appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + + scheduleIosRunnerIdleStop(device.id); + await vi.waitFor(() => assert.equal(session.state, 'stopped')); + + assert.equal( + readRunnerSessionLiveness(device.id), + null, + 'the idle stop removes the session from the device registry', + ); + assert.equal(hasLiveIosRunnerSession(device), false); +}); + +test('stopping the same registered runner twice tears it down once', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-stop-twice' }; + const session = await ensureRunnerSession(device, {}); + + await stopIosRunnerSession(device.id); + assert.equal(vi.mocked(disposeRunnerSession).mock.calls.length, 1); + assert.equal(session.state, 'stopped'); + assert.equal(readRunnerSessionLiveness(device.id), null); + + await stopIosRunnerSession(device.id); + assert.equal(vi.mocked(disposeRunnerSession).mock.calls.length, 1); +}); + +test('stop and invalidate do not start a second disposal already in progress', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-concurrent-stop' }; + const session = await ensureRunnerSession(device, {}); + let resolveShutdown!: (response: Response) => void; + mockWaitForRunner.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveShutdown = resolve; + }), + ); + + const disposal = disposeRunnerSession(session); + await vi.waitFor(() => assert.equal(session.state, 'draining')); + assert.equal(hasLiveIosRunnerSession(device), false); + + await stopIosRunnerSession(device.id); + await invalidateRunnerSession(session, 'late command failure'); + + assert.equal(vi.mocked(disposeRunnerSession).mock.calls.length, 1); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + resolveShutdown(runnerResponse({ uptimeMs: 1 })); + await disposal; + + assert.equal(session.state, 'stopped'); +}); + +test('an answer parsed by a draining session cannot revive it', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-late-answer' }; + const session = await ensureRunnerSession(device, {}); + let resolveShutdown!: (response: Response) => void; + mockWaitForRunner.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveShutdown = resolve; + }), + ); + + const disposal = disposeRunnerSession(session); + await vi.waitFor(() => assert.equal(session.state, 'draining')); + + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ nodes: [], truncated: false })); + await executeRunnerCommandWithSession( + device, + session, + { command: 'snapshot', appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + + assert.equal(session.state, 'draining'); + assert.equal(readRunnerSessionLiveness(device.id)?.liveness, 'draining'); + assert.equal(hasLiveIosRunnerSession(device), false); + assertRunnerCommand(mockWaitForRunner.mock.calls[1]?.[2], { + command: 'snapshot', + appBundleId: 'com.example.demo', + }); + + resolveShutdown(runnerResponse({ uptimeMs: 1 })); + await disposal; + assert.equal(session.state, 'stopped'); +}); + +test('an abort drains a registered runner before reporting it stopped', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-abort' }; + const session = await ensureRunnerSession(device, {}); + runnerStateTransitions.length = 0; + + await abortAllIosRunnerSessions(); + + assert.deepEqual(runnerStateTransitions, ['draining', 'stopped']); + assert.equal(session.state, 'stopped'); + assert.equal(readRunnerSessionLiveness(device.id), null); +}); + +test('shutdown detach moves a handed-off session to stopped without killing its runner', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-detach' }; + mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(null); + const session = await ensureRunnerSession(device, {}); + const runnerPid = session.child.pid; + assert.ok(runnerPid); + runnerStateTransitions.length = 0; + + assert.equal(await detachIosSimulatorRunnerSessionsForShutdown(), 1); + + assert.equal(session.state, 'stopped'); + assert.deepEqual(runnerStateTransitions, ['stopped']); + assert.equal(readRunnerSessionLiveness(device.id), null); + assert.equal(mockIsProcessAlive(runnerPid), true); +}); + +test('a registered runner whose process died is recycled instead of reused', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-dead-process' }; + const first = await ensureRunnerSession(device, {}); + const firstPid = first.child.pid; + assert.ok(firstPid); + runnerStateTransitions.length = 0; + + mockIsProcessAlive.mockImplementation((pid: number) => pid !== firstPid); + assert.deepEqual(readRunnerSessionLiveness(device.id), { + sessionId: first.sessionId, + liveness: 'gone', + }); + + mockGetFreePort.mockResolvedValueOnce(8124); + mockRunCmdBackground.mockReturnValueOnce(makeBackgroundRunner(firstPid + 1)); + const second = await ensureRunnerSession(device, {}); + + assert.notEqual(second.sessionId, first.sessionId); + assert.equal(second.child.pid, firstPid + 1); + assert.equal(first.state, 'stopped'); + assert.deepEqual(runnerStateTransitions, ['draining', 'stopped']); + assert.deepEqual(readRunnerSessionLiveness(device.id), { + sessionId: second.sessionId, + liveness: 'starting', + }); +}); + +test('a draining session is never reused while its next command starts a fresh runner', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-draining-reuse' }; + const first = await ensureRunnerSession(device, {}); + let resolveShutdown!: (response: Response) => void; + mockWaitForRunner.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveShutdown = resolve; + }), + ); + + const disposal = disposeRunnerSession(first); + await vi.waitFor(() => assert.equal(first.state, 'draining')); + + mockGetFreePort.mockResolvedValueOnce(8124); + mockRunCmdBackground.mockReturnValueOnce(makeBackgroundRunner(4243)); + const second = await ensureRunnerSession(device, {}); + + assert.notEqual(second.sessionId, first.sessionId); + assert.equal(first.state, 'draining'); + assert.deepEqual(readRunnerSessionLiveness(device.id), { + sessionId: second.sessionId, + liveness: 'starting', + }); + + resolveShutdown(runnerResponse({ uptimeMs: 1 })); + await disposal; + assert.equal(first.state, 'stopped'); + assert.equal(second.state, 'starting'); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-readiness.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-readiness.test.ts new file mode 100644 index 0000000000..d616fa1715 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-session-readiness.test.ts @@ -0,0 +1,541 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test, vi } from 'vitest'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { + makeClassifyOwnerLivenessViaMocks, + assertRunnerCommand, + captureDiagnostics, + makeBackgroundRunner, + makeRunnerSession, + runnerError, + runnerResponse, + redirectHandle, +} from './runner-session-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +const { + mockAcquireXcodebuildSimulatorSetRedirect, + mockCleanupTempFile, + mockEnsureXctestrunArtifact, + mockGetFreePort, + mockIsProcessAlive, + mockIsProcessGroupAlive, + mockPrepareXctestrunWithEnv, + mockReadProcessCommand, + mockReadProcessStartTime, + mockResolveExpectedRunnerCacheMetadata, + mockResolveRunnerDerivedPath, + mockRunAppleToolCommand, + mockRunCmdBackground, + mockRunXcrun, + mockSendRunnerCommandOnce, + mockSignalPidsBestEffort, + mockSignalProcessGroupBestEffort, + mockWaitForRunner, +} = vi.hoisted(() => ({ + mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), + mockCleanupTempFile: vi.fn(), + mockEnsureXctestrunArtifact: vi.fn(), + mockGetFreePort: vi.fn(), + mockIsProcessAlive: vi.fn(), + mockIsProcessGroupAlive: vi.fn(), + mockPrepareXctestrunWithEnv: vi.fn(), + // Non-empty default: TEST_OWNER_START_TIME below feeds both this mock and + // runnerOwnerStartTime()'s first (memoized) call - see the beforeEach + // wiring and R8 in the conversion guide. readProcessStartTime's real + // implementation shells out to `ps` with a 1s timeout that can miss under + // CPU contention, flipping a live owner to 'owner-process-dead'. + // Deterministic value, no shell-out; identity is still enforced by pid in + // beforeEach below. + mockReadProcessCommand: vi.fn((_pid: number) => null as string | null), + mockReadProcessStartTime: vi.fn((_pid: number) => 'fixed-test-owner-start-time' as string | null), + mockResolveExpectedRunnerCacheMetadata: vi.fn(), + mockResolveRunnerDerivedPath: vi.fn(), + mockRunAppleToolCommand: vi.fn(), + mockRunCmdBackground: vi.fn(), + mockRunXcrun: vi.fn(), + mockSendRunnerCommandOnce: vi.fn(), + // The runner child pid below is fabricated (4242), so the signal writes are + // mocked next to the liveness reads: a real signal to a made-up pid can hit a + // sibling vitest fork (#1824), and the shared setup refuses it outright. + mockSignalPidsBestEffort: vi.fn(), + mockSignalProcessGroupBestEffort: vi.fn(), + mockWaitForRunner: vi.fn(), +})); + +// Fixed owner-identity value shared by the readProcessStartTime override +// below and every fixture/assertion that used to read the module-load-time +// RUNNER_OWNER_START_TIME constant. runnerOwnerStartTime() (R8) memoizes on +// its first call, which reads this same override, so the two stay +// consistent without either one calling back into the other. +const TEST_OWNER_START_TIME = 'fixed-test-owner-start-time'; + +vi.mock('../runner-io.ts', async () => { + const actual = await vi.importActual('../runner-io.ts'); + return { + ...actual, + cleanupTempFile: mockCleanupTempFile, + getFreePort: mockGetFreePort, + }; +}); + +vi.mock('../runner-startup-transport.ts', async () => { + const actual = await vi.importActual( + '../runner-startup-transport.ts', + ); + return { + ...actual, + waitForRunner: mockWaitForRunner, + }; +}); + +vi.mock('../runner-transport.ts', async () => { + const actual = + await vi.importActual('../runner-transport.ts'); + return { + ...actual, + sendRunnerCommandOnce: mockSendRunnerCommandOnce, + }; +}); + +vi.mock('../runner-xctestrun.ts', async () => { + const actual = + await vi.importActual('../runner-xctestrun.ts'); + return { + ...actual, + acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, + ensureXctestrunArtifact: mockEnsureXctestrunArtifact, + prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, + resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, + resolveRunnerDerivedPath: mockResolveRunnerDerivedPath, + }; +}); + +import { abortAllIosRunnerSessions, executeRunnerCommandWithSession } from '../runner-session.ts'; + +// Test-only stand-in for the daemon's own runtime lease-owner-state-dir +// setter (root-only; the package cannot import it - R11). Backs the +// host.leaseOwnerStateDir() getter the package reads instead (R3). +let leaseOwnerStateDirOverride: string | undefined; +function setRunnerLeaseOwnerStateDir(stateDir: string | undefined): void { + leaseOwnerStateDirOverride = stateDir; +} + +beforeEach(async () => { + // Must run before abortAllIosRunnerSessions() below: that call can tear + // down sessions left in memory by the PREVIOUS test, and its cleanup path + // signals real processes (host.signalPidsBestEffort et al.) unless the + // host overrides are already installed. The setup file's own beforeEach + // (which runs before this one) just wiped them back to real defaults. + appleRunnerTestHost.update({ + runCmdBackground: mockRunCmdBackground, + isProcessAlive: mockIsProcessAlive, + isProcessGroupAlive: mockIsProcessGroupAlive, + readProcessCommand: mockReadProcessCommand, + readProcessStartTime: mockReadProcessStartTime, + signalPidsBestEffort: mockSignalPidsBestEffort, + signalProcessGroupBestEffort: mockSignalProcessGroupBestEffort, + runAppleToolCommand: mockRunAppleToolCommand, + runXcrun: mockRunXcrun, + leaseOwnerStateDir: () => leaseOwnerStateDirOverride, + classifyOwnerLiveness: makeClassifyOwnerLivenessViaMocks({ + isProcessAlive: (pid) => Boolean(mockIsProcessAlive(pid)), + readProcessStartTime: (pid) => (mockReadProcessStartTime(pid) as string | null) ?? null, + }), + }); + await abortAllIosRunnerSessions(); + vi.resetAllMocks(); + setRunnerLeaseOwnerStateDir(undefined); + process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = mkdtempForTestSync( + 'agent-device-runner-lease-test-', + ); + mockRunXcrun.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + mockEnsureXctestrunArtifact.mockResolvedValue({ + xctestrunPath: '/tmp/base-runner.xctestrun', + derived: '/tmp/derived', + cache: 'miss', + artifact: 'rebuilt', + buildMs: 12, + xctestrunPathSource: 'build', + }); + mockGetFreePort.mockResolvedValue(8123); + mockPrepareXctestrunWithEnv.mockResolvedValue({ + xctestrunPath: '/tmp/session-runner.xctestrun', + jsonPath: '/tmp/session-runner.json', + }); + mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); + mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); + mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); + mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); + mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + mockIsProcessAlive.mockReturnValue(true); + mockIsProcessGroupAlive.mockReturnValue(false); + mockReadProcessCommand.mockReturnValue(null); + // Our pid reads back its fixed start time; any other pid reads as + // not-found, same as a real `ps` miss. Dead-lease tests use fabricated + // pids already rejected by mockIsProcessAlive before this is consulted. + mockReadProcessStartTime.mockImplementation((pid: number) => + pid === process.pid ? TEST_OWNER_START_TIME : null, + ); + mockWaitForRunner.mockResolvedValue(runnerResponse({ uptimeMs: 1 })); +}); +test('runner session executes read-only commands without uptime preflight', async () => { + const session = makeRunnerSession({ state: 'starting' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ nodes: [], truncated: false })); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'snapshot', appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { nodes: [], truncated: false }); + assert.equal(session.state, 'ready'); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { + command: 'snapshot', + appBundleId: 'com.example.demo', + }); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); +}); + +test('runner session probes readiness before ready read-only commands', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner + .mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })) + .mockResolvedValueOnce(runnerResponse({ nodes: [], truncated: false })); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'snapshot', appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { nodes: [], truncated: false }); + assert.equal(mockWaitForRunner.mock.calls.length, 2); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assert.equal(mockWaitForRunner.mock.calls[0]?.[4], 1_000); + assertRunnerCommand(mockWaitForRunner.mock.calls[1]?.[2], { + command: 'snapshot', + appBundleId: 'com.example.demo', + }); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); +}); + +test('runner session marks read-only readiness preflight failures before command send', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockRejectedValueOnce(new Error('fetch failed')); + + await assert.rejects( + () => + executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'snapshot', appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.runnerReadinessPreflightFailed, true); + return true; + }, + ); + + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); +}); + +test('runner session executes status command as read-only lifecycle command', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockResolvedValueOnce( + runnerResponse({ + commandId: 'runner-command-1', + lifecycleState: 'completed', + lifecycleResponseOk: true, + }), + ); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'status', statusCommandId: 'runner-command-1' }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { + commandId: 'runner-command-1', + lifecycleState: 'completed', + lifecycleResponseOk: true, + }); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand( + mockWaitForRunner.mock.calls[0]?.[2], + { + command: 'status', + statusCommandId: 'runner-command-1', + }, + { commandId: false }, + ); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); +}); + +test('runner session probes readiness before mutating commands', async () => { + const session = makeRunnerSession({ state: 'starting' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { tapped: true }); + assert.equal(session.state, 'ready'); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); + assertRunnerCommand(mockSendRunnerCommandOnce.mock.calls[0]?.[2], { + command: 'tap', + x: 120, + y: 240, + appBundleId: 'com.example.demo', + }); +}); + +test('runner session emits reason diagnostics when readiness preflight is used', async () => { + const session = makeRunnerSession({ state: 'starting' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); + + const diagnostics = await captureDiagnostics(async () => { + await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + }); + + assert.match(diagnostics, /"reason":"startup"/); + assert.match(diagnostics, /ios_runner_readiness_preflight/); +}); + +test('runner session probes readiness for ready tap commands', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { tapped: true }); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assert.equal(mockWaitForRunner.mock.calls[0]?.[4], 1_000); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); +}); + +test('runner session emits explicit diagnostics when ready sessions are probed', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); + + const diagnostics = await captureDiagnostics(async () => { + await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + }); + + assert.match(diagnostics, /ios_runner_readiness_preflight/); + assert.match(diagnostics, /"reason":"no_recent_healthy_mutation"/); + assert.doesNotMatch(diagnostics, /ios_runner_readiness_preflight_skipped/); +}); + +test('runner session marks preflight failures for ready mutating commands', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockRejectedValueOnce(new Error('fetch failed')); + + await assert.rejects( + () => + executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.runnerReadinessPreflightFailed, true); + return true; + }, + ); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); +}); + +test('runner session preserves runner response failures after successful readiness preflight', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce( + runnerError({ + code: 'COMMAND_FAILED', + message: 'Runner failed after receiving command', + }), + ); + + await assert.rejects( + () => + executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'Runner failed after receiving command'); + return true; + }, + ); +}); + +test('runner session probes readiness for ready selector taps', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { + command: 'tap', + selectorKey: 'label', + selectorValue: 'Navigate to article', + appBundleId: 'com.example.demo', + }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { tapped: true }); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assert.equal(mockWaitForRunner.mock.calls[0]?.[4], 1_000); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); +}); + +test('runner session probes readiness for ready sequence commands', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { + command: 'sequence', + steps: [ + { kind: 'tap', x: 120, y: 240, pauseMs: 80 }, + { kind: 'tap', x: 120, y: 240 }, + ], + appBundleId: 'com.example.demo', + }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { tapped: true }); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assert.equal(mockWaitForRunner.mock.calls[0]?.[4], 1_000); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); +}); + +test('runner session keeps readiness preflight for ready tap commands without prior command state', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { tapped: true }); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); +}); + +test('runner session keeps readiness preflight for non-tap mutating commands when marked ready', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ pressed: true })); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'longPress', x: 120, y: 240, appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { pressed: true }); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); +}); + +test('runner session preserves structured runner failures', async () => { + const session = makeRunnerSession({ state: 'ready' }); + mockWaitForRunner.mockResolvedValueOnce( + runnerError({ + code: 'COMMAND_FAILED', + message: 'Runner crashed while reading snapshot', + }), + ); + + await assert.rejects( + () => + executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'snapshot', appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.message, 'Runner crashed while reading snapshot'); + assert.equal(error.details?.logPath, '/tmp/runner.log'); + return true; + }, + ); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts index af8b8e16aa..85e48971b5 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts @@ -88,7 +88,7 @@ vi.mock('../runner-startup-transport.ts', async () => { import { abortAllIosRunnerSessions, ensureRunnerSession, - getRunnerSessionSnapshot, + readRunnerSessionLiveness, markRunnerSessionServed, releaseSpeculativeIosRunnerSession, } from '../runner-session.ts'; @@ -161,7 +161,7 @@ test('a prewarm-started session is speculative until a command other than a read markRunnerSessionServed(session, withRunnerCommandId({ command: 'tap', x: 1, y: 1 })); assert.equal(session.speculative, false); assert.equal(await releaseSpeculativeIosRunnerSession(device.id), false); - assert.notEqual(getRunnerSessionSnapshot(device.id), null, 'a served runner stays'); + assert.notEqual(readRunnerSessionLiveness(device.id), null, 'a served runner stays'); }); test('releasing a speculative session stops it; a session a command asked for is kept', async () => { @@ -172,9 +172,9 @@ test('releasing a speculative session stops it; a session a command asked for is await ensureRunnerSession(demanded, {}); assert.equal(await releaseSpeculativeIosRunnerSession(speculative.id), true); - assert.equal(getRunnerSessionSnapshot(speculative.id), null); + assert.equal(readRunnerSessionLiveness(speculative.id), null); assert.equal(await releaseSpeculativeIosRunnerSession(demanded.id), false); - assert.notEqual(getRunnerSessionSnapshot(demanded.id), null); + assert.notEqual(readRunnerSessionLiveness(demanded.id), null); assert.equal(await releaseSpeculativeIosRunnerSession('no-such-device'), false); }); @@ -198,12 +198,12 @@ test('a release that arrives while the speculative start is still in flight stop new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 50)), ]); assert.equal(settledEarly, 'pending', 'the release waits for the start it cannot yet see'); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); openGate(); await starting; assert.equal(await releasing, true); - assert.equal(getRunnerSessionSnapshot(device.id), null, 'the completed start was stopped'); + assert.equal(readRunnerSessionLiveness(device.id), null, 'the completed start was stopped'); }); test('a release that waits out a demanded start leaves that runner alone', async () => { @@ -222,5 +222,5 @@ test('a release that waits out a demanded start leaves that runner alone', async openGate(); await starting; assert.equal(await releasing, false); - assert.notEqual(getRunnerSessionSnapshot(device.id), null); + assert.notEqual(readRunnerSessionLiveness(device.id), null); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-types.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-types.test.ts new file mode 100644 index 0000000000..182a58c11b --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-session-types.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest'; +import { + advanceRunnerSessionState, + canWorkWithRunnerSession, + resolveRunnerSessionLiveness, + type RunnerSessionState, +} from '../runner-session-types.ts'; + +// The state table is the single place a runner session's lifecycle moves (#2662); these pins keep +// it from quietly gaining an edge that revives a session or tears one down twice. + +const ALL_STATES: readonly RunnerSessionState[] = ['starting', 'ready', 'draining', 'stopped']; + +function held(state: RunnerSessionState) { + return { state }; +} + +describe('advanceRunnerSessionState', () => { + test('advances a session along the legal lifecycle', () => { + const session = held('starting'); + + advanceRunnerSessionState(session, 'ready'); + expect(session.state).toBe('ready'); + + advanceRunnerSessionState(session, 'draining'); + expect(session.state).toBe('draining'); + + advanceRunnerSessionState(session, 'stopped'); + expect(session.state).toBe('stopped'); + }); + + test('hands a healthy runner straight to the next daemon at shutdown', () => { + // The graceful-shutdown handoff writes a detached lease and never disposes, so there is no + // `draining` in between even though the runner process keeps running. + const session = held('ready'); + + advanceRunnerSessionState(session, 'stopped'); + + expect(session.state).toBe('stopped'); + }); + + test('hands an unanswering startup to the next daemon too', () => { + const session = held('starting'); + + advanceRunnerSessionState(session, 'stopped'); + + expect(session.state).toBe('stopped'); + }); + + test('never moves a session backwards', () => { + const cases: ReadonlyArray = [ + ['ready', 'starting'], + ['draining', 'ready'], + ['draining', 'starting'], + ['stopped', 'ready'], + ['stopped', 'starting'], + ['stopped', 'draining'], + ]; + + for (const [from, attempted] of cases) { + const session = held(from); + advanceRunnerSessionState(session, attempted); + expect(session.state, `${from} -> ${attempted}`).toBe(from); + } + }); + + test('lets a starting session stop without ever answering', () => { + const session = held('starting'); + + advanceRunnerSessionState(session, 'draining'); + + expect(session.state).toBe('draining'); + }); + + test('is idempotent, so a repeated transition changes nothing', () => { + const session = held('ready'); + + advanceRunnerSessionState(session, 'ready'); + + expect(session.state).toBe('ready'); + }); +}); + +describe('canWorkWithRunnerSession', () => { + test('is true only before disposal starts', () => { + const verdicts = Object.fromEntries( + ALL_STATES.map((state) => [state, canWorkWithRunnerSession(held(state))]), + ); + + expect(verdicts).toEqual({ + starting: true, + ready: true, + draining: false, + stopped: false, + }); + }); +}); + +describe('resolveRunnerSessionLiveness', () => { + test('answers ready only when an answered session still has its process', () => { + expect(resolveRunnerSessionLiveness({ state: 'ready', processRunning: true })).toBe('ready'); + expect(resolveRunnerSessionLiveness({ state: 'starting', processRunning: true })).toBe( + 'starting', + ); + }); + + test('reports a dead process as gone whatever the session state was', () => { + // A runner that dies while its session is still registered must not look reusable: the next + // command reads `gone` here and starts a fresh process (ADR 0005). + for (const state of ALL_STATES) { + expect(resolveRunnerSessionLiveness({ state, processRunning: false })).toBe('gone'); + } + }); + + test('passes the state through while the process is running', () => { + for (const state of ALL_STATES) { + expect(resolveRunnerSessionLiveness({ state, processRunning: true })).toBe(state); + } + }); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session.test.ts index ca8c8aafbc..a47b8166b9 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session.test.ts @@ -125,7 +125,7 @@ import { ensureRunnerSession, scheduleIosRunnerIdleStop, executeRunnerCommandWithSession, - getRunnerSessionSnapshot, + readRunnerSessionLiveness, invalidateRunnerSession, stopIosRunnerSession, validateRunnerDevice, @@ -207,365 +207,6 @@ beforeEach(async () => { mockWaitForRunner.mockResolvedValue(runnerResponse({ uptimeMs: 1 })); }); -test('runner session executes read-only commands without uptime preflight', async () => { - const session = makeRunnerSession({ ready: false }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ nodes: [], truncated: false })); - - const result = await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'snapshot', appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ); - - assert.deepEqual(result, { nodes: [], truncated: false }); - assert.equal(session.ready, true); - assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { - command: 'snapshot', - appBundleId: 'com.example.demo', - }); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); -}); - -test('runner session probes readiness before ready read-only commands', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner - .mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })) - .mockResolvedValueOnce(runnerResponse({ nodes: [], truncated: false })); - - const result = await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'snapshot', appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ); - - assert.deepEqual(result, { nodes: [], truncated: false }); - assert.equal(mockWaitForRunner.mock.calls.length, 2); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); - assert.equal(mockWaitForRunner.mock.calls[0]?.[4], 1_000); - assertRunnerCommand(mockWaitForRunner.mock.calls[1]?.[2], { - command: 'snapshot', - appBundleId: 'com.example.demo', - }); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); -}); - -test('runner session marks read-only readiness preflight failures before command send', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockRejectedValueOnce(new Error('fetch failed')); - - await assert.rejects( - () => - executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'snapshot', appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.details?.runnerReadinessPreflightFailed, true); - return true; - }, - ); - - assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); -}); - -test('runner session executes status command as read-only lifecycle command', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockResolvedValueOnce( - runnerResponse({ - commandId: 'runner-command-1', - lifecycleState: 'completed', - lifecycleResponseOk: true, - }), - ); - - const result = await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'status', statusCommandId: 'runner-command-1' }, - '/tmp/runner.log', - 30_000, - ); - - assert.deepEqual(result, { - commandId: 'runner-command-1', - lifecycleState: 'completed', - lifecycleResponseOk: true, - }); - assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand( - mockWaitForRunner.mock.calls[0]?.[2], - { - command: 'status', - statusCommandId: 'runner-command-1', - }, - { commandId: false }, - ); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); -}); - -test('runner session probes readiness before mutating commands', async () => { - const session = makeRunnerSession({ ready: false }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); - mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); - - const result = await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ); - - assert.deepEqual(result, { tapped: true }); - assert.equal(session.ready, true); - assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); - assertRunnerCommand(mockSendRunnerCommandOnce.mock.calls[0]?.[2], { - command: 'tap', - x: 120, - y: 240, - appBundleId: 'com.example.demo', - }); -}); - -test('runner session emits reason diagnostics when readiness preflight is used', async () => { - const session = makeRunnerSession({ ready: false }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); - mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); - - const diagnostics = await captureDiagnostics(async () => { - await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ); - }); - - assert.match(diagnostics, /"reason":"startup"/); - assert.match(diagnostics, /ios_runner_readiness_preflight/); -}); - -test('runner session probes readiness for ready tap commands', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); - mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); - - const result = await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ); - - assert.deepEqual(result, { tapped: true }); - assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); - assert.equal(mockWaitForRunner.mock.calls[0]?.[4], 1_000); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); -}); - -test('runner session emits explicit diagnostics when ready sessions are probed', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); - mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); - - const diagnostics = await captureDiagnostics(async () => { - await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ); - }); - - assert.match(diagnostics, /ios_runner_readiness_preflight/); - assert.match(diagnostics, /"reason":"no_recent_healthy_mutation"/); - assert.doesNotMatch(diagnostics, /ios_runner_readiness_preflight_skipped/); -}); - -test('runner session marks preflight failures for ready mutating commands', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockRejectedValueOnce(new Error('fetch failed')); - - await assert.rejects( - () => - executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.details?.runnerReadinessPreflightFailed, true); - return true; - }, - ); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); -}); - -test('runner session preserves runner response failures after successful readiness preflight', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); - mockSendRunnerCommandOnce.mockResolvedValueOnce( - runnerError({ - code: 'COMMAND_FAILED', - message: 'Runner failed after receiving command', - }), - ); - - await assert.rejects( - () => - executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.message, 'Runner failed after receiving command'); - return true; - }, - ); -}); - -test('runner session probes readiness for ready selector taps', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); - mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); - - const result = await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { - command: 'tap', - selectorKey: 'label', - selectorValue: 'Navigate to article', - appBundleId: 'com.example.demo', - }, - '/tmp/runner.log', - 30_000, - ); - - assert.deepEqual(result, { tapped: true }); - assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); - assert.equal(mockWaitForRunner.mock.calls[0]?.[4], 1_000); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); -}); - -test('runner session probes readiness for ready sequence commands', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); - mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); - - const result = await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { - command: 'sequence', - steps: [ - { kind: 'tap', x: 120, y: 240, pauseMs: 80 }, - { kind: 'tap', x: 120, y: 240 }, - ], - appBundleId: 'com.example.demo', - }, - '/tmp/runner.log', - 30_000, - ); - - assert.deepEqual(result, { tapped: true }); - assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); - assert.equal(mockWaitForRunner.mock.calls[0]?.[4], 1_000); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); -}); - -test('runner session keeps readiness preflight for ready tap commands without prior command state', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); - mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); - - const result = await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ); - - assert.deepEqual(result, { tapped: true }); - assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); -}); - -test('runner session keeps readiness preflight for non-tap mutating commands when marked ready', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); - mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ pressed: true })); - - const result = await executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'longPress', x: 120, y: 240, appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ); - - assert.deepEqual(result, { pressed: true }); - assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); - assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); -}); - -test('runner session preserves structured runner failures', async () => { - const session = makeRunnerSession({ ready: true }); - mockWaitForRunner.mockResolvedValueOnce( - runnerError({ - code: 'COMMAND_FAILED', - message: 'Runner crashed while reading snapshot', - }), - ); - - await assert.rejects( - () => - executeRunnerCommandWithSession( - IOS_SIMULATOR, - session, - { command: 'snapshot', appBundleId: 'com.example.demo' }, - '/tmp/runner.log', - 30_000, - ), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.code, 'COMMAND_FAILED'); - assert.equal(error.message, 'Runner crashed while reading snapshot'); - assert.equal(error.details?.logPath, '/tmp/runner.log'); - return true; - }, - ); -}); - test('runner session invalidates after runner-fatal ok payloads', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-fatal-payload-sim' }; const session = await ensureRunnerSession(device, {}); @@ -590,7 +231,7 @@ test('runner session invalidates after runner-fatal ok payloads', async () => { assert.equal(result.runnerFatal, true); assert.equal(result.runnerFatalReason, 'ax_snapshot_unavailable'); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); assert.equal( mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'), true, @@ -626,7 +267,7 @@ test('runner session invalidates after XCTest recorded mutation failures', async return true; }, ); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); assert.equal( mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'), true, @@ -666,8 +307,8 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv mockRunXcrun.mock.calls.some((call) => call[0]?.includes('uninstall')), false, ); - const expected = { sessionId: session.sessionId, alive: true, ready: false }; - assert.deepEqual(getRunnerSessionSnapshot(device.id), expected); + // Registered and launched, but nothing has answered yet. + assert.equal(readRunnerSessionLiveness(device.id)?.liveness, 'starting'); }); test('runner session emits XCTest startup progress only after a runner rebuild', async () => { @@ -775,7 +416,7 @@ test('idle stop tears down a retained runner after the idle window', async () => scheduleIosRunnerIdleStop(device.id); await new Promise((resolve) => setTimeout(resolve, 150)); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); } finally { cancelIosRunnerIdleStop(device.id); if (previousIdleMs === undefined) delete process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; @@ -793,7 +434,7 @@ test('any runner use cancels a pending idle stop', async () => { await ensureRunnerSession(device, {}); await new Promise((resolve) => setTimeout(resolve, 150)); - assert.ok(getRunnerSessionSnapshot(device.id)); + assert.ok(readRunnerSessionLiveness(device.id)); } finally { cancelIosRunnerIdleStop(device.id); if (previousIdleMs === undefined) delete process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; @@ -810,7 +451,7 @@ test('idle stop is disabled when the window is zero', async () => { scheduleIosRunnerIdleStop(device.id); await new Promise((resolve) => setTimeout(resolve, 100)); - assert.ok(getRunnerSessionSnapshot(device.id)); + assert.ok(readRunnerSessionLiveness(device.id)); } finally { cancelIosRunnerIdleStop(device.id); if (previousIdleMs === undefined) delete process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; @@ -827,7 +468,7 @@ test('shutdown detach hands off default-set simulator runner sessions', async () const detached = await detachIosSimulatorRunnerSessionsForShutdown(); assert.equal(detached, 1); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); const leaseRaw = fs.readFileSync( path.join(process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR ?? '', `${device.id}.json`), 'utf8', @@ -850,7 +491,7 @@ test('shutdown detach keeps scoped simulator-set runner sessions for the kill pa // The redirect-holding session must stay for disposal, which restores the // XCTestDevices symlink; detach never releases the redirect itself. assert.equal(detached, 0); - assert.ok(getRunnerSessionSnapshot(device.id)); + assert.ok(readRunnerSessionLiveness(device.id)); assert.equal(redirectRelease.mock.calls.length, 0); }); @@ -1499,7 +1140,7 @@ test('runner session abort removes owned lease for in-memory sessions', async () await abortAllIosRunnerSessions(); assert.equal(fs.existsSync(leasePath), false); - assert.equal(getRunnerSessionSnapshot(session.deviceId), null); + assert.equal(readRunnerSessionLiveness(session.deviceId), null); assert.deepEqual(mockCleanupTempFile.mock.calls, [ ['/tmp/session-runner.xctestrun'], ['/tmp/session-runner.json'], @@ -1547,7 +1188,7 @@ test('runner session invalidation skips graceful shutdown and removes stale sess ['/tmp/session-runner.json'], ]); assert.equal(redirectRelease.mock.calls.length, 1); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); }); test('runner session validates supported Apple runner devices', () => { @@ -1593,7 +1234,7 @@ for (const { name, command } of ALLOWLISTED_MUTATIONS) { vi.setSystemTime(new Date('2026-06-11T00:00:00Z')); try { const session = makeRunnerSession({ - ready: true, + state: 'ready', lastHealthyMutation: { atMs: Date.now() - 1_500, appBundleId: 'com.example.demo' }, }); mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ acted: true })); @@ -1622,7 +1263,7 @@ for (const { name, command } of ALLOWLISTED_MUTATIONS) { } test('runner session records recency only from allowlisted healthy mutations', async () => { - const session = makeRunnerSession({ ready: true }); + const session = makeRunnerSession({ state: 'ready' }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); @@ -1651,7 +1292,7 @@ test('runner session records recency only from allowlisted healthy mutations', a }); test('runner session does not record recency from successful read-only responses', async () => { - const session = makeRunnerSession({ ready: true }); + const session = makeRunnerSession({ state: 'ready' }); mockWaitForRunner .mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })) .mockResolvedValueOnce(runnerResponse({ nodes: [], truncated: false })); @@ -1682,7 +1323,7 @@ test('runner session does not record recency from successful read-only responses }); test('runner session does not record recency from runnerFatal ok payloads', async () => { - const session = makeRunnerSession({ ready: true }); + const session = makeRunnerSession({ state: 'ready' }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); mockSendRunnerCommandOnce.mockResolvedValueOnce( runnerResponse({ @@ -1705,7 +1346,7 @@ test('runner session does not record recency from runnerFatal ok payloads', asyn test('runner session preflights with conservative_command for non-allowlisted mutations', async () => { const session = makeRunnerSession({ - ready: true, + state: 'ready', lastHealthyMutation: { atMs: Date.now(), appBundleId: 'com.example.demo' }, }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); @@ -1726,7 +1367,7 @@ test('runner session preflights with conservative_command for non-allowlisted mu }); test('runner session sends targetReset without a readiness preflight', async () => { - const session = makeRunnerSession({ ready: true }); + const session = makeRunnerSession({ state: 'ready' }); mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ targetReset: true })); const diagnostics = await captureDiagnostics(async () => { @@ -1745,7 +1386,7 @@ test('runner session sends targetReset without a readiness preflight', async () }); test('runner session preflights with no_recent_healthy_mutation when ready without a record', async () => { - const session = makeRunnerSession({ ready: true }); + const session = makeRunnerSession({ state: 'ready' }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); @@ -1765,7 +1406,7 @@ test('runner session preflights with no_recent_healthy_mutation when ready witho test('runner session preflights with healthy_mutation_stale when the record is older than 5s', async () => { const session = makeRunnerSession({ - ready: true, + state: 'ready', lastHealthyMutation: { atMs: Date.now() - 6_000, appBundleId: 'com.example.demo' }, }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); @@ -1787,7 +1428,7 @@ test('runner session preflights with healthy_mutation_stale when the record is o test('runner session preflights with app_activation_uncertain on a differing bundle', async () => { const session = makeRunnerSession({ - ready: true, + state: 'ready', lastHealthyMutation: { atMs: Date.now(), appBundleId: 'com.example.demo' }, }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); @@ -1809,7 +1450,7 @@ test('runner session preflights with app_activation_uncertain on a differing bun test('runner session preflights with startup reason for the first command on a fresh session', async () => { const session = makeRunnerSession({ - ready: false, + state: 'starting', lastHealthyMutation: { atMs: Date.now(), appBundleId: 'com.example.demo' }, }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); @@ -1831,7 +1472,7 @@ test('runner session preflights with startup reason for the first command on a f test('runner session clears recency and marks the error when a skipped-preflight send fails', async () => { const session = makeRunnerSession({ - ready: true, + state: 'ready', lastHealthyMutation: { atMs: Date.now() - 1_000, appBundleId: 'com.example.demo' }, }); mockSendRunnerCommandOnce.mockRejectedValueOnce(new Error('fetch failed')); @@ -1861,7 +1502,7 @@ test('runner session clears recency and marks the error when a skipped-preflight test('runner session does not mark structured runner failures after a skip as skipped-preflight', async () => { const session = makeRunnerSession({ - ready: true, + state: 'ready', lastHealthyMutation: { atMs: Date.now() - 1_000, appBundleId: 'com.example.demo' }, }); mockSendRunnerCommandOnce.mockResolvedValueOnce( @@ -1892,7 +1533,7 @@ test('runner session does not mark structured runner failures after a skip as sk test('runner session clears recency when an allowlisted command returns XCTest recorded failure', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-skip-xctest-failure-sim' }; const session = await ensureRunnerSession(device, {}); - session.ready = true; + session.state = 'ready'; session.lastHealthyMutation = { atMs: Date.now() - 1_000, appBundleId: 'com.example.demo' }; mockWaitForRunner.mockClear(); mockSendRunnerCommandOnce.mockResolvedValueOnce( @@ -1919,13 +1560,13 @@ test('runner session clears recency when an allowlisted command returns XCTest r ); assert.equal(session.lastHealthyMutation, undefined); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); }); test('runner session invalidates when the runner reports abandoned main-thread work is wedged', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-wedged-sim' }; const session = await ensureRunnerSession(device, {}); - session.ready = true; + session.state = 'ready'; mockWaitForRunner.mockClear(); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); mockSendRunnerCommandOnce.mockResolvedValueOnce( @@ -1951,5 +1592,5 @@ test('runner session invalidates when the runner reports abandoned main-thread w }, ); - assert.equal(getRunnerSessionSnapshot(device.id), null); + assert.equal(readRunnerSessionLiveness(device.id), null); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts index ff9ce95f30..d067c120bd 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts @@ -143,7 +143,7 @@ test('waitForRunner wakes a simulator startup retry when the listener reports re const readiness = new AbortController(); const session: RunnerSession = { ...makeReadyRunnerSession(), - ready: false, + state: 'starting', startupRetryWake: readiness.signal, }; let fetchAttempts = 0; @@ -225,7 +225,7 @@ test('waitForRunner preserves xcodebuild diagnostics when the runner exits durin 'The application could not be launched because the Developer App Certificate is not trusted.', }), child: { pid: 1234, exitCode: null } as ExecBackgroundResult['child'], - ready: false, + state: 'starting', }; mockUsbmuxPostCommand.mockImplementation(async () => { (session.child as { exitCode: number | null }).exitCode = 65; @@ -311,6 +311,6 @@ function makeReadyRunnerSession(): RunnerSession { jsonPath: '/tmp/runner.json', testPromise: Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }), child: { pid: 1234, exitCode: null } as ExecBackgroundResult['child'], - ready: true, + state: 'ready', }; } diff --git a/packages/platform-apple/src/runner/client.ts b/packages/platform-apple/src/runner/client.ts index 6d7787bcac..31d02c73f9 100644 --- a/packages/platform-apple/src/runner/client.ts +++ b/packages/platform-apple/src/runner/client.ts @@ -18,7 +18,7 @@ import { runnerLeaseCleanupAdapter } from './runner-disposal.ts'; import { runApplePressSeries } from './runner-sequence.ts'; import { detachIosSimulatorRunnerSessionsForShutdown, - getRunnerSessionSnapshot, + readRunnerSessionLiveness, releaseIosRunnerOnClose, stopAllIosRunnerSessions, stopIosRunnerSession, @@ -43,7 +43,7 @@ export type AppleRunnerClient = { resolveRunnerAppBundleId: typeof resolveRunnerAppBundleId; hasCachedAppleRunnerArtifact: typeof hasCachedAppleRunnerArtifact; detachIosSimulatorRunnerSessionsForShutdown: typeof detachIosSimulatorRunnerSessionsForShutdown; - getRunnerSessionSnapshot: typeof getRunnerSessionSnapshot; + readRunnerSessionLiveness: typeof readRunnerSessionLiveness; releaseIosRunnerOnClose: typeof releaseIosRunnerOnClose; stopIosRunnerSession: typeof stopIosRunnerSession; stopAllIosRunnerSessions: typeof stopAllIosRunnerSessions; @@ -74,7 +74,7 @@ export function createAppleRunnerClient(host: AppleRunnerHost): AppleRunnerClien resolveRunnerAppBundleId, hasCachedAppleRunnerArtifact, detachIosSimulatorRunnerSessionsForShutdown, - getRunnerSessionSnapshot, + readRunnerSessionLiveness, releaseIosRunnerOnClose, stopIosRunnerSession, stopAllIosRunnerSessions, diff --git a/packages/platform-apple/src/runner/runner-adoption.ts b/packages/platform-apple/src/runner/runner-adoption.ts index 0b1cf03a83..5218123411 100644 --- a/packages/platform-apple/src/runner/runner-adoption.ts +++ b/packages/platform-apple/src/runner/runner-adoption.ts @@ -192,7 +192,7 @@ function buildAdoptedRunnerSession( testPromise: wait, child, // The probe already proved the runner answers commands. - ready: true, + state: 'ready', startupTimeoutMs: normalizeRunnerStartupTimeoutMs( requireRunnerPhaseRemainingMs(options.budget, 'runner_session_adoption'), ), diff --git a/packages/platform-apple/src/runner/runner-client.ts b/packages/platform-apple/src/runner/runner-client.ts index d4e2467e13..7056c5f971 100644 --- a/packages/platform-apple/src/runner/runner-client.ts +++ b/packages/platform-apple/src/runner/runner-client.ts @@ -2,7 +2,7 @@ import { retryWithPolicy, emitDiagnostic } from './host.ts'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { ensureRunnerSession, - getRunnerSessionSnapshot, + readRunnerSessionLiveness, stopIosRunnerSession, validateRunnerDevice, releaseSpeculativeIosRunnerSession, @@ -184,10 +184,11 @@ function resolveAppleRunnerRuntime( } /** - * Whether asking this device's runner now would be answered without a startup wait. A session - * that is registered but has not answered yet is still starting, so it does not count: sending it - * a command would queue behind its connection retries, and a failed reset would even invalidate it. - * Observation paths use this to stay runner-free until the runner is ready. + * Whether asking this device's runner now would be answered without a startup wait. Only a + * session in the `ready` state counts: one that is registered but has not answered yet is still + * starting, so sending it a command would queue behind its connection retries, and one already + * going away would be asked to work while it is being taken down. Observation paths use this to + * stay runner-free until the runner is ready. */ export function hasLiveIosRunnerSession( device: DeviceInfo, @@ -209,10 +210,7 @@ export async function releaseSpeculativeIosRunnerSessionFor( const LOCAL_APPLE_RUNNER_RUNTIME = createLocalAppleRunnerProvider(executeRunnerCommand, { prepare: prepareLocalIosRunner, - hasLiveSession: (device) => { - const session = getRunnerSessionSnapshot(device.id); - return session !== null && session.alive && session.ready; - }, + hasLiveSession: (device) => readRunnerSessionLiveness(device.id)?.liveness === 'ready', releaseSpeculativeSession: async (device) => await releaseSpeculativeIosRunnerSession(device.id), prewarm: async (device, options) => { const { healthCheck, ...runnerOptions } = options; diff --git a/packages/platform-apple/src/runner/runner-disposal.ts b/packages/platform-apple/src/runner/runner-disposal.ts index 20c3694713..24265f5154 100644 --- a/packages/platform-apple/src/runner/runner-disposal.ts +++ b/packages/platform-apple/src/runner/runner-disposal.ts @@ -21,7 +21,7 @@ import { type RunnerLeaseCleanupAdapter, } from './runner-lease.ts'; import { IOS_RUNNER_CONTAINER_BUNDLE_IDS, runnerPrepProcesses } from './runner-xctestrun.ts'; -import type { RunnerSession } from './runner-session-types.ts'; +import { advanceRunnerSessionState, type RunnerSession } from './runner-session-types.ts'; export const RUNNER_INVALIDATE_WAIT_TIMEOUT_MS = 1_000; @@ -53,6 +53,9 @@ export async function disposeRunnerSession( session: RunnerSession, options: RunnerDisposalOptions = {}, ): Promise { + // From here the session is going away: it still owns the lease and the process is still up, so + // it can answer a request, and it must not be chosen for new work while it is being taken down. + advanceRunnerSessionState(session, 'draining'); let processExitHandled = false; if (options.graceful !== false) { processExitHandled = await shutdownRunnerSessionGracefully(session); @@ -83,6 +86,9 @@ export async function abortRunnerSessionsAndPrepProcesses( const prepProcesses = Array.from(runnerPrepProcesses); const macOsSessions = activeSessions.filter((session) => isMacOs(session.device)); const otherSessions = activeSessions.filter((session) => !isMacOs(session.device)); + for (const session of activeSessions) { + advanceRunnerSessionState(session, 'draining'); + } await signalRunnerSessions(otherSessions, 'SIGINT'); await signalRunnerPrepProcesses(prepProcesses, 'SIGINT'); await signalRunnerSessions(otherSessions, 'SIGTERM'); @@ -162,19 +168,23 @@ async function interruptMacOsRunnerSessions(sessions: readonly RunnerSession[]): // restores it during xcodebuild teardown. Give SIGINT time to complete that teardown before // escalating; revisit only if XCTest exposes a separate public cleanup acknowledgement. await signalRunnerSessions(sessions, 'SIGINT'); - const afterInterrupt = await runnerSessionsStillAlive( + const afterInterrupt = await runnerSessionsWithRunningProcesses( sessions, MACOS_RUNNER_INTERRUPT_WAIT_TIMEOUT_MS, ); await signalRunnerSessions(afterInterrupt, 'SIGTERM'); - const afterTerm = await runnerSessionsStillAlive( + const afterTerm = await runnerSessionsWithRunningProcesses( afterInterrupt, MACOS_RUNNER_TERM_WAIT_TIMEOUT_MS, ); await signalRunnerSessions(afterTerm, 'SIGKILL'); } -async function runnerSessionsStillAlive( +/** + * The sessions whose runner process is still there after each waited up to `waitTimeoutMs` for its + * exit — the escalation step signals exactly these. + */ +async function runnerSessionsWithRunningProcesses( sessions: readonly RunnerSession[], waitTimeoutMs: number, ): Promise { @@ -192,6 +202,8 @@ async function cleanupRunnerSessionResources( cleanupTempFile(session.xctestrunPath); cleanupTempFile(session.jsonPath); await session.simulatorSetRedirect?.releaseBestEffort(); + // The session's own resources are gone: no lease, no temp files, no redirect to settle. + advanceRunnerSessionState(session, 'stopped'); } /** diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 3c0c309f75..a99316d767 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -7,11 +7,11 @@ import { type RunnerSession, assertExpectedRunnerSession, ensureRunnerSession, - getRunnerSessionSnapshot, invalidateRunnerSession, executeRunnerCommandWithSession, readRunnerStartupTimeoutMs, markRunnerSessionServed, + readRunnerSessionLiveness, } from './runner-session.ts'; import { assertRunnerRequestActive, @@ -263,10 +263,16 @@ export async function executeRunnerCommand( let session: RunnerSession | undefined; let recycleBootBegun = false; try { - // A request that already used a runner session and finds none alive is about to pay for - // a recycle boot (~25s): bound that to the per-request recycle budget so a hostile screen + // A request that already used a runner session and finds no runner process is about to pay + // for a recycle boot (~25s): bound that to the per-request recycle budget so a hostile screen // fails fast with a preserved session instead of stacking runner boots (#1105). - if (!getRunnerSessionSnapshot(device.id)?.alive && hasRunnerRequestTouchedSession(recycleKey)) { + // `gone` and `stopped` are the two liveness answers that mean no runner is answering now, so + // this command is the one that would start a process (#2662). + const liveness = readRunnerSessionLiveness(device.id)?.liveness ?? 'gone'; + if ( + (liveness === 'gone' || liveness === 'stopped') && + hasRunnerRequestTouchedSession(recycleKey) + ) { if (!tryBeginRunnerRecycle(recycleKey)) { throw buildRunnerRecycleBudgetExhaustedError(command, options); } @@ -279,9 +285,8 @@ export async function executeRunnerCommand( commitRunnerRecycle(recycleKey); } markRunnerRequestTouchedSession(recycleKey); - const timeoutMs = session.ready - ? RUNNER_COMMAND_TIMEOUT_MS - : readRunnerStartupTimeoutMs(session); + const timeoutMs = + session.state === 'ready' ? RUNNER_COMMAND_TIMEOUT_MS : readRunnerStartupTimeoutMs(session); return await executeRunnerCommandWithSession( device, session, @@ -293,7 +298,7 @@ export async function executeRunnerCommand( } catch (error) { if (options.expectedRunnerSessionId !== undefined) throw error; const appErr = asAppError(error, 'COMMAND_FAILED'); - if (session && !session.ready && isRequestCanceledError(appErr)) { + if (session && session.state === 'starting' && isRequestCanceledError(appErr)) { await invalidateRunnerSessionBestEffort(session, 'runner_startup_request_canceled'); throw error; } diff --git a/packages/platform-apple/src/runner/runner-session-types.ts b/packages/platform-apple/src/runner/runner-session-types.ts index 86a1f91ad8..74673cf2b4 100644 --- a/packages/platform-apple/src/runner/runner-session-types.ts +++ b/packages/platform-apple/src/runner/runner-session-types.ts @@ -5,6 +5,41 @@ import type { RunnerXctestrunArtifact } from './runner-xctestrun.ts'; import type { RunnerLease } from './runner-lease.ts'; import type { XcodebuildSimulatorSetRedirectHandle } from './runner-device-set.ts'; +/** + * Where one runner process stands in the lifecycle of the session that owns it (#2662). The state + * records what the runner has proved about itself. Whether its process is still there is a + * separate fact, answered only by the `isProcessAlive` probe on the runner host; no state here + * claims that fact. + * + * - `starting`: registered and launched, no runner response read yet. A command sent now queues + * behind the connection probe, so the session cannot be used. + * - `ready`: the runner answered a command, so its HTTP channel works. The process can still die + * afterwards, which is why usability is read separately. + * - `draining`: disposal began. The lease is still held and the process is still being signalled, + * so the runner can still answer, but the session must not be chosen for new work. + * - `stopped`: this session's disposable resources settled, or its ownership was handed to + * another daemon. Nothing is routed to it again, and a `stopped` session is never revived. + */ +export type RunnerSessionState = 'starting' | 'ready' | 'draining' | 'stopped'; + +/** The part of a runner session that its lifecycle state machine reads and writes. */ +export type RunnerSessionStateHolder = { state: RunnerSessionState }; + +/** + * A device's runner across both axes at once (#2662): `starting`, `draining` and `stopped` are the + * session's own {@link RunnerSessionState}, `gone` is the answer when nothing is registered for the + * device at all, and `ready` is the only state where a command issued now is answered without + * waiting on a startup. Three reads used to answer this question by combining a boolean and a + * process probe three different ways; this is the one type that combines them. + */ +export type RunnerSessionLiveness = RunnerSessionState | 'gone'; + +/** What is registered for a device, read through the lifecycle lens. */ +export type RunnerSessionRegistration = Readonly<{ + sessionId: string; + liveness: RunnerSessionLiveness; +}>; + // The runner process seen through the session: pid for liveness/kill-tree and // exitCode for early-exit detection. A spawned ChildProcess satisfies this // structurally; adopted runners (whose spawner died) provide a pid-backed @@ -24,7 +59,8 @@ export type RunnerSession = { jsonPath: string; testPromise: Promise; child: RunnerProcessHandle; - ready: boolean; + /** Moves only through {@link advanceRunnerSessionState}. */ + state: RunnerSessionState; /** Wakes one startup retry when the listener becomes ready or its process exits. */ startupRetryWake?: AbortSignal; startupTimeoutMs?: number; @@ -55,6 +91,50 @@ export type RunnerSession = { lease?: RunnerLease; }; +// A session goes forward through the lifecycle once: a state already reached is never re-entered, +// so a runner cannot be revived by a late answer and a finished teardown never runs again. Both +// live states reach `stopped` without a `draining` step on the graceful-shutdown handoff, where +// the runner keeps serving the next daemon and this session's ownership simply ends. That handoff +// covers a session that has not answered yet: a daemon shutting down during a startup has a +// running runner and a valid lease to hand off exactly as it does for an answered one. +const RUNNER_SESSION_STATE_SUCCESSORS: Record = { + starting: ['ready', 'draining', 'stopped'], + ready: ['draining', 'stopped'], + draining: ['stopped'], + stopped: [], +}; + +/** + * The one place a runner session's state changes: publishing a first answer, disposal starting, + * and disposal or handoff finishing. A state the session already passed is never written again, so + * an answer arriving after disposal began cannot make the session usable. + */ +export function advanceRunnerSessionState( + session: RunnerSessionStateHolder, + next: RunnerSessionState, +): void { + if (!RUNNER_SESSION_STATE_SUCCESSORS[session.state].includes(next)) return; + session.state = next; +} + +/** + * Whether this session still owns a runner worth waiting on: no teardown has begun for it, so a + * stop or invalidate asked of it is real work and not a repeat of one already finished. + */ +export function canWorkWithRunnerSession(session: RunnerSessionStateHolder): boolean { + return session.state === 'starting' || session.state === 'ready'; +} + +/** The liveness of a registered session, read against the process probe held beside its state. */ +export function resolveRunnerSessionLiveness( + session: Readonly<{ state: RunnerSessionState; processRunning: boolean }>, +): RunnerSessionLiveness { + // A session that died before its runner answered, or whose runner died while it stayed + // registered, is over either way: the next command finds that by liveness and starts a runner. + if (!session.processRunning) return 'gone'; + return session.state; +} + export function buildRunnerSessionId(deviceId: string, port: number): string { return `${deviceId}:${port}:${Date.now()}`; } diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index 8b47a5bfb4..1669c941c6 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -65,9 +65,14 @@ import { } from './runner-disposal.ts'; import { enrichRunnerFailureFromLog } from './runner-failure-diagnostics.ts'; import { + advanceRunnerSessionState, buildRunnerSessionId, + canWorkWithRunnerSession, normalizeRunnerStartupTimeoutMs, + resolveRunnerSessionLiveness, type RunnerSession, + type RunnerSessionLiveness, + type RunnerSessionRegistration, } from './runner-session-types.ts'; import { launchRunnerProcess, type LaunchedRunnerProcess } from './runner-process-launch.ts'; @@ -273,7 +278,7 @@ async function startRunnerSessionWithLease( jsonPath, testPromise: runnerProcess.wait, child: runnerProcess.child, - ready: false, + state: 'starting', startupRetryWake: runnerProcess.startupRetryWake, startupTimeoutMs: normalizeRunnerStartupTimeoutMs(startupTimeoutMs), startupTimings, @@ -330,7 +335,8 @@ async function resolveReusableRunnerSession( existing: RunnerSession, startupBudget: RunnerPhaseBudget, ): Promise { - if (!isRunnerProcessAlive(existing.child.pid)) { + const liveness = readRunnerSessionLivenessFor(existing); + if (liveness === 'gone') { await measureRunnerStartupStep({}, 'stop_stale_session', async () => { await stopRunnerSessionInternal(device.id, existing, { graceful: false, @@ -339,6 +345,9 @@ async function resolveReusableRunnerSession( }); return null; } + // A registered session already being taken down or already handed off is not usable, even when + // its runner process is still there for a moment while disposal works. + if (liveness !== 'starting' && liveness !== 'ready') return null; const existingArtifact = existing.xctestrunArtifact; if (existingArtifact?.cache === 'external') { @@ -348,7 +357,7 @@ async function resolveReusableRunnerSession( data: { deviceId: device.id, sessionId: existing.sessionId, - ready: existing.ready, + ready: existing.state === 'ready', cache: existingArtifact.cache, logicalLeaseContext: existing.logicalLeaseContext, }, @@ -383,7 +392,7 @@ async function resolveReusableRunnerSession( data: { deviceId: device.id, sessionId: existing.sessionId, - ready: existing.ready, + ready: existing.state === 'ready', logicalLeaseContext: existing.logicalLeaseContext, }, }); @@ -442,25 +451,36 @@ function isBenignSimulatorRunnerUninstallResult(result: ExecResult): boolean { ); } -export function getRunnerSessionSnapshot( - deviceId: string, -): { sessionId: string; alive: boolean; ready: boolean } | null { +/** + * The one reader of what is registered for a device: the session's state plus the one fact the + * session cannot know itself — whether its runner process is still there. `null` means nothing is + * registered, which is its own answer: there is no session to wait for or tear down. + */ +export function readRunnerSessionLiveness(deviceId: string): RunnerSessionRegistration | null { const session = runnerSessions.get(deviceId); if (!session) return null; return { sessionId: session.sessionId, - alive: isRunnerProcessAlive(session.child.pid), - // A registered session whose runner has not answered yet is still starting. - ready: session.ready, + liveness: readRunnerSessionLivenessFor(session), }; } +function readRunnerSessionLivenessFor(session: RunnerSession): RunnerSessionLiveness { + return resolveRunnerSessionLiveness({ + state: session.state, + processRunning: isRunnerProcessAlive(session.child.pid), + }); +} + export async function invalidateRunnerSession( session: RunnerSession, reason: string, ): Promise { await withRunnerSessionLock(session.deviceId, async () => { if (runnerSessions.get(session.deviceId) !== session) return; + // A session already being torn down, or already torn down, is never disposed a second time + // for a later reason; the reason-coded diagnostic below reports why this call was made. + if (!canWorkWithRunnerSession(session)) return; emitDiagnostic({ level: 'warn', phase: 'ios_runner_session_invalidated', @@ -484,6 +504,10 @@ async function stopRunnerSessionInternal( ): Promise { const session = sessionOverride ?? runnerSessions.get(deviceId); if (!session) return; + // Once disposal has begun or finished, this session has no runner to wait on; a repeat stop + // would only re-signal a process that is already leaving and re-emit a teardown for a reason + // that has nothing left to tear down. + if (!canWorkWithRunnerSession(session)) return; await disposeRunnerSession(session, options); if (runnerSessions.get(deviceId) === session) { runnerSessions.delete(deviceId); @@ -566,7 +590,11 @@ export async function releaseSpeculativeIosRunnerSession(deviceId: string): Prom emitDiagnostic({ level: 'debug', phase: 'ios_runner_speculative_released', - data: { deviceId, sessionId: session.sessionId, ready: session.ready }, + data: { + deviceId, + sessionId: session.sessionId, + ready: session.state === 'ready', + }, }); await stopIosRunnerSession(deviceId); return true; @@ -637,6 +665,7 @@ export async function detachIosSimulatorRunnerSessionsForShutdown(): Promise; }): Promise { const { device, session, runnerCommand, logPath, deadline, signal, decision } = params; - const readinessTimeoutMs = session.ready - ? Math.min(RUNNER_READY_PREFLIGHT_TIMEOUT_MS, deadline.remainingMs()) - : Math.min(readRunnerStartupTimeoutMs(session), deadline.remainingMs()); + const readinessTimeoutMs = + session.state === 'ready' + ? Math.min(RUNNER_READY_PREFLIGHT_TIMEOUT_MS, deadline.remainingMs()) + : Math.min(readRunnerStartupTimeoutMs(session), deadline.remainingMs()); try { const readinessResponse = await withDiagnosticTimer( 'ios_runner_readiness_preflight', @@ -918,7 +949,7 @@ async function runRunnerReadinessPreflight(params: { commandId: runnerCommand.commandId, reason: decision.reason, lastHealthyMutationAgeMs: decision.lastHealthyMutationAgeMs, - sessionReady: session.ready, + sessionReady: session.state === 'ready', timeoutMs: readinessTimeoutMs, }, ); @@ -944,14 +975,20 @@ function emitRunnerReadinessPreflightSkipped( decision.reason === 'recent_healthy_mutation' ? decision.lastHealthyMutationAgeMs : undefined, - sessionReady: session.ready, + sessionReady: session.state === 'ready', }, }); } +/** + * Reads one runner response body and records what it proved about the session (#2662). Only a + * session waiting for its first answer changes: the runner replied, so it is `ready`. A session + * already going away keeps its state — an answer arriving after disposal started comes from a + * runner on its way out, not from a session that can take work. + */ export async function parseRunnerResponse( response: Response, - session: Pick, + session: Pick, logPath?: string, ): Promise> { const payload = decodeRunnerResponseBody(await response.text()); @@ -961,7 +998,7 @@ export async function parseRunnerResponse( logPath, }); } - session.ready = true; + advanceRunnerSessionState(session, 'ready'); const data = readRunnerResponseData(payload); emitRunnerResponseDiagnostics(data); return data; @@ -997,7 +1034,7 @@ function resolveRunnerReadinessPreflightDecision( if (isRunnerReadinessPreflightExempt(command)) { return { action: 'skip', reason: 'preflight_exempt_command' }; } - if (!session.ready) { + if (session.state !== 'ready') { if (readOnlyCommand) { return { action: 'skip', @@ -1115,7 +1152,7 @@ function emitRunnerStartupTimings(session: RunnerSession, command: string): void data: { command, sessionId: session.sessionId, - ready: session.ready, + ready: session.state === 'ready', logicalLeaseContext: session.logicalLeaseContext, timings: session.startupTimings, }, diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index b80643c943..d51b9f3708 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -237,7 +237,7 @@ async function tryReadySimulatorEndpoint(params: { attemptDeadline?: Deadline; setLastError: (error: unknown) => void; }): Promise { - if (params.device.kind !== 'simulator' || !params.session?.ready) return null; + if (params.device.kind !== 'simulator' || params.session?.state !== 'ready') return null; return await tryRunnerSimulatorEndpoint(params.device, params.port, params.command, { signal: params.signal, attemptDeadline: params.attemptDeadline, diff --git a/packages/platform-apple/src/session-observation.ts b/packages/platform-apple/src/session-observation.ts index 07fda89708..d050347fb6 100644 --- a/packages/platform-apple/src/session-observation.ts +++ b/packages/platform-apple/src/session-observation.ts @@ -8,9 +8,13 @@ export function createAppleSessionObservation( ): AppleSessionObservation { return Object.freeze({ async observeRunnerSession(deviceId) { - const { getRunnerSessionSnapshot } = await import('./core/runner-client.ts'); - const snapshot = await getRunnerSessionSnapshot(deviceId); - return snapshot ? { alive: snapshot.alive, sessionId: snapshot.sessionId } : undefined; + const { readRunnerSessionLiveness } = await import('./core/runner-client.ts'); + const snapshot = readRunnerSessionLiveness(deviceId); + // `alive` keeps its contract meaning: a registered session whose runner is going away is + // still a runner the caller can find, and only `gone` means there is none. + return snapshot + ? { alive: snapshot.liveness !== 'gone', sessionId: snapshot.sessionId } + : undefined; }, async resolveSoleForegroundApp(options = {}) { try { diff --git a/src/platform-runtime-screen-recording-apple-runner-transport.test.ts b/src/platform-runtime-screen-recording-apple-runner-transport.test.ts index fa5edaecd0..03cae910a5 100644 --- a/src/platform-runtime-screen-recording-apple-runner-transport.test.ts +++ b/src/platform-runtime-screen-recording-apple-runner-transport.test.ts @@ -11,7 +11,7 @@ const runner = vi.hoisted(() => ({ vi.mock('@agent-device/platform-apple/runner/operations', () => ({ runAppleRunnerCommand: runner.run, - getRunnerSessionSnapshot: runner.snapshot, + readRunnerSessionLiveness: runner.snapshot, })); const device = { @@ -51,7 +51,7 @@ test('scopes an unavailable runner authority instead of falling back to a local }); test('passes the recorded session identity into the runner stop dispatch boundary', async () => { - runner.snapshot.mockReturnValue({ sessionId: 'runner-session-1', alive: true }); + runner.snapshot.mockReturnValue({ sessionId: 'runner-session-1', liveness: 'ready' }); runner.run.mockResolvedValue({}); const transport = resolveAppleRunnerScreenRecordingTransport(); @@ -70,7 +70,7 @@ test('cancellation after runner acquisition stops only the acquired session', as runner.run.mockResolvedValue({}); runner.snapshot.mockImplementation(() => { controller.abort(reason); - return { sessionId: 'runner-session-2', alive: true }; + return { sessionId: 'runner-session-2', liveness: 'ready' }; }); const transport = resolveAppleRunnerScreenRecordingTransport(); @@ -107,7 +107,7 @@ test('does not issue an unowned stop when runner acquisition exposes no session }); test('keeps macOS runner recording ownership local to the requested output path', async () => { - runner.snapshot.mockReturnValue({ sessionId: 'runner-session-1', alive: true }); + runner.snapshot.mockReturnValue({ sessionId: 'runner-session-1', liveness: 'ready' }); runner.run.mockResolvedValue({ recorderStartUptimeMs: 42 }); const transport = resolveAppleRunnerScreenRecordingTransport(); diff --git a/src/platform-runtime-screen-recording-apple-runner-transport.ts b/src/platform-runtime-screen-recording-apple-runner-transport.ts index 767ad0dbff..02fc05be75 100644 --- a/src/platform-runtime-screen-recording-apple-runner-transport.ts +++ b/src/platform-runtime-screen-recording-apple-runner-transport.ts @@ -58,7 +58,7 @@ async function startLocalAppleRunnerRecording({ fps, signal, }: AppleRunnerScreenRecordingStartRequest): Promise { - const { getRunnerSessionSnapshot, runAppleRunnerCommand } = + const { readRunnerSessionLiveness, runAppleRunnerCommand } = await import('@agent-device/platform-apple/runner/operations'); const recordingFileName = `agent-device-recording-${Date.now()}.mp4`; const remotePath = @@ -73,8 +73,8 @@ async function startLocalAppleRunnerRecording({ }, { signal }, ); - const session = await getRunnerSessionSnapshot(device.id); - if (!session?.alive) { + const session = readRunnerSessionLiveness(device.id); + if (!session || session.liveness !== 'ready') { throw new Error('Apple runner recording did not expose a durable runner session identity'); } try { @@ -143,11 +143,11 @@ async function inspectLocalRunner( device: DeviceInfo, runnerSessionId: string, ): Promise { - const { getRunnerSessionSnapshot, readStaleRunnerLease, verifyLeaseRunnerPidIdentity } = + const { readRunnerSessionLiveness, readStaleRunnerLease, verifyLeaseRunnerPidIdentity } = await import('@agent-device/platform-apple/runner/operations'); - const active = await getRunnerSessionSnapshot(device.id); + const active = readRunnerSessionLiveness(device.id); if (active) { - if (!active.alive) return 'missing'; + if (active.liveness === 'gone') return 'missing'; return active.sessionId === runnerSessionId ? 'owned-alive' : 'ownership-lost'; } const lease = await readStaleRunnerLease(device.id);