Skip to content

Commit fd524da

Browse files
committed
fix(ios-runner): answer main-thread work that finished at the timeout boundary
runMainThreadWork threw MAIN_THREAD_TIMEOUT whenever its wait timed out, even when the main-queue block finished between the wait and the lock and had already stored its result. A tap that happened was then reported as a timeout, and a daemon retry could repeat the gesture. Work that is finished under the lock now returns or rethrows its stored result like work that finished in time, and is not counted as abandoned. Only work still unfinished under the lock is abandoned and throws the timeout error. No timeout value changes. Closes #2782
1 parent b3b58ca commit fd524da

2 files changed

Lines changed: 75 additions & 1 deletion

File tree

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+MainThreadWork.swift‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ extension RunnerTests {
9393
}
9494
let waitResult = semaphore.wait(timeout: .now() + timeout)
9595
if waitResult == .timedOut {
96+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
97+
mainThreadWorkTimedOutForTesting?()
98+
#endif
99+
// Work that finished before the lock was taken already stored its result: it is answered
100+
// like work that finished in time, so an action that happened is never reported as a timeout.
96101
mainThreadWorkLock.lock()
97102
let abandoned = !workState.finished
98103
if abandoned {
@@ -110,8 +115,8 @@ extension RunnerTests {
110115
timeout
111116
)
112117
onAbandoned?()
118+
throw timeoutError()
113119
}
114-
throw timeoutError()
115120
}
116121
switch result {
117122
case .success(let value):
@@ -225,5 +230,71 @@ extension RunnerTests {
225230
return XCTFail("expected the runner idle once the abandoned work drained")
226231
}
227232
}
233+
234+
func testRunMainThreadWorkReturnsWorkThatFinishedAtTheTimeoutBoundary() {
235+
let outcome = runMainThreadWorkFinishingAtTheTimeoutBoundary { 42 }
236+
237+
XCTAssertNil(outcome.error, "work that finished before the lock must not read as a timeout")
238+
XCTAssertEqual(outcome.value, 42)
239+
XCTAssertEqual(outcome.abandonedCount, 0)
240+
XCTAssertEqual(outcome.onAbandonedCalls, 0)
241+
}
242+
243+
func testRunMainThreadWorkRethrowsWorkThatFailedAtTheTimeoutBoundary() {
244+
let outcome = runMainThreadWorkFinishingAtTheTimeoutBoundary { () throws -> Int in
245+
throw NSError(domain: "agent-device.runner.tests", code: 7)
246+
}
247+
248+
XCTAssertNil(outcome.value)
249+
XCTAssertEqual((outcome.error as NSError?)?.domain, "agent-device.runner.tests")
250+
XCTAssertEqual((outcome.error as NSError?)?.code, 7)
251+
XCTAssertEqual(outcome.abandonedCount, 0)
252+
XCTAssertEqual(outcome.onAbandonedCalls, 0)
253+
}
254+
255+
private final class BoundaryOutcome {
256+
var value: Int?
257+
var error: Error?
258+
var abandonedCount: Int?
259+
var onAbandonedCalls = 0
260+
}
261+
262+
/// Times the wait out immediately, then lets the work finish before the watchdog takes the lock:
263+
/// the seam releases the blocked work and waits for the serial main queue to run past it.
264+
private func runMainThreadWorkFinishingAtTheTimeoutBoundary(
265+
_ produce: @escaping () throws -> Int
266+
) -> BoundaryOutcome {
267+
let outcome = BoundaryOutcome()
268+
let releaseWork = DispatchSemaphore(value: 0)
269+
let finished = expectation(description: "off-main caller finished")
270+
mainThreadWorkTimedOutForTesting = {
271+
releaseWork.signal()
272+
DispatchQueue.main.sync {}
273+
}
274+
defer { mainThreadWorkTimedOutForTesting = nil }
275+
276+
DispatchQueue(label: "agent-device.runner.tests.timeout-boundary").async {
277+
do {
278+
outcome.value = try self.runMainThreadWork(
279+
"command_execution",
280+
timeout: 0,
281+
timeoutError: self.mainThreadExecutionTimeoutError,
282+
onAbandoned: { outcome.onAbandonedCalls += 1 }
283+
) {
284+
_ = releaseWork.wait(timeout: .now() + 2)
285+
return try produce()
286+
}
287+
} catch {
288+
outcome.error = error
289+
}
290+
self.mainThreadWorkLock.lock()
291+
outcome.abandonedCount = self.abandonedMainThreadWorkCount
292+
self.mainThreadWorkLock.unlock()
293+
finished.fulfill()
294+
}
295+
296+
wait(for: [finished], timeout: 3)
297+
return outcome
298+
}
228299
}
229300
#endif

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ final class RunnerTests: XCTestCase {
197197
var blockingSystemModalPresenceOverrideForTesting: Bool?
198198
var alertResolutionOverrideForTesting: ((Date) -> RunnerAlert?)?
199199
var alertButtonHittabilityProbeOverrideForTesting: ((Date) -> Bool)?
200+
// Runs on the waiting thread after `runMainThreadWork`'s wait timed out and before it takes the
201+
// lock that decides between finished and abandoned, so a test can finish the work in that window.
202+
var mainThreadWorkTimedOutForTesting: (() -> Void)?
200203
#endif
201204
// Observability for the record(_:) suppression below: how many AX-broken-screen snapshot
202205
// issues this session muted, so wedge investigations see the volume without grepping logs.

0 commit comments

Comments
 (0)