diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift index c4b99a1e7d..97d8aa9716 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift @@ -83,9 +83,11 @@ extension RunnerTests { let admitted = RunnerSnapshotBridgeReadForTest(request, 1000, { ["ok": true] }, 16) XCTAssertEqual(admitted["ok"] as? Bool, true) + // A read admitted after an abandoned one must not inherit the old abandon stamp (10): checked at + // 100, while this read is still inside its budget, a stale stamp would answer bridge-reader-wedged. let started = DispatchSemaphore(value: 0) let secondRelease = DispatchSemaphore(value: 0) - let finished = expectation(description: "second read outlives its budget") + let secondAbandoned = expectation(description: "the second read is abandoned at its budget") DispatchQueue.global().async { let second = RunnerSnapshotBridgeReadForTest(request, 1000, { started.signal() @@ -93,12 +95,12 @@ extension RunnerTests { return ["ok": true] }, 100) XCTAssertEqual(second["error_code"] as? String, "bridge-read-abandoned") - finished.fulfill() + secondAbandoned.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) + wait(for: [secondAbandoned], timeout: 5) secondRelease.signal() RunnerSnapshotBridgeDrainForTest() } diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index 26529ab7d6..c15957224d 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -41,6 +41,7 @@ import { SnapshotSourceError } from './snapshot-source/errors.ts'; import { createSystemSurfaceForegroundProbe, type SystemSurfaceForegroundProbe, + type SystemSurfaceForegroundReading, } from './system-surface-foreground.ts'; import { bridgeFailureDisposition, @@ -208,7 +209,7 @@ function createSnapshotBridgeRouteCore */ const circuitForProbe = ( startingCircuit: typeof currentCircuit, - probeResult: Awaited>, + probeResult: SystemSurfaceForegroundReading, device: DeviceInfo, ): typeof currentCircuit => { const probedSessionId = channel.sessionChanged ? probeResult.sessionId : undefined; @@ -451,7 +452,7 @@ async function readSurfaceForeground( input: CaptureSnapshotInput, signal: AbortSignal, probe: SystemSurfaceForegroundProbe, -): Promise>> { +): Promise { const requestId = input.execution?.requestId; const foreground = await probe(device, { signal, ...(requestId ? { requestId } : {}) }); if (foreground.foreground === 'background') { diff --git a/packages/platform-apple/src/system-surface-foreground.ts b/packages/platform-apple/src/system-surface-foreground.ts index c0d8543b8b..d9bf0d79a5 100644 --- a/packages/platform-apple/src/system-surface-foreground.ts +++ b/packages/platform-apple/src/system-surface-foreground.ts @@ -23,10 +23,16 @@ export type SystemSurfaceForeground = | 'background' | 'unknown'; +/** One foreground read, with the runner session it was read through when that session is known. */ +export type SystemSurfaceForegroundReading = Readonly<{ + foreground: SystemSurfaceForeground; + sessionId?: string; +}>; + export type SystemSurfaceForegroundProbe = ( device: DeviceInfo, input: Readonly<{ signal: AbortSignal; requestId?: string }>, -) => Promise>; +) => Promise; /** * Bounds each read below the read-only resend window, so a runner busy draining abandoned XCTest