Repository navigation
feat(apple): serve the Simulator AX bridge from the XCTest runner #4182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
9eaf565
refactor(apple): share the snapshot bridge reader with the runner build
thymikee 948da90
refactor(apple): separate the bridge source pipeline from its transport
thymikee a25ebca
feat(apple): serve the Simulator AX bridge from the XCTest runner
thymikee 6094e44
fix(limrun): require an Xcode 26 runner that embeds XCTest
thymikee 7c7ad5e
fix(apple): fence the provider surface proof to one runner session
thymikee 816e33a
refactor(snapshot): remove the dead limrun-ios-tree producer
thymikee cdaba9c
test(apple): guard the snapshot bridge refusal codes against emitter …
thymikee 77d8390
docs(adr): record that observe-only captures bypass the bridge route
thymikee e403b8b
fix(apple): give a tree that changed during a bridge read capture scope
thymikee d8a6517
refactor(apple): prove surface absence on the runner channel as on th…
thymikee 844e181
fix(apple): keep a malformed continuation re-read as snapshot-tree-ma…
thymikee 162833c
chore(apple): list the request validator among the packed bridge assets
thymikee 88e37e1
docs(apple): correct provider names, refusal codes, and the process-t…
thymikee 169c2ff
fix(apple): type a bridge refusal before reading the target it names
thymikee be03886
fix(apple): hold a runner-served envelope to the bound with its frame…
thymikee b735f22
refactor(apple): require describe identity and a session id by type
thymikee 496455a
fix(apple): refuse a JSON boolean where the reader needs a number
thymikee 348babb
fix(limrun): bundle the Swift Testing interop library when the SDK sh…
thymikee 3e2bf92
test(apple): narrow the runner bridge request union in the route tests
thymikee 98806a7
test(apple): run the shared reader tests on the iOS Simulator only
thymikee aa7bc20
perf(apple): settle the surface foreground proof in registry order
thymikee 6eb1dc2
fix(apple): let the off-simulator runner carry its own bridge refusal
thymikee b246a07
fix(ios): preserve runner bridge refusal evidence
thymikee 6fb0dd1
test(apple): match the native bridge version macros exactly
thymikee da8e78e
refactor(apple): name a missing Simulator by leaving its key out
thymikee File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'agent-device': patch | ||
| --- | ||
|
|
||
| Limrun iOS snapshots no longer use the `limrun-ios-tree` producer. Bridge captures report `simulator-ax-bridge`; captures that fall back to XCTest report `apple-runner`. If you inspect saved snapshot provenance, continue to handle the older `limrun-ios-tree` value for historical captures. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
4 changes: 4 additions & 0 deletions
4
...ner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotBridge.h
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| #import <Foundation/Foundation.h> | ||
|
|
||
| NS_ASSUME_NONNULL_BEGIN | ||
|
|
||
| /// Serves the Simulator AX bridge reader (`apple/snapshot-bridge`) from the runner process, for hosts | ||
| /// that cannot start the guest bridge process, such as a remote Simulator. Each call returns one bridge | ||
| /// envelope: the reader's own response keys, plus `processStartTime` beside `pid` so the host can name | ||
| /// the app generation it read. A host that has no envelope to answer with gets a failure envelope, never | ||
| /// an exception. | ||
| /// | ||
| /// `verb` is `target` or `describe`. `target` names the foreground process of `bundleId`. `describe` reads | ||
| /// that process's tree; with an expected pid and start time it refuses another process of the same | ||
| /// bundle as `stale_target`, and it refuses a capture whose process changed while it was read. | ||
| NSDictionary *RunnerSnapshotBridgeHandle(NSString *verb, NSString *bundleId, NSDictionary *request); | ||
|
|
||
| /// The largest response the reader's frame carries: the bound a request that names none falls back to. | ||
| uint32_t RunnerSnapshotBridgeMaximumFrameBytes(void); | ||
|
|
||
| NS_ASSUME_NONNULL_END |
288 changes: 288 additions & 0 deletions
288
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotBridge.m
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,288 @@ | ||
| #import "RunnerSnapshotBridge.h" | ||
|
|
||
| #import <TargetConditionals.h> | ||
| #import "SnapshotBridgeRuntime.h" | ||
|
|
||
| #if TARGET_OS_IOS && TARGET_OS_SIMULATOR | ||
|
|
||
| // The reader's own translation units, compiled into the runner through HEADER_SEARCH_PATHS so the runner | ||
| // and the guest bridge process cannot drift apart. The guest server (SnapshotBridge.m) stays out: the | ||
| // runner's transport replaces its socket. The guest build owns the reader's warning flags, so the runner | ||
| // target's stricter style checks stop at these includes. | ||
| // The runner abandons a read that outlives its budget (RunnerSnapshotBridgeRead) instead of ending the | ||
| // process, which also runs every XCTest command. | ||
| #define SNAPSHOT_BRIDGE_WATCHDOG_ENDS_PROCESS 0 | ||
| #pragma clang diagnostic push | ||
| #pragma clang diagnostic ignored "-Wnullability-completeness" | ||
| #pragma clang diagnostic ignored "-Wimplicit-retain-self" | ||
| #include "SnapshotBridgeCapture.m" | ||
| #include "SnapshotBridgeRuntime.m" | ||
| #include "SnapshotBridgeSurfaceHosts.m" | ||
| #include "SnapshotBridgeRequest.m" | ||
| #pragma clang diagnostic pop | ||
|
|
||
| #import <objc/runtime.h> | ||
| #import <stdatomic.h> | ||
| #import <sys/sysctl.h> | ||
|
|
||
| @protocol RunnerSnapshotBridgePrimaryApp <NSObject> | ||
| + (id)primaryApp; | ||
| - (pid_t)pid; | ||
| @end | ||
|
|
||
| static NSDictionary *RunnerSnapshotBridgeFailure(NSDictionary *request, | ||
| NSString *kind, | ||
| NSString *code, | ||
| NSString *message) | ||
| { | ||
| NSString *requestId = [request[kRequestIdKey] isKindOfClass:NSString.class] ? request[kRequestIdKey] : @""; | ||
| NSMutableDictionary *failure = [failureResponse(requestId, kind, code, message) mutableCopy]; | ||
| // A refusal answers the read the host planned, so it names that target; the process the runner | ||
| // found instead travels as `observedPid`, never as the envelope's own pid. | ||
| if ([request[@"expectedPid"] isKindOfClass:NSNumber.class]) failure[@"pid"] = request[@"expectedPid"]; | ||
| if ([request[@"generation"] isKindOfClass:NSString.class]) failure[@"generation"] = request[@"generation"]; | ||
| return failure; | ||
| } | ||
|
|
||
| /// The process AXRuntime names as the primary foreground application, the same owner the reader checks | ||
| /// before and after a capture. | ||
| static pid_t RunnerSnapshotBridgePrimaryPid(void) | ||
| { | ||
| Class elementClass = objc_lookUpClass("AXElement"); | ||
| if (![elementClass respondsToSelector:@selector(primaryApp)]) return 0; | ||
| id application = [(Class<RunnerSnapshotBridgePrimaryApp>)elementClass primaryApp]; | ||
| if (![application respondsToSelector:@selector(pid)]) return 0; | ||
| return [(id<RunnerSnapshotBridgePrimaryApp>)application pid]; | ||
| } | ||
|
|
||
| /// The process start time, as seconds and microseconds since the epoch. With the pid it names one | ||
| /// process for its whole life, so a relaunch is a new generation even when the kernel reuses the pid. | ||
| static NSString *_Nullable RunnerSnapshotBridgeStartTime(pid_t pid) | ||
| { | ||
| struct kinfo_proc info; | ||
| size_t size = sizeof(info); | ||
| int name[] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid}; | ||
| if (sysctl(name, 4, &info, &size, NULL, 0) != 0 || size == 0 || info.kp_proc.p_pid != pid) return nil; | ||
| struct timeval started = info.kp_proc.p_starttime; | ||
| return [NSString stringWithFormat:@"%ld.%06d", (long)started.tv_sec, (int)started.tv_usec]; | ||
| } | ||
|
|
||
| /// The executable path of `pid`, the first string `KERN_PROCARGS2` reports after the argument count. | ||
| static NSString *_Nullable RunnerSnapshotBridgeExecutable(pid_t pid) | ||
| { | ||
| int name[] = {CTL_KERN, KERN_PROCARGS2, pid}; | ||
| size_t size = 0; | ||
| if (sysctl(name, 3, NULL, &size, NULL, 0) != 0 || size <= sizeof(int)) return nil; | ||
| NSMutableData *buffer = [NSMutableData dataWithLength:size]; | ||
| if (sysctl(name, 3, buffer.mutableBytes, &size, NULL, 0) != 0 || size <= sizeof(int)) return nil; | ||
| const char *path = (const char *)buffer.bytes + sizeof(int); | ||
| size_t remaining = size - sizeof(int); | ||
| size_t length = strnlen(path, remaining); | ||
| if (length == 0 || length == remaining) return nil; | ||
| return [[NSString alloc] initWithBytes:path length:length encoding:NSUTF8StringEncoding]; | ||
| } | ||
|
|
||
| /// The bundle identifier of the app bundle that holds the process's executable. | ||
| static NSString *_Nullable RunnerSnapshotBridgeBundleId(pid_t pid) | ||
| { | ||
| NSString *executable = RunnerSnapshotBridgeExecutable(pid); | ||
| if (!executable) return nil; | ||
| NSRange app = [executable rangeOfString:@".app/" options:NSBackwardsSearch]; | ||
| if (app.location == NSNotFound) return nil; | ||
| NSString *bundlePath = [executable substringToIndex:NSMaxRange(app) - 1]; | ||
| return [NSBundle bundleWithPath:bundlePath].bundleIdentifier; | ||
| } | ||
|
|
||
| typedef struct { | ||
| pid_t pid; | ||
| NSString *_Nullable startTime; | ||
| } RunnerSnapshotBridgeProcess; | ||
|
|
||
| /// The foreground process of `bundleId`, or a failure envelope saying why there is none. | ||
| static NSDictionary *_Nullable RunnerSnapshotBridgeResolve(NSString *bundleId, | ||
| NSDictionary *request, | ||
| RunnerSnapshotBridgeProcess *process) | ||
| { | ||
| NSString *setupError = nil; | ||
| if (!sharedRuntime(&setupError)) { | ||
| return RunnerSnapshotBridgeFailure(request, @"unsupported", @"runtime-unavailable", setupError); | ||
| } | ||
| pid_t pid = RunnerSnapshotBridgePrimaryPid(); | ||
| if (pid <= 0) { | ||
| return RunnerSnapshotBridgeFailure(request, @"application_unavailable", @"foreground-owner-unverified", | ||
| @"AXRuntime reported no primary foreground application"); | ||
| } | ||
| if (![RunnerSnapshotBridgeBundleId(pid) isEqualToString:bundleId]) { | ||
| return RunnerSnapshotBridgeFailure(request, @"application_unavailable", @"foreground-owner-unverified", | ||
| @"the primary foreground application is not the requested app"); | ||
| } | ||
| NSString *startTime = RunnerSnapshotBridgeStartTime(pid); | ||
| if (!startTime) { | ||
| return RunnerSnapshotBridgeFailure(request, @"stale_target", @"target-process-unavailable", | ||
| @"the foreground process exited before its identity was read"); | ||
| } | ||
| process->pid = pid; | ||
| process->startTime = startTime; | ||
| return nil; | ||
| } | ||
|
|
||
| static NSDictionary *RunnerSnapshotBridgeTarget(NSString *bundleId, NSDictionary *request) | ||
| { | ||
| RunnerSnapshotBridgeProcess process = {0, nil}; | ||
| NSDictionary *failure = RunnerSnapshotBridgeResolve(bundleId, request, &process); | ||
| if (failure) return failure; | ||
| NSString *requestId = [request[kRequestIdKey] isKindOfClass:NSString.class] ? request[kRequestIdKey] : @""; | ||
| return @{ | ||
| kProtocolVersionKey : @(kProtocolVersion), | ||
| kSourceVersionKey : kSourceVersion, | ||
| kRequestIdKey : requestId, | ||
| @"ok" : @YES, | ||
| @"pid" : @(process.pid), | ||
| @"processStartTime" : process.startTime, | ||
| }; | ||
| } | ||
|
|
||
| static NSDictionary *RunnerSnapshotBridgeDescribe(NSString *bundleId, NSDictionary *request) | ||
| { | ||
| RunnerSnapshotBridgeProcess process = {0, nil}; | ||
| NSDictionary *failure = RunnerSnapshotBridgeResolve(bundleId, request, &process); | ||
| if (failure) return failure; | ||
| id expectedPid = request[@"expectedPid"]; | ||
| id expectedStartTime = request[@"expectedProcessStartTime"]; | ||
| if (([expectedPid isKindOfClass:NSNumber.class] && [expectedPid intValue] != process.pid) || | ||
| ([expectedStartTime isKindOfClass:NSString.class] && ![expectedStartTime isEqualToString:process.startTime])) { | ||
| NSMutableDictionary *stale = [RunnerSnapshotBridgeFailure(request, @"stale_target", @"target-process-changed", | ||
| @"the foreground process of the app is a new process") mutableCopy]; | ||
| stale[@"observedPid"] = @(process.pid); | ||
| stale[@"observedProcessStartTime"] = process.startTime; | ||
| return stale; | ||
| } | ||
| NSMutableDictionary *describe = [request mutableCopy]; | ||
| [describe removeObjectsForKeys:@[ @"expectedPid", @"expectedProcessStartTime" ]]; | ||
| describe[@"pid"] = @(process.pid); | ||
| NSMutableDictionary *response = [SnapshotBridgeHandleRequest(describe) mutableCopy]; | ||
| if (![RunnerSnapshotBridgeStartTime(process.pid) isEqualToString:process.startTime]) { | ||
| return RunnerSnapshotBridgeFailure(request, @"stale_target", @"target-process-changed", | ||
| @"the app's process changed while its tree was read"); | ||
| } | ||
| response[@"processStartTime"] = process.startTime; | ||
| return response; | ||
| } | ||
|
|
||
| /// The budget of a request that names none. The daemon names one on every request. | ||
| static const NSUInteger RunnerSnapshotBridgeDefaultBudgetMs = 3000; | ||
|
|
||
| static NSUInteger RunnerSnapshotBridgeBudgetMs(NSDictionary *request) | ||
| { | ||
| NSNumber *budget = [request[@"maxDurationMs"] isKindOfClass:NSNumber.class] ? request[@"maxDurationMs"] : nil; | ||
| if (budget.unsignedIntegerValue == 0) return RunnerSnapshotBridgeDefaultBudgetMs; | ||
| return MIN(budget.unsignedIntegerValue, kMaximumDurationMs); | ||
| } | ||
|
|
||
| /// 1 while a read is admitted and has not returned, 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 dispatch_queue_t RunnerSnapshotBridgeReaderQueue(void) | ||
| { | ||
| static dispatch_queue_t queue; | ||
| static dispatch_once_t onceToken; | ||
| dispatch_once(&onceToken, ^{ | ||
| queue = dispatch_queue_create("com.callstack.agentdevice.runner.snapshot-bridge-reader", | ||
| DISPATCH_QUEUE_SERIAL); | ||
| }); | ||
| return queue; | ||
| } | ||
|
|
||
| @interface RunnerSnapshotBridgeAnswer : NSObject | ||
| @property(atomic, strong, nullable) NSDictionary *value; | ||
| @end | ||
|
thymikee marked this conversation as resolved.
Resolved
|
||
|
|
||
| @implementation RunnerSnapshotBridgeAnswer | ||
| @end | ||
|
|
||
| /// Runs one read on the reader queue and waits for it up to `budgetMs`. A read that outlives the budget | ||
| /// is abandoned, not stopped: its answer is dropped when it returns. | ||
| static NSDictionary *RunnerSnapshotBridgeRead(NSDictionary *request, | ||
|
thymikee marked this conversation as resolved.
Resolved
|
||
| NSUInteger budgetMs, | ||
| NSDictionary * (^read)(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"); | ||
| } | ||
| RunnerSnapshotBridgeAnswer *answer = [RunnerSnapshotBridgeAnswer new]; | ||
| dispatch_semaphore_t done = dispatch_semaphore_create(0); | ||
| dispatch_async(RunnerSnapshotBridgeReaderQueue(), ^{ | ||
| @try { | ||
| answer.value = read(); | ||
| } @catch (NSException *exception) { | ||
| answer.value = RunnerSnapshotBridgeFailure(request, @"reader_unavailable", @"reader-exception", | ||
| exception.reason ?: @"the snapshot reader raised an exception"); | ||
| } | ||
| atomic_store(&RunnerSnapshotBridgeReadsInFlight, 0); | ||
| dispatch_semaphore_signal(done); | ||
|
thymikee marked this conversation as resolved.
Resolved
|
||
| }); | ||
| if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, (int64_t)budgetMs * NSEC_PER_MSEC)) != 0) { | ||
| return RunnerSnapshotBridgeFailure(request, @"application_not_responding", @"bridge-read-abandoned", | ||
| @"the accessibility read outlived its budget and was abandoned"); | ||
| } | ||
| return answer.value; | ||
| } | ||
|
|
||
| uint32_t RunnerSnapshotBridgeMaximumFrameBytes(void) | ||
| { | ||
| return kMaximumFrameBytes; | ||
| } | ||
|
|
||
| NSDictionary *RunnerSnapshotBridgeHandle(NSString *verb, NSString *bundleId, NSDictionary *request) | ||
| { | ||
| if ([verb isEqualToString:@"describe"] && request[@"surfaceHostExecutables"] != nil && | ||
| request[@"simulatorUdid"] == nil) { | ||
| return RunnerSnapshotBridgeFailure(request, @"unsupported", @"system-surface-host-unverified", | ||
| @"the runner cannot identify its Simulator for the surface host check"); | ||
| } | ||
| if ([verb isEqualToString:@"target"]) { | ||
| return RunnerSnapshotBridgeRead(request, RunnerSnapshotBridgeBudgetMs(request), ^{ | ||
| return RunnerSnapshotBridgeTarget(bundleId, request); | ||
| }); | ||
| } | ||
| if ([verb isEqualToString:@"describe"]) { | ||
| return RunnerSnapshotBridgeRead(request, RunnerSnapshotBridgeBudgetMs(request), ^{ | ||
| return RunnerSnapshotBridgeDescribe(bundleId, request); | ||
|
thymikee marked this conversation as resolved.
Resolved
|
||
| }); | ||
| } | ||
| return RunnerSnapshotBridgeFailure(request, @"bad_request", @"verb-not-supported", | ||
| @"the runner snapshot bridge accepts target and describe requests only"); | ||
| } | ||
|
|
||
| #else | ||
|
|
||
| /// Off the iOS Simulator the runner has no reader, so its only envelope is the refusal below. | ||
| static const uint32_t kRunnerSnapshotBridgeRefusalFrameBytes = 4096; | ||
|
|
||
| uint32_t RunnerSnapshotBridgeMaximumFrameBytes(void) | ||
| { | ||
| return kRunnerSnapshotBridgeRefusalFrameBytes; | ||
| } | ||
|
|
||
| NSDictionary *RunnerSnapshotBridgeHandle(NSString *verb, NSString *bundleId, NSDictionary *request) | ||
| { | ||
| (void)verb; | ||
| (void)bundleId; | ||
| id requestId = request[@"requestId"]; | ||
| return @{ | ||
| @"protocolVersion" : @(SNAPSHOT_BRIDGE_PROTOCOL_VERSION), | ||
| @"sourceVersion" : SNAPSHOT_BRIDGE_SOURCE_VERSION, | ||
| @"requestId" : [requestId isKindOfClass:NSString.class] ? requestId : @"", | ||
| @"ok" : @NO, | ||
| @"error_kind" : @"unsupported", | ||
| @"error_code" : @"runner-bridge-unavailable", | ||
| @"error" : @"the runner serves the snapshot bridge on the iOS Simulator only", | ||
| }; | ||
| } | ||
|
|
||
| #endif | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.