Skip to content

Commit bbf88aa

Browse files
committed
fix(ios-runner): bound recorder frame capture through runMainThreadWork
A recording tick captured its frame with an unbounded DispatchQueue.main.sync on the recorder queue. While the main thread was wedged the first tick blocked the recorder queue, and the busy and wedged accounting never saw it. Each tick after the bootstrap frame now hops to main through runMainThreadWork with a timeout of one frame interval, on iOS and macOS. A capture that times out drops the frame and counts as abandoned work; its late result is discarded and never appended. While any abandoned main-thread work is outstanding the tick is skipped, so the recorder keeps at most one capture pending on main. The bootstrap frame is still taken on the calling thread, and screenshots keep their on-main capture path. Closes #2801
1 parent 7100d6a commit bbf88aa

3 files changed

Lines changed: 355 additions & 9 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ extension RunnerTests {
358358
outputPath: resolvedOutPath,
359359
fps: command.fps.map { Int32($0) }
360360
)
361-
try recorder.start { [weak self] in
361+
try startRecording(recorder) { [weak self] in
362362
guard let self else { return .failure(.unresolvedScreen) }
363363
return self.captureRunnerFrameResult(app: activeApp)
364364
}

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

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,20 @@ extension RunnerTests {
2424
private var isStopping = false
2525
private var startedSession = false
2626
private var startError: Error?
27+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
28+
private var appendedFramesForTesting: [RunnerImage] = []
29+
#endif
2730

2831
init(outputPath: String, fps: Int32?) {
2932
self.outputPath = outputPath
3033
self.fps = fps
3134
}
3235

36+
/// `bootstrap` must produce the frame that sizes the writer and runs on the caller's thread.
37+
/// `frame` answers each tick with an image within the given timeout, or `nil` to drop the tick.
3338
func start(
34-
capture: @escaping () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>
39+
bootstrap: @escaping () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>,
40+
frame: @escaping (_ timeout: TimeInterval) -> RunnerImage?
3541
) throws {
3642
let url = URL(fileURLWithPath: outputPath)
3743
let directory = url.deletingLastPathComponent()
@@ -49,7 +55,7 @@ extension RunnerTests {
4955
var lastFailure: RunnerAppScreenCaptureFailure?
5056
let bootstrapDeadline = Date().addingTimeInterval(2.0)
5157
while Date() < bootstrapDeadline {
52-
switch capture() {
58+
switch bootstrap() {
5359
case .success(let captured):
5460
bootstrapImage = captured.image
5561
dimensions = CGSize(width: captured.pixelWidth, height: captured.pixelHeight)
@@ -129,8 +135,8 @@ extension RunnerTests {
129135
timer.setEventHandler { [weak self] in
130136
guard let self else { return }
131137
if self.shouldStop() { return }
132-
guard case .success(let captured) = capture() else { return }
133-
self.append(image: captured.image)
138+
guard let image = frame(self.frameInterval) else { return }
139+
self.append(image: image)
134140
}
135141
self.timer = timer
136142
timer.resume()
@@ -227,6 +233,9 @@ extension RunnerTests {
227233
return
228234
}
229235
lastTimestampValue = timestampValue
236+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
237+
appendedFramesForTesting.append(image)
238+
#endif
230239
}
231240

232241
private func timestampCandidateValue(for nowUptime: TimeInterval) -> Int64 {
@@ -285,6 +294,36 @@ extension RunnerTests {
285294
}
286295

287296
extension RunnerTests {
297+
/// Starts `recorder` on the frames `capture` produces. The bootstrap frame is taken on the calling
298+
/// thread, which is main for `record start`. Each later tick hops to main through
299+
/// `runMainThreadWork`, bounded by the tick's own interval, so a wedged main thread drops frames
300+
/// and is accounted as abandoned work instead of blocking the recorder queue.
301+
func startRecording(
302+
_ recorder: ScreenRecorder,
303+
capture: @escaping () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>
304+
) throws {
305+
try recorder.start(bootstrap: capture) { [weak self] timeout in
306+
self?.boundedRecordingFrame(timeout: timeout, capture: capture)
307+
}
308+
}
309+
310+
/// A recording frame is optional, so while abandoned main-thread work is outstanding the tick is
311+
/// skipped rather than queued behind it: the recorder keeps at most one capture pending on main.
312+
/// A capture that outlives `timeout` is dropped, and its late result is never returned.
313+
private func boundedRecordingFrame(
314+
timeout: TimeInterval,
315+
capture: @escaping () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>
316+
) -> RunnerImage? {
317+
guard !hasAbandonedMainThreadWork() else { return nil }
318+
return try? runMainThreadWork(
319+
"recording_frame",
320+
timeout: timeout,
321+
timeoutError: mainThreadExecutionTimeoutError
322+
) {
323+
try capture().get().image
324+
}
325+
}
326+
288327
/// The error a `record start` bootstrap raises when no initial frame arrived. On iOS the last capture
289328
/// refusal (if any) is the honest reason and travels as its own typed code; only when nothing
290329
/// refused — a macOS host capture, or a deadline that elapsed before any answer — does it fall back
@@ -319,5 +358,11 @@ extension RunnerTests.ScreenRecorder {
319358
lastTimestampValue = allocatedTimestamp
320359
return allocatedTimestamp
321360
}
361+
362+
func appendedFrameSnapshotForTesting() -> [RunnerImage] {
363+
lock.lock()
364+
defer { lock.unlock() }
365+
return appendedFramesForTesting
366+
}
322367
}
323368
#endif

0 commit comments

Comments
 (0)