Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ extension RunnerTests {
outputPath: resolvedOutPath,
fps: command.fps.map { Int32($0) }
)
try recorder.start { [weak self] in
try startRecording(recorder) { [weak self] in
guard let self else { return .failure(.unresolvedScreen) }
return self.captureRunnerFrameResult(app: activeApp)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import XCTest
extension RunnerTests {
/// Tracks one main-queue dispatch so the watchdog and the dispatched block can agree, under
/// `mainThreadWorkLock`, on exactly one of: finished in time, or abandoned.
private final class MainThreadWorkState {
private final class MainThreadWorkState<T> {
let completed = DispatchSemaphore(value: 0)
var result: Result<T, Error>?
var finished = false
var abandoned = false
}
Expand Down Expand Up @@ -62,24 +64,71 @@ extension RunnerTests {
if Thread.isMainThread {
return try work()
}
var result: Result<T, Error>?
let semaphore = DispatchSemaphore(value: 0)
let workState = MainThreadWorkState()
mainThreadWorkLock.lock()
let state = enqueueMainThreadWorkLocked(operation, work)
mainThreadWorkLock.unlock()
return try awaitMainThreadWork(
state,
operation: operation,
timeout: timeout,
timeoutError: timeoutError,
onAbandoned: onAbandoned
)
}

/// Runs optional `work` like `runMainThreadWork`, but only while no other dispatched main-thread
/// work is in flight; otherwise it returns `nil` without dispatching. The check and the enqueue
/// happen under one hold of `mainThreadWorkLock`, the same lock every dispatch enqueues under, so
/// admitted work never waits in the main queue behind a command's hop. The in-flight count covers
/// abandoned work too: a block stays counted until it returns, and it marks itself abandoned in
/// the same window, so occupancy that outlived its slice is already declined here.
func runMainThreadWorkIfIdle<T>(
_ operation: String,
timeout: TimeInterval,
timeoutError: @escaping () -> Error,
_ work: @escaping () throws -> T
) throws -> T? {
if Thread.isMainThread {
return nil
}
mainThreadWorkLock.lock()
guard mainThreadWorkInFlightCount == 0 else {
mainThreadWorkLock.unlock()
return nil
}
let state = enqueueMainThreadWorkLocked(operation, work)
mainThreadWorkLock.unlock()
return try awaitMainThreadWork(
state,
operation: operation,
timeout: timeout,
timeoutError: timeoutError,
onAbandoned: nil
)
}

private func enqueueMainThreadWorkLocked<T>(
_ operation: String,
_ work: @escaping () throws -> T
) -> MainThreadWorkState<T> {
let state = MainThreadWorkState<T>()
mainThreadWorkInFlightCount += 1
DispatchQueue.main.async {
do {
result = .success(try work())
state.result = .success(try work())
} catch {
result = .failure(error)
state.result = .failure(error)
}
self.mainThreadWorkLock.lock()
let abandoned = workState.abandoned
self.mainThreadWorkInFlightCount -= 1
let abandoned = state.abandoned
if abandoned {
self.abandonedMainThreadWorkCount -= 1
if self.abandonedMainThreadWorkCount == 0 {
self.abandonedMainThreadWorkSince = nil
}
} else {
workState.finished = true
state.finished = true
}
let allDrained = abandoned && self.abandonedMainThreadWorkCount == 0
self.mainThreadWorkLock.unlock()
Expand All @@ -89,14 +138,29 @@ extension RunnerTests {
NSLog("AGENT_DEVICE_RUNNER_ABANDONED_WORK_DRAINED")
}
}
semaphore.signal()
state.completed.signal()
}
let waitResult = semaphore.wait(timeout: .now() + timeout)
return state
}

private func awaitMainThreadWork<T>(
_ state: MainThreadWorkState<T>,
operation: String,
timeout: TimeInterval,
timeoutError: @escaping () -> Error,
onAbandoned: (() -> Void)?
) throws -> T {
let waitResult = state.completed.wait(timeout: .now() + timeout)
if waitResult == .timedOut {
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
mainThreadWorkTimedOutForTesting?()
#endif
// Work that finished before the lock was taken already stored its result: it is answered
// like work that finished in time, so an action that happened is never reported as a timeout.
mainThreadWorkLock.lock()
let abandoned = !workState.finished
let abandoned = !state.finished
if abandoned {
workState.abandoned = true
state.abandoned = true
abandonedMainThreadWorkCount += 1
if abandonedMainThreadWorkSince == nil {
abandonedMainThreadWorkSince = Date()
Expand All @@ -110,10 +174,10 @@ extension RunnerTests {
timeout
)
onAbandoned?()
throw timeoutError()
}
throw timeoutError()
}
switch result {
switch state.result {
case .success(let value):
return value
case .failure(let error):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,20 @@ extension RunnerTests {
private var isStopping = false
private var startedSession = false
private var startError: Error?
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
private var appendedFramesForTesting: [RunnerImage] = []
#endif

init(outputPath: String, fps: Int32?) {
self.outputPath = outputPath
self.fps = fps
}

/// `bootstrap` must produce the frame that sizes the writer and runs on the caller's thread.
/// `frame` answers each tick with an image, or `nil` to drop the tick.
func start(
capture: @escaping () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>
bootstrap: @escaping () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>,
frame: @escaping () -> RunnerImage?
) throws {
let url = URL(fileURLWithPath: outputPath)
let directory = url.deletingLastPathComponent()
Expand All @@ -49,7 +55,7 @@ extension RunnerTests {
var lastFailure: RunnerAppScreenCaptureFailure?
let bootstrapDeadline = Date().addingTimeInterval(2.0)
while Date() < bootstrapDeadline {
switch capture() {
switch bootstrap() {
case .success(let captured):
bootstrapImage = captured.image
dimensions = CGSize(width: captured.pixelWidth, height: captured.pixelHeight)
Expand Down Expand Up @@ -129,8 +135,8 @@ extension RunnerTests {
timer.setEventHandler { [weak self] in
guard let self else { return }
if self.shouldStop() { return }
guard case .success(let captured) = capture() else { return }
self.append(image: captured.image)
guard let image = frame() else { return }
self.append(image: image)
}
self.timer = timer
timer.resume()
Expand Down Expand Up @@ -227,6 +233,9 @@ extension RunnerTests {
return
}
lastTimestampValue = timestampValue
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
appendedFramesForTesting.append(image)
#endif
}

private func timestampCandidateValue(for nowUptime: TimeInterval) -> Int64 {
Expand Down Expand Up @@ -285,6 +294,27 @@ extension RunnerTests {
}

extension RunnerTests {
/// Starts `recorder` on the frames `capture` produces. The bootstrap frame is taken on the calling
/// thread, which is main for `record start`. Each later tick is optional work: it hops to main only
/// while no other main-thread work is in flight, so it never queues behind a command.
/// A capture still running after `recordingFrameCaptureTimeout` is abandoned and its frame dropped;
/// its late result is never returned.
func startRecording(
_ recorder: ScreenRecorder,
capture: @escaping () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>
) throws {
try recorder.start(bootstrap: capture) { [weak self] in
guard let self else { return nil }
return try? self.runMainThreadWorkIfIdle(
"recording_frame",
timeout: self.recordingFrameCaptureTimeout,
timeoutError: self.mainThreadExecutionTimeoutError
) {
try capture().get().image
}
}
}

/// The error a `record start` bootstrap raises when no initial frame arrived. On iOS the last capture
/// refusal (if any) is the honest reason and travels as its own typed code; only when nothing
/// refused — a macOS host capture, or a deadline that elapsed before any answer — does it fall back
Expand Down Expand Up @@ -319,5 +349,11 @@ extension RunnerTests.ScreenRecorder {
lastTimestampValue = allocatedTimestamp
return allocatedTimestamp
}

func appendedFrameSnapshotForTesting() -> [RunnerImage] {
lock.lock()
defer { lock.unlock() }
return appendedFramesForTesting
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ final class RunnerTests: XCTestCase {
let xctestIdleKeepaliveInterval: TimeInterval = 60.0
let minRecordingFps = 1
let maxRecordingFps = 120
// A recorder frame still capturing on main after this long is abandoned and dropped. It bounds a
// screenshot round trip, not the frame interval: a capture slower than the interval lowers the
// frame rate, and only a capture this slow counts as main-thread occupancy.
let recordingFrameCaptureTimeout: TimeInterval = 1
var needsPostSnapshotInteractionDelay = false
// Per-command markers that restate a fact of the bound target (the fast app guard, the
// synthesized gesture policy per gesture kind) write only when that fact changes; otherwise a
Expand Down Expand Up @@ -106,6 +110,9 @@ final class RunnerTests: XCTestCase {
// and post-capture bookkeeping stays off main (#1105/#1244).
let mainThreadWorkLock = NSLock()
var abandonedMainThreadWorkCount = 0
// Dispatched main-queue work that has not finished yet, abandoned or not. Only optional work
// reads it, to stay off a main thread that commands are using; occupancy readers do not.
var mainThreadWorkInFlightCount = 0
var abandonedMainThreadWorkSince: Date?
// Past this age the runner stops claiming "busy, retry soon" and reports itself wedged so
// the daemon recycles it — the only cure once the main thread is stuck for good.
Expand Down Expand Up @@ -201,6 +208,9 @@ final class RunnerTests: XCTestCase {
var blockingSystemModalPresenceOverrideForTesting: Bool?
var alertResolutionOverrideForTesting: ((Date) -> RunnerAlert?)?
var alertButtonHittabilityProbeOverrideForTesting: ((Date) -> Bool)?
// Runs on the waiting thread after `runMainThreadWork`'s wait timed out and before it takes the
// lock that decides between finished and abandoned, so a test can finish the work in that window.
var mainThreadWorkTimedOutForTesting: (() -> Void)?
#endif
// Observability for the record(_:) suppression below: how many AX-broken-screen snapshot
// issues this session muted, so wedge investigations see the volume without grepping logs.
Expand Down
Loading
Loading