diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift index deb1bf6b20..c4b99a1e7d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift @@ -82,6 +82,25 @@ extension RunnerTests { RunnerSnapshotBridgeDrainForTest() let admitted = RunnerSnapshotBridgeReadForTest(request, 1000, { ["ok": true] }, 16) XCTAssertEqual(admitted["ok"] as? Bool, true) + + let started = DispatchSemaphore(value: 0) + let secondRelease = DispatchSemaphore(value: 0) + let finished = expectation(description: "second read outlives its budget") + DispatchQueue.global().async { + let second = RunnerSnapshotBridgeReadForTest(request, 1000, { + started.signal() + secondRelease.wait() + return ["ok": true] + }, 100) + XCTAssertEqual(second["error_code"] as? String, "bridge-read-abandoned") + finished.fulfill() + } + started.wait() + let secondBusy = RunnerSnapshotBridgeReadForTest(request, 100, { ["ok": true] }, 100) + XCTAssertEqual(secondBusy["error_code"] as? String, "bridge-read-in-flight") + wait(for: [finished], timeout: 5) + secondRelease.signal() + RunnerSnapshotBridgeDrainForTest() } func testReaderDoesNotRefuseTheBoundsOfAFirstRead() throws { diff --git a/packages/daemon/src/sessions/handlers/lease.ts b/packages/daemon/src/sessions/handlers/lease.ts index c2a6bf4912..0b52d3dbb3 100644 --- a/packages/daemon/src/sessions/handlers/lease.ts +++ b/packages/daemon/src/sessions/handlers/lease.ts @@ -335,7 +335,7 @@ async function heartbeatLease( phase: 'lease_provider_ended_session_teardown_failed', data: { leaseId: lease.leaseId, - error: error instanceof Error ? error.message : String(error), + error: errorMessage(error), }, }); } diff --git a/packages/platform-apple/src/runner-snapshot-bridge.test.ts b/packages/platform-apple/src/runner-snapshot-bridge.test.ts index d4ef0b16b8..956037b126 100644 --- a/packages/platform-apple/src/runner-snapshot-bridge.test.ts +++ b/packages/platform-apple/src/runner-snapshot-bridge.test.ts @@ -39,13 +39,15 @@ type RunnerProcess = { pid: number; processStartTime: string }; * foreground process, and `describe` reads it unless the host planned another process, or answers * the refusal `refuseDescribe` names. A describe given `holdNextDescribe` reads until that promise * settles, and, like the runner, it keeps reading after the daemon stops waiting: a describe that - * arrives meanwhile is refused as `bridge-read-in-flight`. A describe that names surface hosts is - * refused as `system-surface-host-unverified`, as a sandbox that refuses the process table does. + * arrives meanwhile is refused as `bridge-read-in-flight`. With `refuseSurfaceHostCheck`, a describe that + * names surface hosts is refused as `system-surface-host-unverified`, as a sandbox that refuses the + * process table does. */ function bridgeRunner(state: { process: RunnerProcess; refuseDescribe?: string; refuseTarget?: string; + refuseSurfaceHostCheck?: boolean; holdNextDescribe?: Promise; }) { const sent: RunnerCommand[] = []; @@ -86,7 +88,7 @@ function bridgeRunner(state: { ) { return refusal('stale_target', 'target-process-changed'); } - if (request.surfaceHostExecutables) + if (request.surfaceHostExecutables && state.refuseSurfaceHostCheck) return refusal('unsupported', 'system-surface-host-unverified'); if (state.refuseDescribe) return refusal('application_not_responding', state.refuseDescribe); if (reading) return refusal('application_not_responding', 'bridge-read-in-flight'); @@ -142,7 +144,7 @@ function verbs(sent: readonly RunnerCommand[]): string[] { return sent.map((command) => command.snapshotBridge?.verb ?? command.command); } -/** The bridge route of a runner whose foreground read proves no system surface covers the app. */ +/** The bridge route of a runner with an optional foreground session. */ function bridgeRoute(provider: AppleRunnerProvider, runnerSessionId?: string) { const bridge = createRunnerSnapshotBridge({ device: IOS_SIMULATOR, provider }); const presented: SnapshotRuntimeAcquiredResult[] = []; @@ -165,7 +167,9 @@ function bridgeRoute(provider: AppleRunnerProvider, runnerSessionId?: string) { ...(runnerSessionId ? { sessionChanged: bridge.resetTargets } : {}), }, async () => - runnerSessionId ? { foreground: 'background', sessionId: runnerSessionId } : 'background', + runnerSessionId + ? { foreground: 'background', sessionId: runnerSessionId } + : { foreground: 'unknown' }, ); const snapshot = (signal = new AbortController().signal) => route.capture( @@ -427,6 +431,7 @@ test('the runner runtime reads a remote Simulator through the bridge once its fo test('a runner whose foreground read cannot answer asks the bridge read to check the surface hosts', async () => { const { provider, sent } = bridgeRunner({ + refuseSurfaceHostCheck: true, process: { pid: 41, processStartTime: '1700000000.000001' }, }); const { snapshot, fallbacks } = runtimeSnapshots(withHostStates(provider, undefined)); @@ -478,6 +483,7 @@ test('the foreground proof starts the runner when none is live, then reads only test('a runner whose session id cannot be read answers the foreground proof as unknown', async () => { registry.session = undefined; const { provider, sent } = bridgeRunner({ + refuseSurfaceHostCheck: true, process: { pid: 41, processStartTime: '1700000000.000001' }, }); const { snapshot, fallbacks } = runtimeSnapshots(withHostStates(provider, 'notRunning')); @@ -650,7 +656,7 @@ test('a wedge before the runner id is known does not disable a newly identified targetFreshness: 'cached', sessionChanged: bridge.resetTargets, }, - async () => (sessionId ? { foreground: 'background', sessionId } : 'background'), + async () => (sessionId ? { foreground: 'background', sessionId } : { foreground: 'unknown' }), ); const snapshot = () => route.capture( diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index 33f02b7255..4fc6392792 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -132,11 +132,12 @@ test.for([ async ({ runner, host, acquired, checked, served }) => { const source = sourceReturning(host === 'present' ? surfaceHostPresent : bridgeAcquisition()); const fallback = vi.fn(async () => runnerResult()); - const systemSurfaceForeground = vi.fn(async () => - runner === 'foreground' - ? ({ kind: 'foreground', host: presentSurface.host } as const) - : runner, - ); + const systemSurfaceForeground = vi.fn(async () => ({ + foreground: + runner === 'foreground' + ? ({ kind: 'foreground', host: presentSurface.host } as const) + : runner, + })); const route = createAppleSnapshotRoute(bridgePresentingHost(), { source, resolveTarget: vi.fn(async () => target), diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index edc14c24be..26529ab7d6 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -211,10 +211,7 @@ function createSnapshotBridgeRouteCore probeResult: Awaited>, device: DeviceInfo, ): typeof currentCircuit => { - const probedSessionId = - channel.sessionChanged && typeof probeResult === 'object' && 'sessionId' in probeResult - ? probeResult.sessionId - : undefined; + const probedSessionId = channel.sessionChanged ? probeResult.sessionId : undefined; if (probedSessionId === undefined) return startingCircuit; if (currentCircuit !== startingCircuit) { return probedSessionId === currentCircuit.sessionId ? currentCircuit : startingCircuit; @@ -264,10 +261,7 @@ function createSnapshotBridgeRouteCore const startingCircuit = currentCircuit; const probeResult = await readSurfaceForeground(device, input, signal, surfaceForeground); const circuit = circuitForProbe(startingCircuit, probeResult, device); - const foreground = - typeof probeResult === 'object' && 'sessionId' in probeResult - ? probeResult.foreground - : probeResult; + const { foreground } = probeResult; const captureBridgeDisabled = (target: SnapshotBridgeRouteTarget) => isDisabledIn(circuit, target); if (foreground !== 'background' && foreground !== 'unknown') { @@ -460,12 +454,7 @@ async function readSurfaceForeground( ): Promise>> { const requestId = input.execution?.requestId; const foreground = await probe(device, { signal, ...(requestId ? { requestId } : {}) }); - if ( - foreground === 'background' || - (typeof foreground === 'object' && - 'sessionId' in foreground && - foreground.foreground === 'background') - ) { + if (foreground.foreground === 'background') { emitDiagnostic({ level: 'debug', phase: 'ios_snapshot_route_surface_host_background', diff --git a/packages/platform-apple/src/system-surface-foreground.test.ts b/packages/platform-apple/src/system-surface-foreground.test.ts index c1868e0a9f..c69106855a 100644 --- a/packages/platform-apple/src/system-surface-foreground.test.ts +++ b/packages/platform-apple/src/system-surface-foreground.test.ts @@ -96,7 +96,7 @@ test.for([ ] as const)('%s is unknown and sends no command', async ([, liveness]) => { runner.readRunnerSessionLiveness.mockReturnValue(liveness); - await expect(probe()).resolves.toBe('unknown'); + await expect(probe()).resolves.toEqual({ foreground: 'unknown' }); expect(runner.runAppleRunnerCommand).not.toHaveBeenCalled(); }); diff --git a/packages/platform-apple/src/system-surface-foreground.ts b/packages/platform-apple/src/system-surface-foreground.ts index 7b7bd85a66..c0d8543b8b 100644 --- a/packages/platform-apple/src/system-surface-foreground.ts +++ b/packages/platform-apple/src/system-surface-foreground.ts @@ -26,9 +26,7 @@ export type SystemSurfaceForeground = export type SystemSurfaceForegroundProbe = ( device: DeviceInfo, input: Readonly<{ signal: AbortSignal; requestId?: string }>, -) => Promise< - SystemSurfaceForeground | Readonly<{ foreground: SystemSurfaceForeground; sessionId: string }> ->; +) => Promise>; /** * Bounds each read below the read-only resend window, so a runner busy draining abandoned XCTest @@ -65,7 +63,7 @@ export function createSystemSurfaceForegroundProbe( ): SystemSurfaceForegroundProbe { return async (device, input) => { const session = await readySession(device, input); - if (!session) return 'unknown'; + if (!session) return { foreground: 'unknown' }; // The reads go out together: each one is a runner round trip, which costs a network hop to a // remote Simulator. The answer reads them in registry order, so a decisive state settles it // without waiting for the hosts after it.