Skip to content

Commit 2a6827c

Browse files
committed
fix: address iOS runner snapshot review
1 parent 303ddf3 commit 2a6827c

4 files changed

Lines changed: 120 additions & 4 deletions

File tree

‎apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift‎

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,81 @@ extension RunnerTests {
241241
XCTAssertNil(box.error)
242242
XCTAssertEqual(box.observedMainThread, true)
243243
}
244+
245+
func testRunMainThreadWorkTimeoutMarksAbandonedUntilDrained() {
246+
final class ResultBox {
247+
var error: Error?
248+
var abandonedCount: Int?
249+
var abandonedSinceSet: Bool?
250+
var drainedCount: Int?
251+
var drainedSinceCleared: Bool?
252+
}
253+
let box = ResultBox()
254+
let releaseWork = DispatchSemaphore(value: 0)
255+
let observedAbandoned = DispatchSemaphore(value: 0)
256+
let finished = expectation(description: "off-main caller timed out")
257+
let drained = expectation(description: "abandoned main work drained")
258+
259+
DispatchQueue(label: "agent-device.runner.tests.timeout").async {
260+
do {
261+
_ = try self.runMainThreadWork(
262+
command: nil,
263+
timeout: 0,
264+
timeoutError: self.mainThreadExecutionTimeoutError,
265+
onAbandoned: {
266+
box.abandonedCount = self.abandonedMainThreadWorkCount
267+
box.abandonedSinceSet = self.abandonedMainThreadWorkSince != nil
268+
observedAbandoned.signal()
269+
},
270+
onDrained: {
271+
self.mainThreadWorkLock.lock()
272+
box.drainedCount = self.abandonedMainThreadWorkCount
273+
box.drainedSinceCleared = self.abandonedMainThreadWorkSince == nil
274+
self.mainThreadWorkLock.unlock()
275+
drained.fulfill()
276+
}
277+
) {
278+
_ = releaseWork.wait(timeout: .now() + 1)
279+
return true
280+
}
281+
} catch {
282+
box.error = error
283+
}
284+
finished.fulfill()
285+
}
286+
287+
DispatchQueue(label: "agent-device.runner.tests.release-timeout").async {
288+
_ = observedAbandoned.wait(timeout: .now() + 1)
289+
releaseWork.signal()
290+
}
291+
292+
wait(for: [finished, drained], timeout: 2)
293+
XCTAssertEqual((box.error as NSError?)?.code, RunnerErrorCode.mainThreadExecutionTimedOut)
294+
XCTAssertEqual(box.abandonedCount, 1)
295+
XCTAssertEqual(box.abandonedSinceSet, true)
296+
XCTAssertEqual(box.drainedCount, 0)
297+
XCTAssertEqual(box.drainedSinceCleared, true)
298+
}
299+
300+
func testPostSnapshotDelayMarkDoesNotQueueBehindAbandonedTreeCapture() {
301+
abandonedTreeCaptureCount = 1
302+
defer {
303+
abandonedTreeCaptureCount = 0
304+
needsPostSnapshotInteractionDelay = false
305+
}
306+
307+
let finished = expectation(description: "off-main caller finished")
308+
DispatchQueue(label: "agent-device.runner.tests.post-snapshot-delay").async {
309+
self.setNeedsPostSnapshotInteractionDelay()
310+
finished.fulfill()
311+
}
312+
313+
wait(for: [finished], timeout: 1)
314+
mainThreadWorkLock.lock()
315+
let abandonedWorkCount = abandonedMainThreadWorkCount
316+
mainThreadWorkLock.unlock()
317+
XCTAssertEqual(abandonedWorkCount, 0)
318+
}
244319
#endif
245320

246321
func execute(command: Command) throws -> Response {
@@ -583,7 +658,7 @@ extension RunnerTests {
583658
timeout: mainThreadExecutionTimeout,
584659
timeoutError: mainThreadExecutionTimeoutError
585660
) {
586-
self.prepareActiveCommandContext(command: command)
661+
try self.prepareActiveCommandContextSafely(command: command)
587662
}
588663
switch preparation {
589664
case .response(let response):
@@ -627,6 +702,10 @@ extension RunnerTests {
627702
needsPostSnapshotInteractionDelay = true
628703
return
629704
}
705+
guard !hasAbandonedTreeCapture() else {
706+
NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_SKIPPED_XCTEST_OCCUPIED")
707+
return
708+
}
630709
do {
631710
try runMainThreadWork(
632711
command: nil,
@@ -658,6 +737,28 @@ extension RunnerTests {
658737
}
659738
}
660739

740+
private func prepareActiveCommandContextSafely(command: Command) throws -> ActiveCommandPreparation {
741+
var preparation: ActiveCommandPreparation?
742+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
743+
preparation = self.prepareActiveCommandContext(command: command)
744+
})
745+
if let exceptionMessage {
746+
throw NSError(
747+
domain: RunnerErrorDomain.exception,
748+
code: RunnerErrorCode.objcException,
749+
userInfo: [NSLocalizedDescriptionKey: exceptionMessage]
750+
)
751+
}
752+
guard let preparation else {
753+
throw NSError(
754+
domain: RunnerErrorDomain.general,
755+
code: RunnerErrorCode.commandReturnedNoResponse,
756+
userInfo: [NSLocalizedDescriptionKey: "snapshot preflight returned no response"]
757+
)
758+
}
759+
return preparation
760+
}
761+
661762
private func executeOnMain(command: Command) throws -> Response {
662763
let preparation = prepareActiveCommandContext(command: command)
663764
let activeApp: XCUIApplication

‎apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ extension RunnerTests {
103103
return ([.privateAX, .querySweep, .recursiveTree], true)
104104
}
105105

106+
func shouldSkipSnapshotBackendForAbandonedTreeCapture(_ kind: SnapshotBackendKind) -> Bool {
107+
kind != .privateAX && hasAbandonedTreeCapture()
108+
}
109+
106110
// MARK: Plan runner
107111

108112
func runSnapshotCapturePlan(
@@ -144,7 +148,7 @@ extension RunnerTests {
144148
}
145149
// While an abandoned tree capture is still grinding inside testmanagerd, XCTest-backed
146150
// tiers would block behind it; only the private AX backend stays responsive (#1105).
147-
if kind != .privateAX, hasAbandonedTreeCapture() {
151+
if shouldSkipSnapshotBackendForAbandonedTreeCapture(kind) {
148152
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_TIER_SKIPPED_XCTEST_OCCUPIED tier=%@", kind.rawValue)
149153
if firstFailure == nil {
150154
firstFailure = (
@@ -543,6 +547,15 @@ extension RunnerTests {
543547
XCTAssertFalse(isSnapshotTreeBackendPenalized(bundleId: "com.other.app"))
544548
}
545549

550+
func testAbandonedTreeCaptureSkipsOnlyXCTestBackedSnapshotTiers() {
551+
abandonedTreeCaptureCount = 1
552+
defer { abandonedTreeCaptureCount = 0 }
553+
554+
XCTAssertTrue(shouldSkipSnapshotBackendForAbandonedTreeCapture(.recursiveTree))
555+
XCTAssertTrue(shouldSkipSnapshotBackendForAbandonedTreeCapture(.querySweep))
556+
XCTAssertFalse(shouldSkipSnapshotBackendForAbandonedTreeCapture(.privateAX))
557+
}
558+
546559
// Pins the record(_:) suppression class via its pure classifier. record(_:) itself is not
547560
// invoked here: feeding it the must-record variants would record real failures and fail
548561
// this very test run.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@ final class RunnerTests: XCTestCase {
7777
// Bluesky-class screens grind ~4-8s before the tree backend fails; anything past this
7878
// threshold marks the screen hostile so the next capture leads with private AX.
7979
let snapshotTreeSlowCaptureThreshold: TimeInterval = 3
80-
// The blocking XCTest tree snapshot XPC runs on a worker thread with this slice so a
80+
// The blocking XCTest tree snapshot XPC runs on the main thread with this slice so a
8181
// content-dependent grind (#1105: seconds to minutes on live Bluesky screens) cannot pin
82-
// the capture plan. On timeout the XPC keeps grinding on its worker; while any abandoned
82+
// the capture plan. On timeout the XPC keeps grinding on main; while any abandoned
8383
// tree capture is outstanding, plans skip the XCTest-backed tiers (tree, query sweep) and
8484
// use the private AX backend, which does not go through testmanagerd.
8585
let treeCaptureLock = NSLock()

‎src/__tests__/apple-runner-package-source.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ function extractSwiftFunction(source: string, name: string): string {
106106
assert.notEqual(signatureIndex, -1, `missing Swift function ${name}`);
107107
const bodyStart = source.indexOf('{', signatureIndex);
108108
assert.notEqual(bodyStart, -1, `missing Swift function body ${name}`);
109+
// This lightweight guard assumes the target Swift function does not contain unmatched braces
110+
// inside string literals or comments; keep the source guard focused on small functions.
109111
let depth = 0;
110112
for (let index = bodyStart; index < source.length; index += 1) {
111113
const char = source[index];

0 commit comments

Comments
 (0)