Skip to content
Merged
Show file tree
Hide file tree
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 Nov 2, 2026
948da90
refactor(apple): separate the bridge source pipeline from its transport
thymikee Nov 2, 2026
a25ebca
feat(apple): serve the Simulator AX bridge from the XCTest runner
thymikee Nov 2, 2026
6094e44
fix(limrun): require an Xcode 26 runner that embeds XCTest
thymikee Nov 3, 2026
7c7ad5e
fix(apple): fence the provider surface proof to one runner session
thymikee Nov 3, 2026
816e33a
refactor(snapshot): remove the dead limrun-ios-tree producer
thymikee Nov 3, 2026
cdaba9c
test(apple): guard the snapshot bridge refusal codes against emitter …
thymikee Nov 3, 2026
77d8390
docs(adr): record that observe-only captures bypass the bridge route
thymikee Nov 3, 2026
e403b8b
fix(apple): give a tree that changed during a bridge read capture scope
thymikee Nov 3, 2026
d8a6517
refactor(apple): prove surface absence on the runner channel as on th…
thymikee Nov 4, 2026
844e181
fix(apple): keep a malformed continuation re-read as snapshot-tree-ma…
thymikee Nov 4, 2026
162833c
chore(apple): list the request validator among the packed bridge assets
thymikee Nov 4, 2026
88e37e1
docs(apple): correct provider names, refusal codes, and the process-t…
thymikee Nov 4, 2026
169c2ff
fix(apple): type a bridge refusal before reading the target it names
thymikee Nov 4, 2026
be03886
fix(apple): hold a runner-served envelope to the bound with its frame…
thymikee Nov 4, 2026
b735f22
refactor(apple): require describe identity and a session id by type
thymikee Nov 4, 2026
496455a
fix(apple): refuse a JSON boolean where the reader needs a number
thymikee Nov 4, 2026
348babb
fix(limrun): bundle the Swift Testing interop library when the SDK sh…
thymikee Nov 4, 2026
3e2bf92
test(apple): narrow the runner bridge request union in the route tests
thymikee Nov 4, 2026
98806a7
test(apple): run the shared reader tests on the iOS Simulator only
thymikee Nov 4, 2026
aa7bc20
perf(apple): settle the surface foreground proof in registry order
thymikee Nov 5, 2026
6eb1dc2
fix(apple): let the off-simulator runner carry its own bridge refusal
thymikee Nov 5, 2026
b246a07
fix(ios): preserve runner bridge refusal evidence
thymikee Nov 5, 2026
6fb0dd1
test(apple): match the native bridge version macros exactly
thymikee Nov 5, 2026
da8e78e
refactor(apple): name a missing Simulator by leaving its key out
thymikee Nov 5, 2026
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
5 changes: 5 additions & 0 deletions .changeset/limrun-snapshot-producer.md
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.
2 changes: 1 addition & 1 deletion .github/actions/setup-apple-runner-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ runs:
id: source-hash
run: |
set -euo pipefail
echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'apple/snapshot-bridge/ForegroundApplicationGeometry.h') }}" >> "$GITHUB_OUTPUT"
echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'apple/snapshot-bridge/**') }}" >> "$GITHUB_OUTPUT"
shell: bash

- name: Resolve Apple runner cache schema
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release-ios-runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
shell: bash

# Limrun simulators run iOS 26 and cannot reach this machine's Xcode. Xcode 26 embeds XCTest in
# the runner app; Xcode 27 leaves it out, and the packaging step refuses such a runner.
- name: Select Xcode 26
run: |
set -euo pipefail
XCODE_APP="$(ls -d /Applications/Xcode_26*.app | sort -V | tail -n 1)"
echo "DEVELOPER_DIR=${XCODE_APP}/Contents/Developer" >> "$GITHUB_ENV"
echo "Using ${XCODE_APP}"
shell: bash

- name: Build iOS simulator runner
env:
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
#import "RunnerObjCExceptionCatcher.h"
#import "RunnerAXSnapshotBridge.h"
#import "RunnerSimulatorEdgeTouch.h"
#import "RunnerSnapshotBridge.h"
#import "RunnerSynthesizedGesture.h"
#import "RunnerSynthesizedTextEntry.h"
#import "RunnerXCTestEventBridge.h"
// The packaged runner omits UnitTests/. OTHER_CFLAGS defines do not reach this header.
#if __has_include("UnitTests/RunnerSnapshotBridgeReaderRequest.h")
#import "UnitTests/RunnerSnapshotBridgeReaderRequest.h"
#endif
#if __has_include("UnitTests/RunnerAXModalScopeCapture.h")
#import "UnitTests/RunnerAXModalScopeCapture.h"
#endif
Expand Down
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
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.
Comment thread
thymikee marked this conversation as resolved.
Resolved
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
Comment thread
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,
Comment thread
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);
Comment thread
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);
Comment thread
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
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,8 @@ extension RunnerTests {
return executeUptime()
case .appState:
return executeAppState(command: command)
case .snapshotBridge:
return executeSnapshotBridge(command: command)
case .pasteboardWrite:
return executePasteboardWrite(command: command)
case .activate:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ extension RunnerTests {
}
Comment thread
thymikee marked this conversation as resolved.
Resolved
switch command.command {
case .status, .activate, .terminate, .targetReset, .shutdown, .recordStart, .recordStop, .uptime,
.appState, .pasteboardWrite, .snapshot:
.appState, .pasteboardWrite, .snapshot, .snapshotBridge:
return Response(
ok: false,
error: ErrorPayload(
Expand Down
Loading
Loading