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 @@ -97,6 +97,9 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsBlockedKey;
/// adds no work", which is only observable as this counter standing still.
+ (NSInteger)customActionReadDispatchCount;

/// Total reads refused by single-flight admission.
+ (NSInteger)customActionReadBlockedCount;

/// The shared AX client (`XCUIDevice.accessibilityInterface`), or nil when the
/// private interface is unavailable.
+ (nullable id)accessibilityClient;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ static dispatch_queue_t RunnerAXCustomActionReadQueue(void)
/// staying put, so it is recorded rather than inferred.
static atomic_long RunnerAXCustomActionReadDispatches = 0;

/// Total single-flight admission refusals.
static atomic_long RunnerAXCustomActionReadBlocked = 0;

/// Output caps. The element budget bounds how many elements we read; these
/// bound what any ONE element can put in the response, so a pathological app
/// cannot spend the whole snapshot on one node's action list.
Expand Down Expand Up @@ -514,17 +517,15 @@ + (BOOL)isFrameOnScreen:(id)frameValue rootFrame:(CGRect)rootFrame
if (![axClient respondsToSelector:selector]) {
return nil;
}
// Single flight. An abandoned read still owns the serial queue, so
// dispatching here would only queue work behind it — and repeating the
// capture would keep queueing more. Refuse without dispatching instead.
if (atomic_load(&RunnerAXCustomActionReadsInFlight) > 0) {
NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_CUSTOM_ACTIONS_READ_BLOCKED");
int expectedReadsInFlight = 0;
if (!atomic_compare_exchange_strong(
&RunnerAXCustomActionReadsInFlight, &expectedReadsInFlight, 1)) {
atomic_fetch_add(&RunnerAXCustomActionReadBlocked, 1);
return nil;
}
// The AX call is a synchronous XPC round trip with no timeout of its own, so
// it runs off-thread behind a bounded wait. On timeout the result box is
// never read, so a late-returning wedged call cannot race this thread.
atomic_fetch_add(&RunnerAXCustomActionReadsInFlight, 1);
atomic_fetch_add(&RunnerAXCustomActionReadDispatches, 1);
NSMutableArray *box = [NSMutableArray array];
dispatch_semaphore_t finished = dispatch_semaphore_create(0);
Expand All @@ -543,7 +544,7 @@ + (BOOL)isFrameOnScreen:(id)frameValue rootFrame:(CGRect)rootFrame
}
// Clear in-flight BEFORE signalling, so a waiter that wakes on this signal
// never observes its own completed read as still outstanding.
atomic_fetch_sub(&RunnerAXCustomActionReadsInFlight, 1);
atomic_store(&RunnerAXCustomActionReadsInFlight, 0);
dispatch_semaphore_signal(finished);
});
long waited = dispatch_semaphore_wait(
Expand Down Expand Up @@ -622,6 +623,11 @@ + (NSInteger)customActionReadDispatchCount
return (NSInteger)atomic_load(&RunnerAXCustomActionReadDispatches);
}

+ (NSInteger)customActionReadBlockedCount
{
return (NSInteger)atomic_load(&RunnerAXCustomActionReadBlocked);
}

+ (nullable id)accessibilityClient
{
return [self objectFrom:XCUIDevice.sharedDevice selectorName:@"accessibilityInterface"];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ extension RunnerTests {
let hung = HungAXClientForTesting()
let element = NSObject()
let dispatchesBefore = RunnerAXSnapshotBridge.customActionReadDispatchCount()
let blockedBefore = RunnerAXSnapshotBridge.customActionReadBlockedCount()
defer { hung.release() }

// 1. First read wedges. The caller is freed by the deadline, but the call is
Expand All @@ -603,18 +604,17 @@ extension RunnerTests {
RunnerAXSnapshotBridge.customActionReadDispatchCount(), dispatchesBefore + 1)

// 2. Repeats do NOT accumulate: no new dispatch, still exactly one in
// flight, and they return immediately instead of paying the deadline.
// flight, and every repeat is refused by single-flight admission.
for _ in 0..<5 {
let started = Date()
XCTAssertNil(
RunnerAXSnapshotBridge.customActionNames(
forElement: element, axClient: hung, completed: &completed))
XCTAssertFalse(completed.boolValue)
XCTAssertLessThan(-started.timeIntervalSinceNow, 0.2)
}
XCTAssertEqual(RunnerAXSnapshotBridge.customActionReadsInFlight(), 1)
XCTAssertEqual(
RunnerAXSnapshotBridge.customActionReadDispatchCount(), dispatchesBefore + 1)
XCTAssertEqual(RunnerAXSnapshotBridge.customActionReadBlockedCount(), blockedBefore + 5)

// 3. A capture in that state discloses the skip rather than presenting the
// unread elements as action-free — and spends no read budget doing it.
Expand Down
Loading