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 @@ -15,3 +15,7 @@
#if __has_include("UnitTests/RunnerEdgeContactTesting.h")
#import "UnitTests/RunnerEdgeContactTesting.h"
#endif

#if __has_include("UnitTests/RunnerSnapshotBridgeTesting.h")
#import "UnitTests/RunnerSnapshotBridgeTesting.h"
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,13 @@ static NSUInteger RunnerSnapshotBridgeBudgetMs(NSDictionary *request)
return MIN(budget.unsignedIntegerValue, kMaximumDurationMs);
Comment thread
thymikee marked this conversation as resolved.
Resolved
}

/// 1 while a read is admitted and has not returned, including a read abandoned past its budget: the
/// A read stays admitted until it returns, including a read abandoned past its budget: the
/// accessibility call it waits on cannot be cancelled, so later reads are refused until it returns
/// rather than queued behind it.
static atomic_int RunnerSnapshotBridgeReadsInFlight = 0;
static BOOL RunnerSnapshotBridgeReadsInFlight = NO;
static NSUInteger RunnerSnapshotBridgeReadSequence = 0;
static NSTimeInterval RunnerSnapshotBridgeAbandonedAt = 0;
static const NSTimeInterval RunnerSnapshotBridgeWedgeSeconds = 5;

static dispatch_queue_t RunnerSnapshotBridgeReaderQueue(void)
{
Expand All @@ -206,13 +209,21 @@ @implementation RunnerSnapshotBridgeAnswer
/// is abandoned, not stopped: its answer is dropped when it returns.
static NSDictionary *RunnerSnapshotBridgeRead(NSDictionary *request,
NSUInteger budgetMs,
NSDictionary * (^read)(void))
NSDictionary * (^read)(void),
NSTimeInterval (^clock)(void))
{
// Admission is one atomic step, because requests arrive on a concurrent queue.
int idle = 0;
if (!atomic_compare_exchange_strong(&RunnerSnapshotBridgeReadsInFlight, &idle, 1)) {
return RunnerSnapshotBridgeFailure(request, @"application_not_responding", @"bridge-read-in-flight",
@"another accessibility read has not returned yet");
NSUInteger sequence;
@synchronized (RunnerSnapshotBridgeAnswer.class) {
if (RunnerSnapshotBridgeReadsInFlight) {
BOOL wedged = RunnerSnapshotBridgeAbandonedAt > 0 &&
clock() - RunnerSnapshotBridgeAbandonedAt >= RunnerSnapshotBridgeWedgeSeconds;
return RunnerSnapshotBridgeFailure(request, @"application_not_responding",
wedged ? @"bridge-reader-wedged" : @"bridge-read-in-flight",
@"another accessibility read has not returned yet");
}
RunnerSnapshotBridgeReadsInFlight = YES;
RunnerSnapshotBridgeAbandonedAt = 0;
sequence = ++RunnerSnapshotBridgeReadSequence;
}
RunnerSnapshotBridgeAnswer *answer = [RunnerSnapshotBridgeAnswer new];
dispatch_semaphore_t done = dispatch_semaphore_create(0);
Expand All @@ -223,16 +234,36 @@ @implementation RunnerSnapshotBridgeAnswer
answer.value = RunnerSnapshotBridgeFailure(request, @"reader_unavailable", @"reader-exception",
exception.reason ?: @"the snapshot reader raised an exception");
}
atomic_store(&RunnerSnapshotBridgeReadsInFlight, 0);
@synchronized (RunnerSnapshotBridgeAnswer.class) {
RunnerSnapshotBridgeReadsInFlight = NO;
RunnerSnapshotBridgeAbandonedAt = 0;
}
dispatch_semaphore_signal(done);
});
if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, (int64_t)budgetMs * NSEC_PER_MSEC)) != 0) {
@synchronized (RunnerSnapshotBridgeAnswer.class) {
if (RunnerSnapshotBridgeReadsInFlight && RunnerSnapshotBridgeReadSequence == sequence) {
RunnerSnapshotBridgeAbandonedAt = clock();
}
}
return RunnerSnapshotBridgeFailure(request, @"application_not_responding", @"bridge-read-abandoned",
@"the accessibility read outlived its budget and was abandoned");
}
return answer.value;
}

#if defined(AGENT_DEVICE_RUNNER_UNIT_TESTS)
NSDictionary *RunnerSnapshotBridgeReadForTest(NSDictionary *request, NSUInteger budgetMs,
NSDictionary *(^read)(void), NSTimeInterval now)
{
return RunnerSnapshotBridgeRead(request, budgetMs, read, ^{ return now; });
}
void RunnerSnapshotBridgeDrainForTest(void)
{
dispatch_sync(RunnerSnapshotBridgeReaderQueue(), ^{});
}
#endif

uint32_t RunnerSnapshotBridgeMaximumFrameBytes(void)
{
return kMaximumFrameBytes;
Expand All @@ -248,12 +279,12 @@ uint32_t RunnerSnapshotBridgeMaximumFrameBytes(void)
if ([verb isEqualToString:@"target"]) {
return RunnerSnapshotBridgeRead(request, RunnerSnapshotBridgeBudgetMs(request), ^{
return RunnerSnapshotBridgeTarget(bundleId, request);
});
}, ^{ return NSProcessInfo.processInfo.systemUptime; });
}
if ([verb isEqualToString:@"describe"]) {
return RunnerSnapshotBridgeRead(request, RunnerSnapshotBridgeBudgetMs(request), ^{
return RunnerSnapshotBridgeDescribe(bundleId, request);
});
}, ^{ return NSProcessInfo.processInfo.systemUptime; });
}
return RunnerSnapshotBridgeFailure(request, @"bad_request", @"verb-not-supported",
@"the runner snapshot bridge accepts target and describe requests only");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#import <Foundation/Foundation.h>
#import <TargetConditionals.h>
NS_ASSUME_NONNULL_BEGIN
#if TARGET_OS_IOS && TARGET_OS_SIMULATOR
NSDictionary *RunnerSnapshotBridgeReadForTest(NSDictionary *request, NSUInteger budgetMs,
NSDictionary *(^read)(void), NSTimeInterval now);
void RunnerSnapshotBridgeDrainForTest(void);
#endif
NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ extension RunnerTests {
let named = Self.snapshotBridgeReaderRequest(payload, simulatorUdid: "SIM")
XCTAssertEqual(named["simulatorUdid"] as? String, "SIM")
}
func testAbandonedReadBecomesWedgedUntilItReturns() {
let release = DispatchSemaphore(value: 0)
let request = ["requestId": "wedge"]
let first = RunnerSnapshotBridgeReadForTest(request, 100, {
release.wait()
return ["ok": true]
}, 10)
XCTAssertEqual(first["error_code"] as? String, "bridge-read-abandoned")
let busy = RunnerSnapshotBridgeReadForTest(request, 100, { ["ok": true] }, 14.999)
XCTAssertEqual(busy["error_code"] as? String, "bridge-read-in-flight")
let wedged = RunnerSnapshotBridgeReadForTest(request, 100, { ["ok": true] }, 15)
XCTAssertEqual(wedged["error_code"] as? String, "bridge-reader-wedged")
release.signal()
RunnerSnapshotBridgeDrainForTest()
let admitted = RunnerSnapshotBridgeReadForTest(request, 1000, { ["ok": true] }, 16)
XCTAssertEqual(admitted["ok"] as? Bool, true)
}

func testReaderDoesNotRefuseTheBoundsOfAFirstRead() throws {
var request = Self.snapshotBridgeReaderRequest(try firstReadPayload())
request["pid"] = 1
Expand Down
5 changes: 3 additions & 2 deletions contracts/fixtures/ios-snapshot-bridge-refusals.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"description": "Every refusal code the snapshot bridge emits, with the scope the route gives it. A capture-scoped refusal speaks for one capture and keeps the app generation on the bridge; every other code retires the generation. Emitters: the Objective-C reader (apple/snapshot-bridge), the runner-served bridge (RunnerSnapshotBridge.m, RunnerTests+SnapshotBridge.swift), and the TypeScript snapshot source (packages/platform-apple/src/snapshot-source, runner-snapshot-bridge.ts). The TypeScript policy packages/platform-apple/src/snapshot-route-failure-policy.ts mirrors this table. test/repo-guards/snapshot-bridge-refusals.test.ts fails when an emitted code is missing here, when a code here is emitted nowhere, or when the policy disagrees. Classify a new code here by an explicit decision; an unlisted code fails the guard.",
"description": "Every refusal code the snapshot bridge emits, with the scope the route gives it. A capture-scoped refusal speaks for one capture and keeps the app generation on the bridge; generation-scoped codes retire the generation, and runner-session-scoped codes retire the runner bridge. Emitters: the Objective-C reader (apple/snapshot-bridge), the runner-served bridge (RunnerSnapshotBridge.m, RunnerTests+SnapshotBridge.swift), and the TypeScript snapshot source (packages/platform-apple/src/snapshot-source, runner-snapshot-bridge.ts). The TypeScript policy packages/platform-apple/src/snapshot-route-failure-policy.ts mirrors this table. test/repo-guards/snapshot-bridge-refusals.test.ts fails when an emitted code is missing here, when a code here is emitted nowhere, or when the policy disagrees. Classify a new code here by an explicit decision; an unlisted code fails the guard.",
"captureScoped": [
{
"code": "window-coordinate-space-unresolved",
Expand Down Expand Up @@ -112,5 +112,6 @@
"unknown",
"user-interaction-invalid",
"verb-not-supported"
]
],
"runnerSessionScoped": ["bridge-reader-wedged"]
}
6 changes: 5 additions & 1 deletion docs/adr/0004-ios-snapshot-backend-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,11 @@ The runner must not end itself, because it also runs every XCTest command.
The runner abandons such a read (`bridge-read-abandoned`).
The runner admits one read at a time.
Until the admitted read returns, the runner refuses each new read (`bridge-read-in-flight`).
A read that a cancelled capture left running meets that refusal too.
After an abandoned read stays in flight for five seconds, the runner refuses with `bridge-reader-wedged`.
That refusal retires the bridge for the runner session, across app generations.
The route clears its circuit and cached targets when the runner session changes.
The runner does not replace the reader queue, because a second concurrent accessibility read is not proven safe.
A read that a cancelled capture left running meets the in-flight refusal too.
The refusal says only that another read is running, so it has capture scope.
The daemon gives the runner its read budget minus a transport margin.
Thus, the runner abandons a slow read first, and the daemon receives `bridge-read-abandoned`, not a lost reply.
Expand Down
6 changes: 4 additions & 2 deletions packages/platform-apple/src/runner-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function createAppleRunnerRuntime(options: {
// Every capture of a remote Simulator reaches the runner, through the bridge or through XCTest. So
// the foreground read starts a runner that is not up yet, under the capture's own budget, instead
// of giving up the proof that keeps the capture on the bridge.
let snapshotRoute: SnapshotBridgeRoute | undefined;
const readForeground = createSystemSurfaceForegroundProbe(async (device, input) => {
if (!options.provider.hasLiveSession(device)) {
try {
Expand All @@ -73,9 +74,9 @@ export function createAppleRunnerRuntime(options: {
}
}
const session = readRunnerSessionLiveness(device.id);
return session?.liveness === 'ready' ? { sessionId: session.sessionId } : undefined;
if (session?.liveness !== 'ready') return undefined;
return { sessionId: session.sessionId };
});
let snapshotRoute: SnapshotBridgeRoute | undefined;
/**
* The route outlives every request binding: its circuit, depth hints, and cached target describe
* the app generations this runner has read. The presenter comes with the first binding and is the
Expand All @@ -95,6 +96,7 @@ export function createAppleRunnerRuntime(options: {
// The runner answers the target read itself; there is no detached discovery to wait on.
isDiscoveryPending: () => false,
targetFreshness: 'cached',
sessionChanged: bridge.resetTargets,
},
async (device, input) =>
await withAppleRunnerProvider(
Expand Down
Loading
Loading