feat(ios): productionize Simulator AX snapshot bridge (#2277)

* feat(ios): productionize Simulator AX snapshot bridge

* fix: address Simulator AX bridge review comments

* docs: refresh Simulator AX evidence

* fix: address new Simulator AX bridge review comments

* docs: record public snapshot source timings

* fix: preserve size report helper on base checkout

* fix: allow base packages without snapshot bridge

* fix: close simulator snapshot source ownership gaps

* docs: explain simulator bridge language choice
This commit is contained in:
Michał Pierzchała
2026-09-04 13:56:22 +02:00
committed by GitHub
parent 17b6ca36f8
commit 5bb3ea3b2a
41 changed files with 4541 additions and 10 deletions
+23
View File
@@ -300,6 +300,29 @@
"file": "packages/platform-android/src/mechanics.ts",
"exports": ["*"]
},
{
"comment": "Apple Simulator snapshot acquisition is a private package facet consumed by downstream runtime work; Fallow cannot see external consumers through the workspace exports map. Keep only the deliberately narrow public factory, source interface, and request/outcome types here; host injection, cache metadata, and preparation internals stay private.",
"file": "packages/platform-apple/src/snapshot-source-facade.ts",
"exports": [
"createSimulatorSnapshotSource",
"SimulatorSnapshotSource",
"SnapshotSourceFailure",
"SnapshotSourceFailureKind",
"SnapshotSourceLimits",
"SnapshotSourceOutcome",
"SnapshotSourceRequest",
"SnapshotSourceTarget"
]
},
{
"comment": "The native wire vocabulary is exported only so the parity test can pin the Objective-C literals; it is not part of the snapshot-source package facet.",
"file": "packages/platform-apple/src/snapshot-source/protocol.ts",
"exports": [
"SNAPSHOT_SOURCE_ATTRIBUTE_KEYS",
"SNAPSHOT_SOURCE_RESPONSE_KEYS",
"SNAPSHOT_SOURCE_WIRE_KEYS"
]
},
{
"comment": "Deliberately kept off the @agent-device/maestro façade (index.test.ts asserts its absence) and consumed only by the conformance harness under packages/maestro/test/.",
"file": "packages/maestro/src/internal/program-ir-command-parser.ts",
+18
View File
@@ -126,6 +126,24 @@ jobs:
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
preferred-device-name: iPhone 17 Pro
- name: Verify clean-installed Simulator snapshot bridge preparation
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git fetch origin "$BASE_SHA" --depth=1
if git diff --quiet "$BASE_SHA"...HEAD -- \
apple/snapshot-bridge \
packages/platform-apple/src/snapshot-source \
scripts/check-package.ts \
scripts/size-report-install.mjs \
scripts/size-report-package.mjs; then
echo "Snapshot bridge packaging is unchanged; skipping preparation proof."
exit 0
fi
pnpm build
pnpm check:package -- --verify-snapshot-bridge-preparation
- name: Run targeted iOS runner XCTest regressions
run: |
XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)"
+23
View File
@@ -0,0 +1,23 @@
The Simulator AX bridge contains code adapted from Meta Platforms, Inc. idb
v1.5.2, specifically SimulatorFrameworkBridge/AccessibilityService.m and
SimulatorFrameworkBridge/AccessibilityRuntime.m.
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+39
View File
@@ -0,0 +1,39 @@
# Simulator AX bridge
This directory contains the small private accessibility reader used by the
Apple platform acquisition facet. The framed server and request validation
live in `SnapshotBridge.m`; private runtime binding lives in
`SnapshotBridgeRuntime.m`. It is compiled for the iOS Simulator on first use
and is never downloaded, pre-signed, or built by npm installation.
The guest process uses the `XCTAccessibilityFramework` remote-access client
from the simulator runtime and the `userTestingSnapshotForElement:options:error:`
single-fetch API. Requests and responses are length-prefixed JSON frames:
```text
uint32 big-endian byte length
UTF-8 JSON object
```
The host owns all target identity, bounds, deadlines, and lifecycle decisions.
The guest returns only a bounded raw tree, the target pid, truncation, and
protocol/source versions. It does not expose an HTTP route or a public CLI
surface.
The private API is intentionally pinned to the idb v1.5.2-compatible shape.
See `LICENSE.idb` for attribution.
## Why Objective-C
The selected #2192 mechanism was idb v1.5.2's Objective-C
`SimulatorFrameworkBridge`; the Python used during the spike was only a client
for exercising that guest reader. This bridge keeps the proven native boundary
and removes the Python/idb client dependency.
Objective-C is the narrowest implementation for this private runtime adapter:
it resolves unavailable classes and functions with `dlopen`, `dlsym`, and the
Objective-C runtime, invokes dynamically discovered selectors, and contains
`NSException` failures. A Swift implementation would still require an
Objective-C shim for those operations, adding another native boundary. Keeping
the guest in Objective-C also allows direct lazy compilation with `clang`
without an Xcode project or Swift module for private headers.
+280
View File
@@ -0,0 +1,280 @@
/*
* The framed server and request validation for the private Simulator AX reader.
* The runtime binding is isolated in SnapshotBridgeRuntime.m.
*/
#import "SnapshotBridgeRuntime.h"
#import <Foundation/Foundation.h>
#import <arpa/inet.h>
#import <errno.h>
#import <limits.h>
#import <math.h>
#import <poll.h>
#import <sys/socket.h>
#import <sys/stat.h>
#import <sys/types.h>
#import <sys/un.h>
#import <unistd.h>
static const int kDefaultIdleTimeoutSeconds = 60;
static void bridgeLog(NSString *message)
{
fprintf(stderr, "[agent-device-snapshot-bridge] %s\n", message.UTF8String ?: "(no message)");
fflush(stderr);
}
NSDictionary *failureResponse(NSString *requestId,
NSString *kind,
NSString *code,
NSString *message)
{
return @{
kProtocolVersionKey : @(kProtocolVersion),
kSourceVersionKey : kSourceVersion,
kRequestIdKey : requestId ?: @"",
@"ok" : @NO,
@"error_kind" : kind ?: @"reader_unavailable",
@"error_code" : code ?: @"unknown",
@"error" : message ?: @"snapshot bridge request failed",
};
}
static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, NSUInteger *output)
{
if (![value isKindOfClass:NSNumber.class]) return NO;
NSNumber *number = value;
if (number.doubleValue != floor(number.doubleValue)) return NO;
if (number.unsignedIntegerValue < minimum || number.unsignedIntegerValue > maximum) return NO;
if (output) *output = number.unsignedIntegerValue;
return YES;
}
static NSDictionary *handleRequest(NSDictionary *request)
{
NSString *requestId = [request[kRequestIdKey] isKindOfClass:NSString.class] ? request[kRequestIdKey] : @"";
id verb = request[@"verb"];
if (![verb isKindOfClass:NSString.class] || ![verb isEqualToString:@"describe"]) {
return failureResponse(requestId, @"bad_request", @"verb-not-supported", @"snapshot bridge accepts describe requests only");
}
NSNumber *pidValue = request[@"pid"];
if (!validBoundInteger(pidValue, 1, INT_MAX, NULL)) {
return failureResponse(requestId, @"bad_request", @"pid-required", @"describe requires a positive target pid");
}
NSString *generation = [request[@"generation"] isKindOfClass:NSString.class] ? request[@"generation"] : @"";
if (generation.length == 0) {
return failureResponse(requestId, @"bad_request", @"generation-required", @"describe requires an opaque target generation");
}
id snapshotTree = request[@"snapshotTree"];
if (![snapshotTree isKindOfClass:NSNumber.class] || ![snapshotTree boolValue]) {
return failureResponse(requestId, @"bad_request", @"snapshot-tree-required", @"snapshotTree must be enabled");
}
id automationMode = request[@"automationMode"];
if (![automationMode isKindOfClass:NSNumber.class] || ![automationMode boolValue]) {
return failureResponse(requestId, @"bad_request", @"automation-mode-required", @"automationMode must be enabled");
}
NSUInteger maxDepth = 0;
NSUInteger maxNodes = 0;
NSUInteger maxDurationMs = 0;
NSUInteger maxResponseBytes = 0;
if (!validBoundInteger(request[@"maxDepth"], 0, kMaximumDepth, &maxDepth) ||
!validBoundInteger(request[@"maxNodes"], 1, kMaximumNodes, &maxNodes) ||
!validBoundInteger(request[@"maxDurationMs"], 1, kMaximumDurationMs, &maxDurationMs) ||
!validBoundInteger(request[@"maxResponseBytes"], 1024, kMaximumFrameBytes, &maxResponseBytes)) {
return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"snapshot bridge request bounds are outside the bridge limits");
}
NSString *setupError = nil;
BridgeRuntime *runtime = sharedRuntime(&setupError);
if (!runtime) {
NSMutableDictionary *unavailable = [failureResponse(requestId, @"unsupported", @"runtime-unavailable", setupError) mutableCopy];
unavailable[@"pid"] = pidValue;
unavailable[@"generation"] = generation;
return unavailable;
}
NSDictionary *error = nil;
NSDictionary *response = [runtime snapshotForProcess:pidValue.intValue
maxDepth:maxDepth
maxNodes:maxNodes
requestId:requestId
generation:generation
maxDurationMs:maxDurationMs
error:&error];
if (response) return response;
if (error) {
NSMutableDictionary *annotated = [error mutableCopy];
annotated[@"pid"] = pidValue;
annotated[@"generation"] = generation;
return annotated;
}
return failureResponse(requestId, @"reader_unavailable", @"empty-response", @"AX bridge returned no response");
}
static BOOL readFully(int fd, void *buffer, size_t length)
{
size_t offset = 0;
while (offset < length) {
ssize_t count = recv(fd, (char *)buffer + offset, length - offset, 0);
if (count > 0) {
offset += (size_t)count;
continue;
}
if (count < 0 && errno == EINTR) continue;
return NO;
}
return YES;
}
static BOOL writeFully(int fd, const void *buffer, size_t length)
{
size_t offset = 0;
while (offset < length) {
ssize_t count = send(fd, (const char *)buffer + offset, length - offset, MSG_NOSIGNAL);
if (count > 0) {
offset += (size_t)count;
continue;
}
if (count < 0 && errno == EINTR) continue;
return NO;
}
return YES;
}
static NSData *serializedResponse(NSDictionary *response, NSUInteger maxResponseBytes)
{
NSError *error = nil;
NSData *data = nil;
@try {
data = [NSJSONSerialization dataWithJSONObject:response options:0 error:&error];
if (data && data.length + sizeof(uint32_t) <= maxResponseBytes) return data;
} @catch (NSException *exception) {
bridgeLog(exception.reason ?: @"response serialization raised an exception");
}
NSMutableDictionary *fallback = [failureResponse(
response[kRequestIdKey],
data ? @"response_limit_exceeded" : @"malformed_tree",
data ? @"response-too-large" : @"response-not-json-safe",
data ? @"snapshot response exceeds the per-request response bound" : (error.localizedDescription ?: @"response was not JSON serializable")) mutableCopy];
if (response[@"pid"] != nil) fallback[@"pid"] = response[@"pid"];
if (response[@"generation"] != nil) fallback[@"generation"] = response[@"generation"];
return [NSJSONSerialization dataWithJSONObject:fallback options:0 error:NULL];
}
static NSUInteger responseLimitForRequest(id request)
{
if (![request isKindOfClass:NSDictionary.class]) return kMaximumFrameBytes;
NSNumber *value = request[@"maxResponseBytes"];
if (![value isKindOfClass:NSNumber.class]) return kMaximumFrameBytes;
NSUInteger result = value.unsignedIntegerValue;
return result >= 1024 && result <= kMaximumFrameBytes ? result : kMaximumFrameBytes;
}
static int serve(NSString *socketPath, int idleTimeoutSeconds, BOOL exitOnDisconnect)
{
if (socketPath.length == 0 || socketPath.length >= sizeof(((struct sockaddr_un *)0)->sun_path)) {
bridgeLog(@"socket path is empty or too long");
return 1;
}
int listener = socket(AF_UNIX, SOCK_STREAM, 0);
if (listener < 0) {
bridgeLog([NSString stringWithFormat:@"socket failed: %s", strerror(errno)]);
return 1;
}
struct sockaddr_un address = {0};
address.sun_family = AF_UNIX;
strlcpy(address.sun_path, socketPath.fileSystemRepresentation, sizeof(address.sun_path));
unlink(address.sun_path);
if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || listen(listener, 4) != 0) {
bridgeLog([NSString stringWithFormat:@"bind/listen failed for %@: %s", socketPath, strerror(errno)]);
close(listener);
return 1;
}
chmod(address.sun_path, S_IRUSR | S_IWUSR);
bridgeLog([NSString stringWithFormat:@"serving protocol %lu on %@", (unsigned long)kProtocolVersion, socketPath]);
BOOL done = NO;
while (!done) {
struct pollfd waitForClient = {.fd = listener, .events = POLLIN, .revents = 0};
int ready = poll(&waitForClient, 1, idleTimeoutSeconds * 1000);
if (ready == 0) break;
if (ready < 0) {
if (errno == EINTR) continue;
break;
}
int connection = accept(listener, NULL, NULL);
if (connection < 0) {
if (errno == EINTR) continue;
break;
}
struct timeval timeout = {.tv_sec = idleTimeoutSeconds, .tv_usec = 0};
setsockopt(connection, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(connection, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
while (YES) {
@autoreleasepool {
uint32_t networkLength = 0;
if (!readFully(connection, &networkLength, sizeof(networkLength))) break;
uint32_t length = ntohl(networkLength);
if (length == 0 || length > kMaximumFrameBytes) break;
NSMutableData *body = [NSMutableData dataWithLength:length];
if (!readFully(connection, body.mutableBytes, length)) break;
id parsed = [NSJSONSerialization JSONObjectWithData:body options:0 error:NULL];
NSDictionary *response = [parsed isKindOfClass:NSDictionary.class]
? handleRequest(parsed)
: failureResponse(@"", @"bad_request", @"json-object-required", @"request frame must be a JSON object");
NSData *encoded = serializedResponse(response, responseLimitForRequest(parsed));
if (encoded.length > kMaximumFrameBytes) break;
uint32_t responseLength = htonl((uint32_t)encoded.length);
if (!writeFully(connection, &responseLength, sizeof(responseLength)) ||
!writeFully(connection, encoded.bytes, encoded.length)) break;
}
}
close(connection);
if (exitOnDisconnect) done = YES;
}
close(listener);
unlink(address.sun_path);
return 0;
}
static int integerArgument(NSArray<NSString *> *arguments, NSString *flag, int fallback)
{
for (NSUInteger index = 0; index + 1 < arguments.count; index += 1) {
if (![arguments[index] isEqualToString:flag]) continue;
NSInteger value = arguments[index + 1].integerValue;
if (value > 0 && value <= INT_MAX) return (int)value;
}
return fallback;
}
static BOOL boolArgument(NSArray<NSString *> *arguments, NSString *flag, BOOL fallback)
{
for (NSUInteger index = 0; index + 1 < arguments.count; index += 1) {
if ([arguments[index] isEqualToString:flag]) return [arguments[index + 1] boolValue];
}
return fallback;
}
int main(int argc, const char *argv[])
{
@autoreleasepool {
if (argc < 3 || strcmp(argv[1], "serve") != 0) {
fprintf(stderr, "Usage: %s serve <socket> [--idle-timeout <seconds>] [--exit-on-disconnect <bool>]\n", argv[0]);
return 2;
}
NSMutableArray<NSString *> *arguments = [NSMutableArray array];
for (int index = 2; index < argc; index += 1) {
NSString *value = [NSString stringWithUTF8String:argv[index]];
if (value) [arguments addObject:value];
}
NSString *socketPath = arguments.firstObject;
if (socketPath.length == 0) return 2;
NSArray<NSString *> *flags = [arguments subarrayWithRange:NSMakeRange(1, arguments.count - 1)];
return serve(socketPath,
integerArgument(flags, @"--idle-timeout", kDefaultIdleTimeoutSeconds),
boolArgument(flags, @"--exit-on-disconnect", YES));
}
}
@@ -0,0 +1,33 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
extern NSString *const kProtocolVersionKey;
extern NSString *const kSourceVersionKey;
extern NSString *const kRequestIdKey;
extern NSString *const kSourceVersion;
extern const NSUInteger kProtocolVersion;
extern const uint32_t kMaximumFrameBytes;
extern const NSUInteger kMaximumDepth;
extern const NSUInteger kMaximumNodes;
extern const NSUInteger kMaximumDurationMs;
NSDictionary *failureResponse(NSString *requestId,
NSString *kind,
NSString *code,
NSString *message);
@interface BridgeRuntime : NSObject
- (nullable instancetype)initWithError:(NSString *_Nullable *_Nullable)error;
- (nullable NSDictionary *)snapshotForProcess:(pid_t)pid
maxDepth:(NSUInteger)maxDepth
maxNodes:(NSUInteger)maxNodes
requestId:(NSString *)requestId
generation:(NSString *)generation
maxDurationMs:(NSUInteger)maxDurationMs
error:(NSDictionary *_Nullable *_Nonnull)error;
@end
BridgeRuntime *_Nullable sharedRuntime(NSString *_Nullable *_Nullable error);
NS_ASSUME_NONNULL_END
@@ -0,0 +1,359 @@
/*
* The private Simulator AX reader is adapted from Meta Platforms, Inc. idb v1.5.2
* SimulatorFrameworkBridge/AccessibilityService.m and AccessibilityRuntime.m.
* See LICENSE.idb for the upstream notice and license.
*/
#import "SnapshotBridgeRuntime.h"
#import <CoreGraphics/CoreGraphics.h>
#import <objc/message.h>
#import <objc/runtime.h>
#import <dlfcn.h>
#import <signal.h>
#import <unistd.h>
NSString *const kProtocolVersionKey = @"protocolVersion";
NSString *const kSourceVersionKey = @"sourceVersion";
NSString *const kRequestIdKey = @"requestId";
NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.3";
const NSUInteger kProtocolVersion = 1;
const uint32_t kMaximumFrameBytes = 16 * 1024 * 1024;
const NSUInteger kMaximumDepth = 128;
const NSUInteger kMaximumNodes = 10000;
const NSUInteger kMaximumDurationMs = 120000;
static NSString *const kAttributeElementType = @"XC_kAXXCAttributeElementType";
static NSString *const kAttributeElementBaseType = @"XC_kAXXCAttributeElementBaseType";
static NSString *const kAttributeLabel = @"XC_kAXXCAttributeLabel";
static NSString *const kAttributeValue = @"XC_kAXXCAttributeValue";
static NSString *const kAttributeIdentifier = @"XC_kAXXCAttributeIdentifier";
static NSString *const kAttributeFrame = @"XC_kAXXCAttributeFrame";
static NSString *const kAttributeAutomationType = @"XC_kAXXCAttributeAutomationType";
static NSString *const kAttributeChildren = @"XC_kAXXCAttributeChildren";
static NSString *const kSnapshotAttributes = @"UIAccessibilitySnapshotKeyAttributes";
static NSString *const kSnapshotChildren = @"UIAccessibilitySnapshotKeyChildren";
static NSString *const kXctAutomationSupportPath =
@"/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework/XCTAutomationSupport";
static NSString *const kAxRuntimePath =
@"/System/Library/PrivateFrameworks/AXRuntime.framework/AXRuntime";
static NSString *const kAccessibilityErrorKey = @"accessibility-error";
typedef NSDictionary<NSString *, id> *_Nullable (*DefaultSnapshotParametersFn)(void);
typedef NSArray<NSNumber *> *_Nullable (*AttributeNumbersForNamesFn)(NSArray<NSString *> *names);
typedef uint32_t (*AXValueGetTypeFn)(const void *value);
typedef Boolean (*AXValueGetValueFn)(const void *value, uint32_t type, void *out);
typedef bool (*AutomationEnabledFn)(void);
@interface XCTAccessibilityFramework : NSObject
- (instancetype)initForRemoteAccess;
- (nullable id)userTestingSnapshotForElement:(id)element
options:(NSDictionary<NSString *, id> *)options
error:(NSError **)error;
@end
@interface XCAccessibilityElement : NSObject
- (nullable void *)AXUIElement;
@end
@protocol XCAccessibilityElementFactory <NSObject>
+ (nullable XCAccessibilityElement *)elementWithProcessIdentifier:(pid_t)pid;
@end
static NSNumber *finiteNumber(double value)
{
return isfinite(value) ? @(value) : nil;
}
static NSDictionary *_Nullable rectDictionary(CGRect rect)
{
NSNumber *x = finiteNumber(rect.origin.x);
NSNumber *y = finiteNumber(rect.origin.y);
NSNumber *width = finiteNumber(rect.size.width);
NSNumber *height = finiteNumber(rect.size.height);
if (!x || !y || !width || !height) {
return nil;
}
return @{ @"X" : x, @"Y" : y, @"Width" : width, @"Height" : height };
}
@interface SnapshotWatchdogState : NSObject
@property(atomic) BOOL completed;
@end
@implementation SnapshotWatchdogState
@end
static dispatch_source_t startRequestWatchdog(NSUInteger durationMs, SnapshotWatchdogState *state)
{
dispatch_source_t watchdog = dispatch_source_create(
DISPATCH_SOURCE_TYPE_TIMER,
0,
0,
dispatch_get_global_queue(QOS_CLASS_UTILITY, 0));
dispatch_source_set_timer(
watchdog,
dispatch_time(DISPATCH_TIME_NOW, (uint64_t)durationMs * NSEC_PER_MSEC),
DISPATCH_TIME_FOREVER,
0);
dispatch_source_set_event_handler(watchdog, ^{
if (!state.completed) kill(getpid(), SIGKILL);
});
dispatch_resume(watchdog);
return watchdog;
}
static void finishRequestWatchdog(dispatch_source_t watchdog, SnapshotWatchdogState *state)
{
state.completed = YES;
dispatch_source_cancel(watchdog);
}
@implementation BridgeRuntime {
XCTAccessibilityFramework *_framework;
Class<XCAccessibilityElementFactory> _elementClass;
DefaultSnapshotParametersFn _defaultSnapshotParameters;
AttributeNumbersForNamesFn _attributeNumbersForNames;
AXValueGetTypeFn _valueGetType;
AXValueGetValueFn _valueGetValue;
AutomationEnabledFn _automationEnabled;
}
- (nullable instancetype)initWithError:(NSString *_Nullable *_Nullable)error
{
self = [super init];
if (!self) return nil;
dlopen(kAxRuntimePath.UTF8String, RTLD_NOW);
dlopen(kXctAutomationSupportPath.UTF8String, RTLD_NOW);
Class frameworkClass = objc_lookUpClass("XCTAccessibilityFramework");
_elementClass = (Class<XCAccessibilityElementFactory>)objc_lookUpClass("XCAccessibilityElement");
if (!frameworkClass || !_elementClass) {
if (error) *error = @"XCTAutomationSupport accessibility classes are unavailable";
return nil;
}
_framework = [(XCTAccessibilityFramework *)[frameworkClass alloc] initForRemoteAccess];
if (!_framework || ![_framework respondsToSelector:@selector(userTestingSnapshotForElement:options:error:)]) {
if (error) *error = @"XCTAccessibilityFramework snapshot API is unavailable";
return nil;
}
_defaultSnapshotParameters = (DefaultSnapshotParametersFn)dlsym(RTLD_DEFAULT, "XCTDefaultSnapshotParameters");
_attributeNumbersForNames = (AttributeNumbersForNamesFn)dlsym(
RTLD_DEFAULT, "XCAXAccessibilityAttributesForStringAttributes");
_valueGetType = (AXValueGetTypeFn)dlsym(RTLD_DEFAULT, "AXValueGetType");
_valueGetValue = (AXValueGetValueFn)dlsym(RTLD_DEFAULT, "AXValueGetValue");
_automationEnabled = (AutomationEnabledFn)dlsym(RTLD_DEFAULT, "_AXSAutomationEnabled");
if (!_defaultSnapshotParameters || !_attributeNumbersForNames || !_valueGetType || !_valueGetValue) {
if (error) *error = @"AX snapshot conversion functions are unavailable";
return nil;
}
return self;
}
- (BOOL)assertAutomationMode:(BOOL)wanted
{
Class settingsClass = NSClassFromString(@"AXSettings");
SEL sharedInstance = NSSelectorFromString(@"sharedInstance");
SEL setter = NSSelectorFromString(@"setAutomationEnabled:");
if (settingsClass && [settingsClass respondsToSelector:sharedInstance]) {
id settings = ((id (*)(id, SEL))objc_msgSend)(settingsClass, sharedInstance);
if ([settings respondsToSelector:setter]) {
((void (*)(id, SEL, BOOL))objc_msgSend)(settings, setter, wanted);
}
}
return _automationEnabled != NULL && _automationEnabled();
}
- (nullable id)jsonValue:(id)value name:(NSString *)name
{
if (!value || value == [NSNull null]) return nil;
if ([value isKindOfClass:NSString.class] || [value isKindOfClass:NSNumber.class]) return value;
const void *raw = (__bridge const void *)value;
if (_valueGetType(raw) == 3) {
CGRect rect = CGRectZero;
if (_valueGetValue(raw, 3, &rect)) return rectDictionary(rect);
}
if ([name isEqualToString:kAttributeFrame]) return nil;
return nil;
}
- (nullable NSDictionary *)nodeFromSnapshot:(id)snapshot
namesByNumber:(NSDictionary<NSNumber *, NSString *> *)namesByNumber
depth:(NSUInteger)depth
maxDepth:(NSUInteger)maxDepth
maxNodes:(NSUInteger)maxNodes
count:(NSUInteger *)count
truncated:(BOOL *)truncated
malformed:(BOOL *)malformed
{
if (![snapshot isKindOfClass:NSDictionary.class]) {
*malformed = YES;
return nil;
}
if (*count >= maxNodes) {
*truncated = YES;
return nil;
}
(*count)++;
NSDictionary *attributes = ((NSDictionary *)snapshot)[kSnapshotAttributes];
if (![attributes isKindOfClass:NSDictionary.class]) {
*malformed = YES;
return nil;
}
NSMutableDictionary *node = [NSMutableDictionary dictionary];
for (NSNumber *number in attributes) {
NSString *name = namesByNumber[number];
if (!name || [name isEqualToString:kAttributeChildren]) continue;
id safe = [self jsonValue:attributes[number] name:name];
if (safe) node[name] = safe;
}
NSArray *children = ((NSDictionary *)snapshot)[kSnapshotChildren];
if (![children isKindOfClass:NSArray.class]) {
*malformed = YES;
return nil;
}
NSMutableArray *builtChildren = [NSMutableArray array];
if (depth >= maxDepth) {
if (children.count > 0) *truncated = YES;
} else {
for (id child in children) {
NSDictionary *built = [self nodeFromSnapshot:child
namesByNumber:namesByNumber
depth:depth + 1
maxDepth:maxDepth
maxNodes:maxNodes
count:count
truncated:truncated
malformed:malformed];
if (built) [builtChildren addObject:built];
if (*malformed) return nil;
if (*truncated) break;
if (*count >= maxNodes) {
if (builtChildren.count < children.count) *truncated = YES;
break;
}
}
}
node[kAttributeChildren] = builtChildren;
return node;
}
- (nullable NSDictionary *)snapshotForProcess:(pid_t)pid
maxDepth:(NSUInteger)maxDepth
maxNodes:(NSUInteger)maxNodes
requestId:(NSString *)requestId
generation:(NSString *)generation
maxDurationMs:(NSUInteger)maxDurationMs
error:(NSDictionary *_Nullable *_Nonnull)error
{
SnapshotWatchdogState *watchdogState = [SnapshotWatchdogState new];
dispatch_source_t watchdog = startRequestWatchdog(maxDurationMs, watchdogState);
XCAccessibilityElement *root = [_elementClass elementWithProcessIdentifier:pid];
if (!root) {
if (error) *error = failureResponse(requestId, @"application_unavailable", @"application-element-missing", @"application element is unavailable");
finishRequestWatchdog(watchdog, watchdogState);
return nil;
}
void *raw = [root AXUIElement];
if (!raw) {
if (error) *error = failureResponse(requestId, @"application_unavailable", @"application-element-missing", @"application element is unavailable");
finishRequestWatchdog(watchdog, watchdogState);
return nil;
}
NSArray<NSString *> *names = @[
kAttributeElementType,
kAttributeElementBaseType,
kAttributeLabel,
kAttributeValue,
kAttributeIdentifier,
kAttributeFrame,
kAttributeAutomationType,
kAttributeChildren,
];
NSArray<NSNumber *> *numbers = _attributeNumbersForNames(names);
if (![numbers isKindOfClass:NSArray.class] || numbers.count != names.count) {
if (error) *error = failureResponse(requestId, @"reader_unavailable", @"attribute-vocabulary-mismatch", @"AX attribute vocabulary is incompatible");
finishRequestWatchdog(watchdog, watchdogState);
return nil;
}
NSMutableDictionary<NSNumber *, NSString *> *namesByNumber = [NSMutableDictionary dictionary];
[numbers enumerateObjectsUsingBlock:^(NSNumber *number, NSUInteger index, BOOL *stop) {
(void)stop;
if ([number isKindOfClass:NSNumber.class]) namesByNumber[number] = names[index];
}];
NSMutableDictionary *options = [_defaultSnapshotParameters() mutableCopy];
if (!options) options = [NSMutableDictionary dictionary];
options[@"attributes"] = numbers;
options[@"maxDepth"] = @(maxDepth);
options[@"maxChildren"] = @(maxNodes);
options[@"maxArrayCount"] = @(maxNodes);
BOOL automationEnabled = [self assertAutomationMode:YES];
NSError *runtimeError = nil;
id snapshot = nil;
@try {
snapshot = [_framework userTestingSnapshotForElement:(__bridge id)raw options:options error:&runtimeError];
} @catch (NSException *exception) {
if (error) *error = failureResponse(requestId, @"reader_unavailable", @"private-api-exception", exception.reason ?: @"AX snapshot raised an exception");
finishRequestWatchdog(watchdog, watchdogState);
return nil;
}
if (!snapshot) {
NSNumber *axError = runtimeError.userInfo[kAccessibilityErrorKey];
NSInteger code = [axError respondsToSelector:@selector(integerValue)] ? axError.integerValue : runtimeError.code;
NSString *kind = code == -25216 ? @"application_not_responding" : @"application_unavailable";
NSString *message = runtimeError.localizedDescription ?: @"AX snapshot returned no tree";
if (error) *error = failureResponse(requestId, kind, code == -25216 ? @"application-timeout" : @"application-server-unavailable", message);
finishRequestWatchdog(watchdog, watchdogState);
return nil;
}
BOOL truncated = NO;
BOOL malformed = NO;
NSUInteger count = 0;
NSDictionary *tree = [self nodeFromSnapshot:snapshot
namesByNumber:namesByNumber
depth:0
maxDepth:maxDepth
maxNodes:maxNodes
count:&count
truncated:&truncated
malformed:&malformed];
if (!tree) {
if (error) *error = failureResponse(requestId, @"malformed_tree", malformed ? @"snapshot-tree-malformed" : @"snapshot-root-invalid", malformed ? @"AX snapshot contained a malformed node" : @"AX snapshot did not contain a materialized root node");
finishRequestWatchdog(watchdog, watchdogState);
return nil;
}
finishRequestWatchdog(watchdog, watchdogState);
return @{
kProtocolVersionKey : @(kProtocolVersion),
kSourceVersionKey : kSourceVersion,
kRequestIdKey : requestId ?: @"",
@"generation" : generation ?: @"",
@"ok" : @YES,
@"pid" : @(pid),
@"tree" : tree,
@"truncated" : @(truncated),
@"automationEnabled" : @(automationEnabled),
};
}
@end
BridgeRuntime *sharedRuntime(NSString **error)
{
static BridgeRuntime *runtime;
static dispatch_once_t once;
static NSString *setupError;
dispatch_once(&once, ^{
NSString *localError = nil;
runtime = [[BridgeRuntime alloc] initWithError:&localError];
setupError = [localError copy];
});
if (!runtime && error) *error = setupError ?: @"AX bridge runtime is unavailable";
return runtime;
}
@@ -0,0 +1,71 @@
# iOS Simulator snapshot-source live evidence
- Issue: #2196
- Observed: 2026-09-04T10:14:05Z
- Revision: `597cb16db1`
- Target: verified booted `iPhone 17 Pro` Simulator, iOS 26.2
- UDID: `F7D6F9A4-4FCC-4DD7-AC0B-3280C9319CB9`
- App: `Agent Device Tester` (`com.callstack.agentdevicelab`), initial PID `65124`, final PID `67942`
- Workflow: `agent-device open` established the session; one instance of the public `@agent-device/platform-apple/snapshot-source` facet was called with raw projection. Eight warm acquires were followed by eight terminate/launch acquires carrying new app generations. No production routing or proxy path was used.
## Result
| Measurement | Observed |
|---|---:|
| Public-facet prime acquire (includes preparation) | 2462.4 ms |
| Public-facet warm acquire p95 (8 samples) | 14.1 ms |
| Public-facet relaunch acquire p95 (8 samples) | 97.1 ms |
| Warm acquire range | 12.114.1 ms |
| Relaunch acquire range | 38.397.1 ms |
| Raw nodes | 77 |
| Truncated | false |
| Viewport | 402 x 874 |
| Producer | `simulator-ax-bridge` |
| Intent | `full` |
| Residue | `hittability` unavailable |
The returned lineage carried each supplied target id and changed opaque generation. Every sample returned 77 raw nodes without truncation; the source did not claim hittability or interaction-query facts. The prime includes the one-time source/toolchain preparation; the source instance retained the successfully prepared binary for all later acquires.
## Build and cache
- Protocol version: `1`
- Source version: `agent-device-simulator-ax-v1.5.3`
- Source hash: `44e0c10dd5f0bf236c35293999e05d6bfaa740b492a98206da6dc1dec6f7d879`
- Cache key: `0c73362db09451e54089e40d42c8f263`
- The prime used the prepared disk-cache entry and completed source/toolchain validation once; deterministic tests cover cold publish, atomic concurrent publish, corrupt-entry rejection, source invalidation, and toolchain invalidation.
- Closing the source after the measurement left no `snapshot-bridge` or `agent-device-ax-*` helper process.
## Package size
- Measured npm artifact at the revision above: 482 files, 1,035,582-byte tarball, 3,512,119 unpacked and clean-installed bytes.
- The exact base/head delta is supplied by the GitHub Size workflow; its base-aware assertion does not require a bridge asset on a base commit that predates this facet.
- The `apple-snapshot-bridge` component contributes 29,684 unpacked bytes across five published source/license/readme files.
## Boundary
- The first live attempt intentionally exercised the original long temp-socket path and failed closed with the guest's typed `socket path is empty or too long` diagnostic. The path was shortened to a per-host-process, target-hashed `/tmp` namespace before the successful retry.
- Native sources compile with `clang -Werror -Wall -Wextra` for the iOS Simulator.
- This is evidence for the private acquisition facet only. It does not authorize production snapshot routing, fallback, XCTest interaction, physical-device support, or a public CLI surface.
## Review reconciliation
The implementation remains one reviewable facet with four ownership layers: native AX acquisition,
the framed wire contract, host-side build/cache, and helper lifecycle. The tests and gates stay beside
those layers, including an explicit wire-vocabulary literal guard rather than a native-produced round
trip claim. The change is intentionally not split into
independently publishable commits because each layer is unusable without the adjacent protocol and
lifecycle contract.
| Retained growth | Scope kept in the facet |
|---|---|
| Native runtime | Private AX binding, strict tree materialization, watchdog, and bounded response framing |
| Host/cache | Toolchain-aware atomic build cache and clean-installed native source preparation |
| Lifecycle/wire | Per-simulator generation routing, persistent helper reuse, typed failures, and reap recovery |
| Proof | Vitest coverage topology, native/TypeScript vocabulary parity, size base/head handling, and live evidence |
The smaller alternatives were rejected for concrete boundary reasons: a generic cancellation protocol
cannot interrupt the synchronous private AX call safely, so a per-request native watchdog plus exact
helper reap is the bounded failure path; exposing host/cache injection would make test seams part of
the public contract, so injection remains internal; and hashing the whole source directory would make
README, license, and other package-only files rebuild the binary, so the cache fingerprints only the
three native compile inputs.
+1
View File
@@ -209,6 +209,7 @@
"dist",
"apple/macos-helper",
"!apple/macos-helper/**/.build",
"apple/snapshot-bridge",
"android/snapshot-helper/dist",
"!android/snapshot-helper/dist/*.idsig",
"!android/snapshot-helper/README.md",
+7
View File
@@ -4,6 +4,9 @@
"private": true,
"type": "module",
"description": "Apple-family platform runtime metadata and implementations for agent-device.",
"scripts": {
"verify-installed-snapshot-bridge": "node --experimental-strip-types scripts/verify-installed-snapshot-bridge.ts"
},
"dependencies": {
"@agent-device/capture-kit": "workspace:*",
"@agent-device/contracts": "workspace:*",
@@ -53,6 +56,10 @@
"types": "./src/runner/index.ts",
"default": "./src/runner/index.ts"
},
"./snapshot-source": {
"types": "./src/snapshot-source-facade.ts",
"default": "./src/snapshot-source-facade.ts"
},
"./runner/operations": {
"types": "./src/runner-operations-facade.ts",
"default": "./src/runner-operations-facade.ts"
@@ -0,0 +1,26 @@
import path from 'node:path';
import { ensureSnapshotBridgeBinary } from '../src/snapshot-source/cache.ts';
import { createSnapshotSourceDeadline } from '../src/snapshot-source/deadline.ts';
import { createSnapshotSourceHost } from '../src/snapshot-source/host.ts';
import { resolveSnapshotSourceLimits } from '../src/snapshot-source/limits.ts';
const [installedRoot, cacheRoot] = process.argv.slice(2);
if (!installedRoot || !cacheRoot) {
throw new Error('Usage: verify-installed-snapshot-bridge <installed-root> <cache-root>');
}
const host = {
...createSnapshotSourceHost(),
projectRoot: () => installedRoot,
};
const limits = resolveSnapshotSourceLimits({ maxDurationMs: 120_000 });
const prepared = await ensureSnapshotBridgeBinary({
host,
runtime: 'installed-package-verification',
limits,
deadline: createSnapshotSourceDeadline(limits.maxDurationMs, undefined),
cacheRoot,
});
if (!prepared.path.startsWith(`${cacheRoot}${path.sep}`) || !host.exists(prepared.path)) {
throw new Error('Installed snapshot bridge preparation did not publish its compiled binary.');
}
@@ -0,0 +1,47 @@
/**
* Dormant Simulator AX acquisition. The implementation is loaded only when a caller explicitly
* creates the source; importing this facet keeps the platform package's startup surface inert.
*/
export type {
SnapshotSourceFailure,
SnapshotSourceFailureKind,
SnapshotSourceLimits,
SnapshotSourceOutcome,
SnapshotSourceRequest,
SnapshotSourceTarget,
} from './snapshot-source/types.ts';
import type { SnapshotSourceOutcome, SnapshotSourceRequest } from './snapshot-source/types.ts';
export type SimulatorSnapshotSource = Readonly<{
acquire(request: SnapshotSourceRequest): Promise<SnapshotSourceOutcome>;
close(): Promise<void>;
}>;
export function createSimulatorSnapshotSource(): SimulatorSnapshotSource {
let implementation:
| Promise<import('./snapshot-source/adapter.ts').SimulatorSnapshotSource>
| undefined;
let closed = false;
const load = async () => {
implementation ??= import('./snapshot-source/adapter.ts').then(
({ createSimulatorSnapshotSource: create }) => create(),
);
return await implementation;
};
return {
acquire: async (request) => {
if (closed) {
return {
stage: 'failed',
failure: { kind: 'unsupported', code: 'source-closed' },
};
}
return await (await load()).acquire(request);
},
close: async () => {
closed = true;
if (implementation) await (await implementation).close();
},
};
}
@@ -0,0 +1,246 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'vitest';
import {
createIosSnapshotRequest,
deriveIosCaptureHint,
} from '@agent-device/capture-kit/ios-snapshot-planning';
import { createSnapshotSourceHost } from './host.ts';
import { createSimulatorSnapshotSource } from './adapter.ts';
import {
encodeSnapshotBridgeFrame,
SNAPSHOT_SOURCE_PROTOCOL_VERSION,
SNAPSHOT_SOURCE_VERSION,
} from './protocol.ts';
import type { SnapshotSourceHost, SnapshotSourceProcess, SnapshotSourceSocket } from './types.ts';
test('the Simulator AX source returns raw acquisition facts and discloses unsupported facets', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-adapter-'));
const sourceRoot = path.join(root, 'source');
const cacheRoot = path.join(root, 'cache');
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
const fixture = createAdapterHost();
const source = createSimulatorSnapshotSource({
host: fixture.host,
sourceRoot,
cacheRoot,
limits: { maxNodes: 20, maxTraversalDepth: 10, maxDurationMs: 1000 },
});
const request = createIosSnapshotRequest({ interactiveOnly: true });
const hint = deriveIosCaptureHint(request);
try {
const result = await source.acquire({
target: {
udid: 'simulator-1',
runtime: 'iOS 26.2',
pid: 321,
generation: 'generation-1',
targetId: 'target-1',
},
hint,
});
assert.equal(fixture.builds, 1);
assert.equal(fixture.runs, 6);
assert.equal(result.stage, 'acquired');
assert.equal(result.acquisition.producer, 'simulator-ax-bridge');
assert.equal(result.acquisition.intent, 'full');
assert.deepEqual(result.acquisition.hint, hint);
assert.equal(result.acquisition.nodes[0]?.pid, 321);
assert.deepEqual(result.acquisition.viewport, {
kind: 'reported',
rect: { x: 0, y: 0, width: 390, height: 844 },
});
assert.deepEqual(result.acquisition.lineage, {
targetId: 'target-1',
generation: 'generation-1',
});
assert.deepEqual(result.acquisition.residue, [
{ kind: 'unavailable-fact', fact: 'hittability' },
{ kind: 'unavailable-fact', fact: 'interactive-query' },
]);
fixture.responsePid = 999;
const outcome = await source.acquire({
target: {
udid: 'simulator-1',
runtime: 'iOS 26.2',
pid: 321,
generation: 'generation-1',
},
hint,
});
assert.equal(outcome.stage, 'failed');
if (outcome.stage === 'failed') assert.equal(outcome.failure.kind, 'stale-target');
assert.equal(fixture.runs, 6);
} finally {
await source.close();
await rm(root, { recursive: true, force: true });
}
});
test('preparation consumes the same acquisition deadline as bridge I/O', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-adapter-deadline-'));
const sourceRoot = path.join(root, 'source');
const cacheRoot = path.join(root, 'cache');
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
const fixture = createAdapterHost(150);
const source = createSimulatorSnapshotSource({ host: fixture.host, sourceRoot, cacheRoot });
const request = createIosSnapshotRequest();
const hint = deriveIosCaptureHint(request);
try {
const outcome = await source.acquire({
target: { ...targetForTest(), generation: 'generation-1' },
hint,
limits: { maxDurationMs: 100 },
});
assert.equal(outcome.stage, 'failed');
if (outcome.stage === 'failed') assert.equal(outcome.failure.kind, 'timeout');
assert.equal(fixture.builds, 1);
} finally {
await source.close();
await rm(root, { recursive: true, force: true });
}
});
type AdapterFixture = {
host: SnapshotSourceHost;
builds: number;
runs: number;
responsePid: number;
};
function targetForTest() {
return {
udid: 'simulator-1',
runtime: 'iOS 26.2',
pid: 321,
};
}
function createAdapterHost(buildDelayMs = 0): AdapterFixture {
const realHost = createSnapshotSourceHost();
const fixture: AdapterFixture = {
host: undefined as never,
builds: 0,
runs: 0,
responsePid: 321,
};
const host: SnapshotSourceHost = {
...realHost,
run: async (command, args) => {
fixture.runs += 1;
if (command === 'xcrun' && args.includes('clang')) {
fixture.builds += 1;
if (buildDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, buildDelayMs));
await writeFile(args.at(-1)!, 'bridge-binary');
return { stdout: '', stderr: '', exitCode: 0 };
}
return {
stdout:
command === 'xcodebuild'
? 'Xcode 16.4\nBuild version 16F6'
: command === 'sw_vers'
? '15.6'
: command === 'uname'
? 'arm64'
: '26.2',
stderr: '',
exitCode: 0,
};
},
start: () => new AdapterProcess(),
connect: async () => new AdapterSocket(() => fixture.responsePid),
readTargetProcessStartTime: async () => 'target-start',
};
fixture.host = host;
return fixture;
}
class AdapterProcess implements SnapshotSourceProcess {
readonly pid = 801;
readonly wait: Promise<{ stdout: string; stderr: string; exitCode: number }>;
private resolveWait!: (result: { stdout: string; stderr: string; exitCode: number }) => void;
private alive = true;
constructor() {
this.wait = new Promise((resolve) => {
this.resolveWait = resolve;
});
}
isAlive(): boolean {
return this.alive;
}
signal(): void {
this.alive = false;
this.resolveWait({ stdout: '', stderr: '', exitCode: 0 });
}
readLog(): string {
return '';
}
}
class AdapterSocket extends EventEmitter implements SnapshotSourceSocket {
destroyed = false;
private readonly readResponsePid: () => number;
constructor(responsePid: () => number) {
super();
this.readResponsePid = responsePid;
}
write(frame: Buffer): boolean {
const bodyLength = frame.readUInt32BE(0);
const request = JSON.parse(frame.subarray(4, bodyLength + 4).toString('utf8')) as {
requestId: string;
pid: number;
generation: string;
};
queueMicrotask(() => {
if (this.destroyed) return;
this.emit(
'data',
encodeSnapshotBridgeFrame(
{
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
sourceVersion: SNAPSHOT_SOURCE_VERSION,
requestId: request.requestId,
ok: true,
pid: this.readResponsePid(),
generation: request.generation,
truncated: false,
automationEnabled: true,
tree: {
XC_kAXXCAttributeElementType: 'Application',
XC_kAXXCAttributeFrame: { X: 0, Y: 0, Width: 390, Height: 844 },
XC_kAXXCAttributeChildren: [],
},
},
{
maxRequestBytes: 64 * 1024,
},
),
);
});
return true;
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
queueMicrotask(() => this.emit('close'));
}
}
@@ -0,0 +1,254 @@
import { AppError } from '@agent-device/kernel/errors';
import type { CaptureHint, IosSnapshotAcquisition } from '@agent-device/contracts/ios-snapshot';
import { ensureSnapshotBridgeBinary } from './cache.ts';
import { createSnapshotSourceDeadline, remainingSnapshotSourceMs } from './deadline.ts';
import { asSnapshotSourceError, snapshotSourceError } from './errors.ts';
import { SnapshotBridgeManager } from './lifecycle.ts';
import { resolveSnapshotSourceLimits } from './limits.ts';
import type { SnapshotBridgeEnvelope } from './protocol.ts';
import { decodeSnapshotBridgeTree } from './tree.ts';
import { createSnapshotSourceHost } from './host.ts';
import type {
SnapshotSourceHost,
SnapshotSourceBridgeBinary,
SnapshotSourceLimits,
SnapshotSourceOutcome,
SnapshotSourceRequest,
} from './types.ts';
const SNAPSHOT_SOURCE_PRODUCER = 'simulator-ax-bridge' as const;
export type SimulatorSnapshotSourceOptions = Readonly<{
host?: SnapshotSourceHost;
limits?: Partial<SnapshotSourceLimits>;
sourceRoot?: string;
cacheRoot?: string;
}>;
export type SimulatorSnapshotSource = Readonly<{
acquire(request: SnapshotSourceRequest): Promise<SnapshotSourceOutcome>;
close(): Promise<void>;
}>;
export function createSimulatorSnapshotSource(
options: SimulatorSnapshotSourceOptions = {},
): SimulatorSnapshotSource {
const host = options.host ?? createSnapshotSourceHost();
const manager = new SnapshotBridgeManager(host);
const preparedBinaries = new Map<string, SnapshotSourceBridgeBinary>();
let closed = false;
const prepare = async (
input: Readonly<{
runtime: string;
limits: SnapshotSourceLimits;
deadline: import('./deadline.ts').SnapshotSourceDeadline;
}>,
) => {
if (closed) throw snapshotSourceError('unsupported', 'source-closed');
const prepared = preparedBinaries.get(input.runtime);
if (prepared) return prepared;
const completed = await host.withDiagnosticTimer(
'ios.snapshot-source.prepare',
async () =>
await ensureSnapshotBridgeBinary({
host,
runtime: input.runtime,
limits: input.limits,
deadline: input.deadline,
sourceRoot: options.sourceRoot,
cacheRoot: options.cacheRoot,
}),
{ producer: SNAPSHOT_SOURCE_PRODUCER },
);
preparedBinaries.set(input.runtime, completed);
return completed;
};
const acquire = async (request: SnapshotSourceRequest): Promise<SnapshotSourceOutcome> => {
try {
if (closed) throw snapshotSourceError('unsupported', 'source-closed');
validateRequest(request);
const limits = resolveSnapshotSourceLimits({ ...options.limits, ...request.limits });
const deadline = createSnapshotSourceDeadline(limits.maxDurationMs, request.signal);
const maxDepth = resolveRequestedDepth(request.hint, limits.maxTraversalDepth);
return await host.withDiagnosticTimer(
'ios.snapshot-source.acquire',
async () => {
const bridge = await prepare({
runtime: request.target.runtime,
limits,
deadline,
});
const envelope = await manager.request({
target: request.target,
bridge,
limits,
maxDepth,
deadline,
});
remainingSnapshotSourceMs(deadline, 'snapshot-decode-deadline');
return {
stage: 'acquired',
acquisition: createAcquisition(
request.hint,
request.target,
envelope,
limits,
maxDepth,
),
};
},
{ producer: SNAPSHOT_SOURCE_PRODUCER },
);
} catch (error) {
const failure = asSnapshotSourceError(error);
return {
stage: 'failed',
failure: {
kind: failure.failureKind,
code: failure.failureCode,
...(failure.details ? { details: failure.details } : {}),
},
} satisfies SnapshotSourceOutcome;
}
};
return {
acquire,
close: async () => {
if (closed) return;
closed = true;
await manager.close();
},
};
}
// fallow-ignore-next-line complexity
function validateRequest(request: SnapshotSourceRequest): void {
if (
!request.target.udid.trim() ||
!request.target.runtime.trim() ||
!request.target.generation.trim() ||
!Number.isSafeInteger(request.target.pid) ||
request.target.pid <= 0
) {
throw new AppError('INVALID_ARGS', 'Simulator snapshot source target identity is incomplete');
}
const hint = request.hint;
if (
(hint.projection !== 'raw' && hint.projection !== 'regular') ||
!['full', 'surface-observation'].includes(hint.acquisitionIntent) ||
typeof hint.interactiveOnly !== 'boolean' ||
typeof hint.customActions !== 'boolean' ||
!validDepth(hint.rawTraversalDepth) ||
!validDepth(hint.regularPresentedDepth)
) {
throw new AppError('INVALID_ARGS', 'Simulator snapshot source capture hint is invalid');
}
}
function resolveRequestedDepth(hint: CaptureHint, maximum: number): number {
const requested = hint.rawTraversalDepth ?? hint.regularPresentedDepth ?? maximum;
if (requested > maximum) {
throw new AppError('INVALID_ARGS', 'Simulator snapshot source depth exceeds its bound', {
requested,
maximum,
});
}
return requested;
}
function validDepth(value: number | null): boolean {
return value === null || (Number.isSafeInteger(value) && value >= 0);
}
function createAcquisition(
hint: CaptureHint,
target: SnapshotSourceRequest['target'],
envelope: SnapshotBridgeEnvelope,
limits: SnapshotSourceLimits,
maxDepth: number,
): IosSnapshotAcquisition {
if (envelope.automationEnabled !== true) {
throw snapshotSourceError('unsupported', 'automation-mode-unavailable');
}
const tree = envelope.tree;
const truncated = envelope.truncated;
if (typeof truncated !== 'boolean') {
throw snapshotSourceError('malformed-tree', 'truncated-invalid');
}
const decoded = decodeSnapshotBridgeTree(tree, { truncated }, limits);
const generation = envelope.generation;
if (typeof generation !== 'string' || !generation) {
throw snapshotSourceError('malformed-tree', 'generation-invalid');
}
const nodes = Object.freeze(
decoded.nodes.map((node) => Object.freeze({ ...node, pid: target.pid })),
);
const residue = createAcquisitionResidue(
hint,
truncated,
decoded,
limits,
maxDepth,
nodes.length,
);
const lineage = Object.freeze({
...(target.targetId ? { targetId: target.targetId } : {}),
generation,
});
const common = {
producer: SNAPSHOT_SOURCE_PRODUCER,
nodes,
truncated,
viewport: decoded.viewport,
lineage,
residue,
};
if (hint.acquisitionIntent === 'full') {
return { ...common, intent: 'full', hint: { ...hint, acquisitionIntent: 'full' } };
}
return {
...common,
intent: 'surface-observation',
hint: { ...hint, acquisitionIntent: 'surface-observation' },
};
}
function createAcquisitionResidue(
hint: CaptureHint,
truncated: boolean,
decoded: ReturnType<typeof decodeSnapshotBridgeTree>,
limits: SnapshotSourceLimits,
maxDepth: number,
nodeCount: number,
) {
return Object.freeze([
{ kind: 'unavailable-fact', fact: 'hittability' } as const,
...(hint.interactiveOnly
? ([{ kind: 'unavailable-fact', fact: 'interactive-query' }] as const)
: []),
...(truncated
? [truncationResidue(decoded.maxTraversalDepth, nodeCount, limits, maxDepth)]
: []),
...(decoded.viewport.kind === 'missing'
? ([{ kind: 'missing-viewport', reason: decoded.viewport.reason }] as const)
: []),
]);
}
function truncationResidue(
maxTraversalDepth: number,
nodeCount: number,
limits: SnapshotSourceLimits,
maxDepth: number,
): { kind: 'truncated'; dimension: 'nodes' | 'depth' | 'payload'; limit?: number } {
if (nodeCount >= limits.maxNodes) {
return { kind: 'truncated', dimension: 'nodes', limit: limits.maxNodes };
}
if (maxTraversalDepth >= maxDepth) {
return { kind: 'truncated', dimension: 'depth', limit: maxDepth };
}
return { kind: 'truncated', dimension: 'payload', limit: limits.maxResponseBytes };
}
@@ -0,0 +1,99 @@
import { createHash } from 'node:crypto';
import path from 'node:path';
import { snapshotSourceError } from './errors.ts';
import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts';
import type { SnapshotSourceHost } from './types.ts';
export type SnapshotSourceToolchainIdentity = Readonly<{
xcode: string;
macosProductVersion: string;
macosBuild: string;
architecture: 'arm64' | 'x86_64';
simulatorSdk: string;
simulatorRuntime: string;
}>;
export const SNAPSHOT_BRIDGE_SOURCE_FILENAMES = [
'SnapshotBridge.m',
'SnapshotBridgeRuntime.m',
'SnapshotBridgeRuntime.h',
] as const;
export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = [
'SnapshotBridge.m',
'SnapshotBridgeRuntime.m',
] as const;
export async function fingerprintSnapshotBridgeSource(
host: SnapshotSourceHost,
root: string,
deadline: SnapshotSourceDeadline,
): Promise<string> {
const hash = createHash('sha256');
for (const sourceFile of SNAPSHOT_BRIDGE_SOURCE_FILENAMES) {
const filePath = path.join(root, sourceFile);
remainingSnapshotSourceMs(deadline, 'native-source-fingerprint-deadline');
if (!host.exists(filePath)) {
throw snapshotSourceError('unsupported', 'native-source-missing', { filePath });
}
hash.update(sourceFile);
hash.update('\0');
hash.update(await host.readBinary(filePath));
hash.update('\0');
}
return hash.digest('hex');
}
export async function readSnapshotSourceToolchain(
host: SnapshotSourceHost,
simulatorRuntime: string,
deadline: SnapshotSourceDeadline,
): Promise<SnapshotSourceToolchainIdentity> {
const xcode = await toolOutput(host, 'xcodebuild', ['-version'], deadline);
const macosProductVersion = await toolOutput(host, 'sw_vers', ['-productVersion'], deadline);
const macosBuild = await toolOutput(host, 'sw_vers', ['-buildVersion'], deadline);
const architecture = await toolOutput(host, 'uname', ['-m'], deadline);
const simulatorSdk = await toolOutput(
host,
'xcrun',
['--sdk', 'iphonesimulator', '--show-sdk-version'],
deadline,
);
const runtime = simulatorRuntime.trim();
if (!runtime) throw snapshotSourceError('unsupported', 'simulator-runtime-missing');
if (architecture !== 'arm64' && architecture !== 'x86_64') {
throw snapshotSourceError('unsupported', 'simulator-architecture-unsupported', {
architecture,
});
}
return {
xcode,
macosProductVersion,
macosBuild,
architecture,
simulatorSdk,
simulatorRuntime: runtime,
};
}
async function toolOutput(
host: SnapshotSourceHost,
command: string,
args: string[],
deadline: SnapshotSourceDeadline,
): Promise<string> {
const result = await host.run(command, args, {
allowFailure: true,
signal: deadline.signal,
timeoutMs: Math.min(10_000, remainingSnapshotSourceMs(deadline, 'toolchain-probe-deadline')),
});
if (result.exitCode !== 0) {
throw snapshotSourceError('unsupported', 'toolchain-probe-failed', {
command,
exitCode: result.exitCode,
stderr: result.stderr.slice(0, 1024),
});
}
const output = (result.stdout || result.stderr).trim();
if (!output) throw snapshotSourceError('unsupported', 'toolchain-probe-empty', { command });
return output;
}
@@ -0,0 +1,252 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'vitest';
import { createSnapshotSourceHost } from './host.ts';
import { ensureSnapshotBridgeBinary } from './cache.ts';
import { createSnapshotSourceDeadline } from './deadline.ts';
import { DEFAULT_SNAPSHOT_SOURCE_LIMITS } from './limits.ts';
import type { SnapshotSourceHost } from './types.ts';
test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt or stale entries', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-source-'));
const sourceRoot = path.join(root, 'source');
const cacheRoot = path.join(root, 'cache');
await writeFile(path.join(root, 'placeholder'), 'unused');
const sourceFile = path.join(sourceRoot, 'SnapshotBridge.m');
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
await writeFile(sourceFile, 'native source v1');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime v1');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header v1');
let builds = 0;
let xcodeVersion = 'Xcode 16.4\nBuild version 16F6';
const host = createFakeBuildHost(
() => {
builds += 1;
return `binary-${builds}`;
},
() => xcodeVersion,
);
try {
const first = await ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
deadline: testDeadline(),
sourceRoot,
cacheRoot,
});
assert.equal(builds, 1);
assert.equal(await readFile(first.path, 'utf8'), 'binary-1');
const hit = await ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
deadline: testDeadline(),
sourceRoot,
cacheRoot,
});
assert.equal(hit.path, first.path);
assert.equal(builds, 1);
await writeFile(path.join(sourceRoot, 'README.md'), 'documentation v1');
const differentTreeLimits = await ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: { ...DEFAULT_SNAPSHOT_SOURCE_LIMITS, maxNodes: 200, maxTraversalDepth: 12 },
deadline: testDeadline(),
sourceRoot,
cacheRoot,
});
assert.equal(differentTreeLimits.cacheKey, first.cacheKey);
assert.equal(builds, 1);
await writeFile(path.join(sourceRoot, 'README.md'), 'documentation v2');
const documentationChanged = await ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
deadline: testDeadline(),
sourceRoot,
cacheRoot,
});
assert.equal(documentationChanged.cacheKey, first.cacheKey);
assert.equal(builds, 1);
const manifest = JSON.parse(
await readFile(path.join(path.dirname(first.path), 'manifest.json'), 'utf8'),
) as { toolchain: { macosBuild: string } };
assert.equal(manifest.toolchain.macosBuild, '24G90');
await writeFile(first.path, 'corrupt');
await ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
deadline: testDeadline(),
sourceRoot,
cacheRoot,
});
assert.equal(builds, 2);
await writeFile(sourceFile, 'native source v2');
const sourceChanged = await ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
deadline: testDeadline(),
sourceRoot,
cacheRoot,
});
assert.notEqual(sourceChanged.sourceHash, first.sourceHash);
assert.equal(builds, 3);
xcodeVersion = 'Xcode 16.5\nBuild version 16F5';
const toolchainChanged = await ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
deadline: testDeadline(),
sourceRoot,
cacheRoot,
});
assert.notEqual(toolchainChanged.cacheKey, sourceChanged.cacheKey);
assert.equal(builds, 4);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test('concurrent snapshot bridge preparation publishes one cache entry', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-source-concurrent-'));
const sourceRoot = path.join(root, 'source');
const cacheRoot = path.join(root, 'cache');
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
let builds = 0;
const host = createFakeBuildHost(async () => {
builds += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
return `binary-${builds}`;
});
try {
const results = await Promise.all(
[1, 2].map(() =>
ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
deadline: testDeadline(),
sourceRoot,
cacheRoot,
}),
),
);
assert.equal(builds, 1);
assert.equal(results[0]?.path, results[1]?.path);
assert.equal(await readFile(results[0]!.path, 'utf8'), 'binary-1');
} finally {
await rm(root, { recursive: true, force: true });
}
});
test('an aborted cache waiter does not cancel an independent preparation', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-source-abort-'));
const sourceRoot = path.join(root, 'source');
const cacheRoot = path.join(root, 'cache');
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
let builds = 0;
let buildStarted!: () => void;
const started = new Promise<void>((resolve) => {
buildStarted = resolve;
});
const host = createFakeBuildHost(async () => {
builds += 1;
buildStarted();
await new Promise((resolve) => setTimeout(resolve, 40));
return `binary-${builds}`;
});
const controller = new AbortController();
try {
const canceled = ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: { ...DEFAULT_SNAPSHOT_SOURCE_LIMITS, maxDurationMs: 300 },
deadline: testDeadline(300, controller.signal),
sourceRoot,
cacheRoot,
});
await started;
controller.abort();
const survivor = ensureSnapshotBridgeBinary({
host,
runtime: 'iOS 26.2',
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
deadline: testDeadline(),
sourceRoot,
cacheRoot,
});
await expectRejectedCancellation(canceled);
const result = await survivor;
assert.equal(await readFile(result.path, 'utf8'), 'binary-2');
assert.equal(builds, 2);
} finally {
await rm(root, { recursive: true, force: true });
}
});
async function expectRejectedCancellation(value: Promise<unknown>): Promise<void> {
await assert.rejects(value, (error: unknown) => {
return (
error instanceof Error &&
'failureKind' in error &&
(error as { failureKind: string }).failureKind === 'cancelled'
);
});
}
function testDeadline(
timeoutMs = DEFAULT_SNAPSHOT_SOURCE_LIMITS.maxDurationMs,
signal?: AbortSignal,
) {
return createSnapshotSourceDeadline(timeoutMs, signal);
}
function createFakeBuildHost(
binary: string | (() => string | Promise<string>),
getXcode: () => string = () => 'Xcode 16.4\nBuild version 16F6',
): SnapshotSourceHost {
const real = createSnapshotSourceHost();
return {
...real,
run: async (command, args) => {
if (command === 'xcrun' && args.includes('clang')) {
const outputPath = args.at(-1)!;
const contents = typeof binary === 'function' ? await binary() : binary;
await writeFile(outputPath, contents);
return { stdout: '', stderr: '', exitCode: 0 };
}
const stdout =
command === 'xcodebuild'
? getXcode()
: command === 'sw_vers'
? args.includes('-buildVersion')
? '24G90'
: '15.6'
: command === 'uname'
? 'arm64'
: '26.2';
return { stdout, stderr: '', exitCode: 0 };
},
};
}
@@ -0,0 +1,240 @@
import { createHash } from 'node:crypto';
import path from 'node:path';
import { SnapshotSourceError, snapshotSourceError } from './errors.ts';
import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts';
import {
fingerprintSnapshotBridgeSource,
readSnapshotSourceToolchain,
SNAPSHOT_BRIDGE_COMPILE_FILENAMES,
SNAPSHOT_BRIDGE_SOURCE_FILENAMES,
type SnapshotSourceToolchainIdentity,
} from './cache-identity.ts';
import { SNAPSHOT_SOURCE_PROTOCOL_VERSION, SNAPSHOT_SOURCE_VERSION } from './protocol.ts';
import type {
SnapshotSourceBridgeBinary,
SnapshotSourceHost,
SnapshotSourceLimits,
} from './types.ts';
type SnapshotBridgeCacheManifest = Readonly<{
schemaVersion: 1;
protocolVersion: number;
sourceVersion: string;
sourceHash: string;
cacheKey: string;
toolchain: SnapshotSourceToolchainIdentity;
binarySha256: string;
}>;
const CACHE_SCHEMA_VERSION = 1 as const;
const BRIDGE_FILENAME = 'snapshot-bridge';
const MANIFEST_FILENAME = 'manifest.json';
const BUILD_TIMEOUT_MS = 120_000;
export async function ensureSnapshotBridgeBinary(
input: Readonly<{
host: SnapshotSourceHost;
runtime: string;
limits: SnapshotSourceLimits;
deadline: SnapshotSourceDeadline;
sourceRoot?: string;
cacheRoot?: string;
}>,
): Promise<SnapshotSourceBridgeBinary> {
const deadline = input.deadline;
const sourceRoot = input.sourceRoot ?? resolveSnapshotBridgeSourceRoot(input.host);
const sourceHash = await fingerprintSnapshotBridgeSource(input.host, sourceRoot, deadline);
const toolchain = await readSnapshotSourceToolchain(input.host, input.runtime, deadline);
const cacheKey = hashJson({
schemaVersion: CACHE_SCHEMA_VERSION,
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
sourceVersion: SNAPSHOT_SOURCE_VERSION,
sourceHash,
toolchain,
});
const cacheRoot =
input.cacheRoot ?? path.join(input.host.homeDirectory(), '.agent-device', 'snapshot-source');
const entryPath = path.join(cacheRoot, cacheKey);
const releaseLock = await input.host.acquireLock(path.join(cacheRoot, `${cacheKey}.lock`), {
deadline,
});
try {
const cached = await readValidCache(
input.host,
entryPath,
{
sourceHash,
cacheKey,
toolchain,
},
deadline,
);
if (cached) return cached;
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
if (input.host.exists(entryPath)) await input.host.remove(entryPath);
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
await input.host.ensureDirectory(cacheRoot);
const temporaryPath = path.join(cacheRoot, `.${cacheKey}.${input.host.processId()}.tmp`);
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
await input.host.remove(temporaryPath);
try {
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
await input.host.ensureDirectory(temporaryPath);
const outputPath = path.join(temporaryPath, BRIDGE_FILENAME);
const result = await input.host.run(
'xcrun',
[
'--sdk',
'iphonesimulator',
'clang',
'-arch',
toolchain.architecture,
'-mios-simulator-version-min=15.0',
'-fobjc-arc',
'-Werror',
'-Wall',
'-Wextra',
'-framework',
'Foundation',
'-framework',
'CoreGraphics',
...SNAPSHOT_BRIDGE_COMPILE_FILENAMES.map((sourceFile) =>
path.join(sourceRoot, sourceFile),
),
'-o',
outputPath,
],
{
signal: deadline.signal,
timeoutMs: Math.min(
BUILD_TIMEOUT_MS,
remainingSnapshotSourceMs(deadline, 'native-build-deadline'),
),
allowFailure: true,
},
);
if (result.exitCode !== 0 || !input.host.exists(outputPath)) {
throw snapshotSourceError('unsupported', 'native-build-failed', {
exitCode: result.exitCode,
stderr: result.stderr.slice(0, 4096),
});
}
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
await input.host.chmod(outputPath, 0o755);
const binarySha256 = await sha256File(input.host, outputPath, deadline);
const manifest: SnapshotBridgeCacheManifest = {
schemaVersion: CACHE_SCHEMA_VERSION,
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
sourceVersion: SNAPSHOT_SOURCE_VERSION,
sourceHash,
cacheKey,
toolchain,
binarySha256,
};
await input.host.writeText(
path.join(temporaryPath, MANIFEST_FILENAME),
`${JSON.stringify(manifest, null, 2)}\n`,
);
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
await input.host.rename(temporaryPath, entryPath);
return {
path: path.join(entryPath, BRIDGE_FILENAME),
sourceHash,
cacheKey,
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
sourceVersion: SNAPSHOT_SOURCE_VERSION,
};
} catch (error) {
await input.host.remove(temporaryPath);
throw error;
}
} finally {
await releaseLock();
}
}
function resolveSnapshotBridgeSourceRoot(host: SnapshotSourceHost): string {
const projectRoot = host.projectRoot();
const checkoutRoot = path.join(projectRoot, 'apple', 'snapshot-bridge');
if (
SNAPSHOT_BRIDGE_SOURCE_FILENAMES.every((sourceFile) =>
host.exists(path.join(checkoutRoot, sourceFile)),
)
) {
return checkoutRoot;
}
const packagedRoot = path.join(projectRoot, 'dist', 'apple', 'snapshot-bridge');
if (
SNAPSHOT_BRIDGE_SOURCE_FILENAMES.every((sourceFile) =>
host.exists(path.join(packagedRoot, sourceFile)),
)
) {
return packagedRoot;
}
throw snapshotSourceError('unsupported', 'native-source-missing', { projectRoot });
}
// fallow-ignore-next-line complexity
async function readValidCache(
host: SnapshotSourceHost,
entryPath: string,
expected: Readonly<{
sourceHash: string;
cacheKey: string;
toolchain: SnapshotSourceToolchainIdentity;
}>,
deadline: SnapshotSourceDeadline,
): Promise<SnapshotSourceBridgeBinary | undefined> {
const binaryPath = path.join(entryPath, BRIDGE_FILENAME);
if (!host.exists(binaryPath) || !host.exists(path.join(entryPath, MANIFEST_FILENAME))) {
return undefined;
}
try {
const manifest = JSON.parse(
await host.readText(path.join(entryPath, MANIFEST_FILENAME)),
) as Partial<SnapshotBridgeCacheManifest>;
if (
manifest.schemaVersion !== CACHE_SCHEMA_VERSION ||
manifest.protocolVersion !== SNAPSHOT_SOURCE_PROTOCOL_VERSION ||
manifest.sourceVersion !== SNAPSHOT_SOURCE_VERSION ||
manifest.sourceHash !== expected.sourceHash ||
manifest.cacheKey !== expected.cacheKey ||
JSON.stringify(manifest.toolchain) !== JSON.stringify(expected.toolchain) ||
typeof manifest.binarySha256 !== 'string'
) {
return undefined;
}
if ((await sha256File(host, binaryPath, deadline)) !== manifest.binarySha256) return undefined;
return {
path: binaryPath,
sourceHash: expected.sourceHash,
cacheKey: expected.cacheKey,
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
sourceVersion: SNAPSHOT_SOURCE_VERSION,
};
} catch (error) {
if (
error instanceof SnapshotSourceError &&
(error.failureKind === 'cancelled' || error.failureKind === 'timeout')
) {
throw error;
}
return undefined;
}
}
async function sha256File(
host: SnapshotSourceHost,
filePath: string,
deadline: SnapshotSourceDeadline,
): Promise<string> {
remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline');
return createHash('sha256')
.update(await host.readBinary(filePath))
.digest('hex');
}
function hashJson(value: unknown): string {
return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32);
}
@@ -0,0 +1,46 @@
import { Deadline } from '@agent-device/host-kit/retry';
import { snapshotSourceError } from './errors.ts';
export type SnapshotSourceDeadline = Readonly<{
clock: Deadline;
signal: AbortSignal | undefined;
}>;
export function createSnapshotSourceDeadline(
timeoutMs: number,
signal: AbortSignal | undefined,
): SnapshotSourceDeadline {
if (signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal');
return { clock: Deadline.fromTimeoutMs(timeoutMs), signal };
}
export function remainingSnapshotSourceMs(deadline: SnapshotSourceDeadline, code: string): number {
if (deadline.signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal');
const remainingMs = deadline.clock.remainingMs();
if (remainingMs <= 0) throw snapshotSourceError('timeout', code);
return Math.max(1, Math.floor(remainingMs));
}
export async function waitForSnapshotSourceDelay(
deadline: SnapshotSourceDeadline,
requestedMs: number,
code: string,
): Promise<void> {
const delayMs = Math.min(requestedMs, remainingSnapshotSourceMs(deadline, code));
await new Promise<void>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => finish(resolve), delayMs);
const onAbort = () => {
finish(() => reject(snapshotSourceError('cancelled', 'abort-signal')));
};
const finish = (action: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
deadline.signal?.removeEventListener('abort', onAbort);
action();
};
deadline.signal?.addEventListener('abort', onAbort, { once: true });
if (deadline.signal?.aborted) onAbort();
});
}
@@ -0,0 +1,70 @@
import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors';
import type { SnapshotSourceFailureKind } from './types.ts';
const APP_ERROR_CODE_BY_KIND: Readonly<Record<SnapshotSourceFailureKind, string>> = {
unsupported: 'UNSUPPORTED_OPERATION',
'malformed-tree': 'COMMAND_FAILED',
'stale-target': 'COMMAND_FAILED',
timeout: 'COMMAND_FAILED',
cancelled: 'COMMAND_FAILED',
'process-crash': 'COMMAND_FAILED',
'transport-failure': 'COMMAND_FAILED',
};
export class SnapshotSourceError extends AppError {
readonly failureKind: SnapshotSourceFailureKind;
readonly failureCode: string;
constructor(
kind: SnapshotSourceFailureKind,
code: string,
message = `iOS Simulator snapshot source ${kind}: ${code}`,
details: Readonly<Record<string, unknown>> = {},
cause?: unknown,
) {
super(
APP_ERROR_CODE_BY_KIND[kind],
message,
{
...details,
bridgeFailure: kind,
bridgeFailureCode: code,
...(kind === 'cancelled' ? { reason: 'request_canceled' } : {}),
},
cause,
);
this.name = 'SnapshotSourceError';
this.failureKind = kind;
this.failureCode = code;
}
}
export function snapshotSourceError(
kind: SnapshotSourceFailureKind,
code: string,
details: Readonly<Record<string, unknown>> = {},
cause?: unknown,
): SnapshotSourceError {
return new SnapshotSourceError(kind, code, undefined, details, cause);
}
export function asSnapshotSourceError(error: unknown): SnapshotSourceError {
if (error instanceof SnapshotSourceError) return error;
if (isRequestCanceledError(error)) {
return snapshotSourceError('cancelled', 'abort-signal', {}, error);
}
if (error instanceof AppError && typeof error.details?.timeoutMs === 'number') {
return snapshotSourceError(
'timeout',
'host-operation-timeout',
{ timeoutMs: error.details.timeoutMs },
error,
);
}
return snapshotSourceError(
'transport-failure',
'unexpected-host-error',
{ error: error instanceof Error ? error.message : String(error) },
error,
);
}
@@ -0,0 +1,40 @@
{
"protocolVersion": 1,
"sourceVersion": "agent-device-simulator-ax-v1.5.3",
"requestKeys": [
"verb",
"requestId",
"pid",
"generation",
"snapshotTree",
"automationMode",
"maxDepth",
"maxNodes",
"maxDurationMs",
"maxResponseBytes"
],
"responseKeys": [
"protocolVersion",
"sourceVersion",
"requestId",
"generation",
"ok",
"pid",
"tree",
"truncated",
"automationEnabled",
"error_kind",
"error_code",
"error"
],
"attributeKeys": [
"XC_kAXXCAttributeElementType",
"XC_kAXXCAttributeElementBaseType",
"XC_kAXXCAttributeLabel",
"XC_kAXXCAttributeValue",
"XC_kAXXCAttributeIdentifier",
"XC_kAXXCAttributeFrame",
"XC_kAXXCAttributeAutomationType",
"XC_kAXXCAttributeChildren"
]
}
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts';
test('snapshot bridge socket paths stay within the AF_UNIX limit and are target-specific', () => {
const host = createSnapshotSourceHost();
const first = snapshotSourceSocketPath(host, 'simulator-1', 'owner-1');
const second = snapshotSourceSocketPath(host, 'simulator-2', 'owner-1');
const otherOwner = snapshotSourceSocketPath(host, 'simulator-1', 'owner-2');
assert.equal(first.length < 104, true);
assert.equal(second.length < 104, true);
assert.equal(otherOwner.length < 104, true);
assert.notEqual(first, second);
assert.notEqual(first, otherOwner);
});
@@ -0,0 +1,246 @@
import { createHash } from 'node:crypto';
import net from 'node:net';
import path from 'node:path';
import { runCmd, runCmdBackground } from '@agent-device/host-kit/command';
import { acquireProcessLock } from '@agent-device/host-kit/file';
import {
chmodHostFile,
ensureHostDirectory,
hostFileExistsSync,
hostHomeDirectory,
readHostBinaryFile,
readHostTextFile,
removeHostPath,
renameHostPath,
writeHostTextFile,
} from '@agent-device/host-kit/host-file';
import {
hostProcessId,
readProcessStartTime,
signalProcessGroupBestEffort,
} from '@agent-device/host-kit/process';
import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics';
import { findProjectRoot } from '@agent-device/host-kit/version';
import { SnapshotSourceError, snapshotSourceError } from './errors.ts';
import { remainingSnapshotSourceMs } from './deadline.ts';
import type { SnapshotSourceHost, SnapshotSourceProcess, SnapshotSourceSocket } from './types.ts';
const BRIDGE_IDLE_TIMEOUT_SECONDS = 60;
const MAX_PROCESS_LOG_BYTES = 64 * 1024;
const SNAPSHOT_SOCKET_ROOT = '/tmp';
export function createSnapshotSourceHost(): SnapshotSourceHost {
return {
projectRoot: findProjectRoot,
homeDirectory: hostHomeDirectory,
run: async (command, args, options) => await runCmd(command, args, options),
start: startSnapshotBridge,
connect: connectSnapshotBridge,
readText: readHostTextFile,
readBinary: readHostBinaryFile,
writeText: writeHostTextFile,
ensureDirectory: ensureHostDirectory,
chmod: chmodHostFile,
exists: hostFileExistsSync,
rename: renameHostPath,
remove: removeHostPath,
acquireLock: acquireSnapshotSourceLock,
emitDiagnostic,
withDiagnosticTimer,
processId: hostProcessId,
readTargetProcessStartTime,
};
}
async function readTargetProcessStartTime(
pid: number,
options: { signal?: AbortSignal; timeoutMs: number },
): Promise<string | null> {
const result = await runCmd('ps', ['-p', String(pid), '-o', 'lstart='], {
allowFailure: true,
signal: options.signal,
timeoutMs: options.timeoutMs,
});
if (result.exitCode !== 0) return null;
return result.stdout.trim() || null;
}
function startSnapshotBridge(
udid: string,
bridgePath: string,
socketPath: string,
options: { signal?: AbortSignal } = {},
): SnapshotSourceProcess {
if (options.signal?.aborted) {
throw snapshotSourceError('cancelled', 'abort-signal');
}
const started = runCmdBackground(
'xcrun',
[
'simctl',
'spawn',
udid,
bridgePath,
'serve',
socketPath,
'--idle-timeout',
String(BRIDGE_IDLE_TIMEOUT_SECONDS),
'--exit-on-disconnect',
'false',
],
{
allowFailure: true,
captureOutput: false,
detached: true,
},
);
const pid = started.child.pid ?? 0;
if (pid <= 0) {
throw snapshotSourceError('transport-failure', 'bridge-process-pid-missing');
}
let log = '';
started.child.stderr?.setEncoding('utf8');
started.child.stderr?.on('data', (chunk: string | Buffer) => {
log = appendBoundedLog(log, String(chunk));
});
return {
pid,
wait: started.wait,
isAlive: () => started.child.exitCode === null && started.child.signalCode === null,
signal: (signal) => {
if (!signalProcessGroupBestEffort(pid, signal)) {
started.child.kill(signal);
}
},
readLog: () => log,
};
}
async function connectSnapshotBridge(
socketPath: string,
options: { signal?: AbortSignal; timeoutMs: number },
): Promise<SnapshotSourceSocket> {
if (options.signal?.aborted) {
throw snapshotSourceError('cancelled', 'abort-signal');
}
const timeoutMs = Math.max(1, Math.floor(options.timeoutMs));
return await new Promise<SnapshotSourceSocket>((resolve, reject) => {
const socket = net.createConnection({ path: socketPath });
let connected = false;
let settled = false;
const timer = setTimeout(() => {
finish(snapshotSourceError('timeout', 'bridge-connect-timeout'));
socket.destroy();
}, timeoutMs);
const onAbort = () => {
finish(snapshotSourceError('cancelled', 'abort-signal'));
socket.destroy();
};
const onConnect = () => {
connected = true;
clearTimeout(timer);
options.signal?.removeEventListener('abort', onAbort);
socket.off('error', onError);
socket.off('close', onClose);
socket.setTimeout(0);
resolve(socket);
};
const onError = (error: Error) => {
finish(error);
socket.destroy();
};
const onClose = () => {
if (!connected)
finish(snapshotSourceError('transport-failure', 'bridge-closed-before-connect'));
};
const finish = (error: unknown) => {
if (settled || connected) return;
settled = true;
clearTimeout(timer);
options.signal?.removeEventListener('abort', onAbort);
socket.off('connect', onConnect);
socket.off('error', onError);
socket.off('close', onClose);
reject(error);
};
socket.once('connect', onConnect);
socket.once('error', onError);
socket.once('close', onClose);
options.signal?.addEventListener('abort', onAbort, { once: true });
if (options.signal?.aborted) onAbort();
});
}
async function acquireSnapshotSourceLock(
lockPath: string,
options: Parameters<SnapshotSourceHost['acquireLock']>[1],
): Promise<() => Promise<void>> {
const pid = hostProcessId();
const deadline = options.deadline;
const pending = acquireProcessLock({
lockDirPath: lockPath,
owner: {
pid,
startTime: readProcessStartTime(pid),
acquiredAtMs: Date.now(),
},
timeoutMs: remainingSnapshotSourceMs(deadline, 'cache-lock-deadline'),
pollMs: 100,
ownerGraceMs: 5_000,
description: 'iOS Simulator snapshot bridge cache',
});
const signal = deadline.signal;
if (!signal) return await pending;
let canceled = false;
let onAbort!: () => void;
const aborted = new Promise<never>((_, reject) => {
onAbort = () => {
canceled = true;
reject(snapshotSourceError('cancelled', 'abort-signal'));
};
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
});
try {
return await Promise.race([pending, aborted]);
} catch (error) {
if (canceled)
void pending.then(
(release) => release(),
() => undefined,
);
if (
deadline.clock.isExpired() &&
!(error instanceof SnapshotSourceError && error.failureKind === 'cancelled')
) {
throw snapshotSourceError('timeout', 'cache-lock-deadline');
}
throw error;
} finally {
signal.removeEventListener('abort', onAbort);
}
}
function appendBoundedLog(current: string, addition: string): string {
const combined = current + addition;
return combined.length <= MAX_PROCESS_LOG_BYTES
? combined
: combined.slice(combined.length - MAX_PROCESS_LOG_BYTES);
}
export function snapshotSourceSocketPath(
host: SnapshotSourceHost,
udid: string,
ownerId: string,
): string {
const targetKey = createHash('sha256').update(udid).digest('hex').slice(0, 12);
const ownerKey = createHash('sha256').update(ownerId).digest('hex').slice(0, 12);
return path.join(
SNAPSHOT_SOCKET_ROOT,
`agent-device-ax-${targetKey}-${host.processId()}-${ownerKey}`,
'snapshot.sock',
);
}
@@ -0,0 +1,526 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { test } from 'vitest';
import { createSnapshotSourceHost } from './host.ts';
import { SnapshotSourceError, snapshotSourceError } from './errors.ts';
import { createSnapshotSourceDeadline } from './deadline.ts';
import {
encodeSnapshotBridgeFrame,
SNAPSHOT_SOURCE_PROTOCOL_VERSION,
SNAPSHOT_SOURCE_VERSION,
} from './protocol.ts';
import { SnapshotBridgeManager } from './lifecycle.ts';
import type {
SnapshotSourceHost,
SnapshotSourceLimits,
SnapshotSourceProcess,
SnapshotSourceSocket,
} from './types.ts';
const limits: SnapshotSourceLimits = {
maxRequestBytes: 64 * 1024,
maxResponseBytes: 4 * 1024,
maxNodes: 20,
maxTraversalDepth: 10,
maxDurationMs: 100,
};
const target = {
udid: 'simulator-1',
runtime: 'iOS 26.2',
pid: 123,
generation: 'generation-1',
};
const bridge = {
path: '/tmp/snapshot-bridge',
sourceHash: 'source-hash',
cacheKey: 'cache-key',
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
sourceVersion: SNAPSHOT_SOURCE_VERSION,
};
test('the bridge manager reuses a healthy per-device helper and stops it exactly once', async () => {
const fixture = createLifecycleFixture();
const manager = new SnapshotBridgeManager(fixture.host);
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
assert.equal(fixture.processes.length, 1);
assert.equal(fixture.sockets.length, 1);
await manager.close();
assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']);
});
test('a new target generation reuses the healthy helper and carries generation per request', async () => {
const fixture = createLifecycleFixture();
const manager = new SnapshotBridgeManager(fixture.host);
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
await manager.request({
target: { ...target, generation: 'generation-2' },
bridge,
limits,
maxDepth: 10,
deadline: deadline(),
});
assert.equal(fixture.processes.length, 1);
assert.deepEqual(fixture.processes[0]!.signals, []);
await manager.close();
assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']);
});
test('cancellation while queued prevents a later dispatch', async () => {
const fixture = createLifecycleFixture({ responseDelayMs: 200 });
const manager = new SnapshotBridgeManager(fixture.host);
const first = manager.request({
target,
bridge,
limits,
maxDepth: 10,
deadline: deadline(undefined, 1000),
});
await waitForDispatch(fixture);
const controller = new AbortController();
const startedAt = Date.now();
const queued = manager.request({
target,
bridge,
limits,
maxDepth: 10,
deadline: deadline(controller.signal, 1000),
});
setTimeout(() => controller.abort(), 10);
await assert.rejects(
queued,
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled',
);
assert.ok(Date.now() - startedAt < 100);
assert.equal(fixture.sockets[0]?.writes, 1);
await first;
assert.equal(fixture.sockets.length, 1);
await manager.close();
});
test('cancelling a middle waiter does not release the following request early', async () => {
const fixture = createLifecycleFixture({ responseDelayMs: 120 });
const manager = new SnapshotBridgeManager(fixture.host);
const first = manager.request({
target,
bridge,
limits,
maxDepth: 10,
deadline: deadline(undefined, 1000),
});
await waitForDispatch(fixture);
const controller = new AbortController();
const middle = manager.request({
target,
bridge,
limits,
maxDepth: 10,
deadline: deadline(controller.signal, 1000),
});
const last = manager.request({
target,
bridge,
limits,
maxDepth: 10,
deadline: deadline(undefined, 1000),
});
controller.abort();
await assert.rejects(
middle,
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled',
);
assert.equal(fixture.sockets[0]?.writes, 1);
await first;
await last;
assert.equal(fixture.sockets[0]?.writes, 2);
await manager.close();
});
test('independent managers own distinct sockets for the same Simulator', async () => {
const fixture = createLifecycleFixture();
const first = new SnapshotBridgeManager(fixture.host);
const second = new SnapshotBridgeManager(fixture.host);
await first.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
await second.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
assert.equal(fixture.socketPaths.length, 2);
assert.notEqual(fixture.socketPaths[0], fixture.socketPaths[1]);
await first.close();
assert.equal(fixture.processes[1]?.isAlive(), true);
await second.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
await second.close();
});
test('request cancellation after dispatch reaps the exact helper before recovery', async () => {
const fixture = createLifecycleFixture({ responseDelayMs: 80 });
const manager = new SnapshotBridgeManager(fixture.host);
const controller = new AbortController();
const request = manager.request({
target,
bridge,
limits,
maxDepth: 10,
deadline: deadline(controller.signal),
});
setTimeout(() => controller.abort(), 10);
await assert.rejects(
request,
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled',
);
assert.deepEqual(fixture.processes[0]?.signals, ['SIGTERM']);
assert.equal(fixture.processes[0]?.alive, false);
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
assert.equal(fixture.processes.length, 2);
await manager.close();
assert.deepEqual(fixture.processes[1]?.signals, ['SIGTERM']);
});
test('pre-dispatch cancellation preserves a healthy helper', async () => {
const fixture = createLifecycleFixture({ connectDelayMs: 40 });
const manager = new SnapshotBridgeManager(fixture.host);
await manager.request({
target,
bridge,
limits: { ...limits, maxDurationMs: 500 },
maxDepth: 10,
deadline: deadline(undefined, 500),
});
fixture.sockets[0]!.destroy();
const controller = new AbortController();
const request = manager.request({
target,
bridge,
limits: { ...limits, maxDurationMs: 500 },
maxDepth: 10,
deadline: deadline(controller.signal, 500),
});
setTimeout(() => controller.abort(), 10);
await assert.rejects(
request,
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled',
);
assert.equal(fixture.processes.length, 1);
assert.deepEqual(fixture.processes[0]?.signals, []);
assert.equal(fixture.processes[0]?.alive, true);
await manager.request({
target,
bridge,
limits: { ...limits, maxDurationMs: 500 },
maxDepth: 10,
deadline: deadline(undefined, 500),
});
assert.equal(fixture.processes.length, 1);
await manager.close();
assert.deepEqual(fixture.processes[0]?.signals, ['SIGTERM']);
});
test('one absolute deadline covers helper connect and response read', async () => {
const fixture = createLifecycleFixture({ connectDelayMs: 70, responseDelayMs: 70 });
const manager = new SnapshotBridgeManager(fixture.host);
await assert.rejects(
manager.request({
target,
bridge,
limits: { ...limits, maxDurationMs: 100 },
maxDepth: 10,
deadline: deadline(undefined, 100),
}),
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout',
);
assert.deepEqual(fixture.processes[0]?.signals, ['SIGTERM']);
await manager.close();
});
test('a crashed helper is removed and the next request starts a fresh helper', async () => {
const fixture = createLifecycleFixture({ responseDelayMs: 80 });
const manager = new SnapshotBridgeManager(fixture.host);
const request = manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
setTimeout(() => fixture.processes[0]?.crash(), 10);
await assert.rejects(
request,
(error: unknown) =>
error instanceof SnapshotSourceError && error.failureKind === 'process-crash',
);
assert.equal(fixture.processes[0]?.signals.length, 0);
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
assert.equal(fixture.processes.length, 2);
await manager.close();
});
test('a crashed helper emits its bounded log once and keeps exit facts typed', async () => {
const fixture = createLifecycleFixture({ responseDelayMs: 80 });
const manager = new SnapshotBridgeManager(fixture.host);
const request = manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
setTimeout(() => fixture.processes[0]?.crash(), 10);
let failure: SnapshotSourceError | undefined;
await assert.rejects(request, (error: unknown) => {
failure = error instanceof SnapshotSourceError ? error : undefined;
return failure?.failureKind === 'process-crash';
});
assert.equal(failure?.details?.pid, 700);
assert.equal(failure?.details?.exitCode, 1);
assert.equal(failure?.details?.log, undefined);
const processDiagnostics = fixture.diagnostics.filter(
(event) => event.phase === 'ios.snapshot-source.bridge-process-exit',
);
assert.equal(processDiagnostics.length, 1);
assert.equal(processDiagnostics[0]?.data?.pid, 700);
assert.equal(processDiagnostics[0]?.data?.stderr, 'fixture log');
await manager.close();
});
test('the manager rejects a response for a different target process as stale', async () => {
const fixture = createLifecycleFixture({ responsePid: target.pid + 1 });
const manager = new SnapshotBridgeManager(fixture.host);
await assert.rejects(
manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }),
(error: unknown) =>
error instanceof SnapshotSourceError && error.failureKind === 'stale-target',
);
await manager.close();
});
test('the manager rejects a response carrying a previous target generation as stale', async () => {
const fixture = createLifecycleFixture({ responseGeneration: 'generation-0' });
const manager = new SnapshotBridgeManager(fixture.host);
await assert.rejects(
manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }),
(error: unknown) =>
error instanceof SnapshotSourceError &&
error.failureKind === 'stale-target' &&
error.failureCode === 'bridge-generation-mismatch',
);
await manager.close();
});
test('the manager rejects a tree when the target process changes during acquisition', async () => {
const fixture = createLifecycleFixture({ targetStartTimes: ['start-1', 'start-2'] });
const manager = new SnapshotBridgeManager(fixture.host);
await assert.rejects(
manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }),
(error: unknown) =>
error instanceof SnapshotSourceError &&
error.failureKind === 'stale-target' &&
error.failureCode === 'target-process-changed',
);
await manager.close();
});
test('typed guest failures retain their kind after target validation', async () => {
const fixture = createLifecycleFixture({ responseErrorKind: 'application_not_responding' });
const manager = new SnapshotBridgeManager(fixture.host);
await assert.rejects(
manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }),
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout',
);
await manager.close();
});
type LifecycleFixture = {
host: SnapshotSourceHost;
processes: FakeProcess[];
sockets: FakeSocket[];
diagnostics: Array<Parameters<SnapshotSourceHost['emitDiagnostic']>[0]>;
socketPaths: string[];
};
function deadline(signal?: AbortSignal, timeoutMs = limits.maxDurationMs) {
return createSnapshotSourceDeadline(timeoutMs, signal);
}
async function waitForDispatch(fixture: LifecycleFixture): Promise<void> {
const startedAt = Date.now();
while (fixture.sockets[0]?.writes !== 1) {
if (Date.now() - startedAt >= 1000) throw new Error('Fixture request was not dispatched');
await new Promise((resolve) => setTimeout(resolve, 1));
}
}
function createLifecycleFixture(
options: {
connectDelayMs?: number;
responseDelayMs?: number;
responsePid?: number;
responseGeneration?: string;
responseErrorKind?: string;
targetStartTimes?: Array<string | null>;
} = {},
): LifecycleFixture {
const processes: FakeProcess[] = [];
const sockets: FakeSocket[] = [];
const diagnostics: LifecycleFixture['diagnostics'] = [];
const socketPaths: string[] = [];
const realHost = createSnapshotSourceHost();
const host: SnapshotSourceHost = {
...realHost,
emitDiagnostic: (event) => diagnostics.push(event),
readTargetProcessStartTime: async () => options.targetStartTimes?.shift() ?? 'target-start',
start: (_udid, _bridgePath, socketPath) => {
socketPaths.push(socketPath);
const process = new FakeProcess(700 + processes.length);
processes.push(process);
return process;
},
connect: async (_socketPath, connectOptions) => {
if (options.connectDelayMs) {
await new Promise<void>((resolve, reject) => {
const finish = (error?: SnapshotSourceError) => {
clearTimeout(timer);
connectOptions.signal?.removeEventListener('abort', onAbort);
if (error) reject(error);
else resolve();
};
const timer = setTimeout(() => finish(), options.connectDelayMs);
const onAbort = () => {
finish(snapshotSourceError('cancelled', 'abort-signal'));
};
connectOptions.signal?.addEventListener('abort', onAbort, { once: true });
if (connectOptions.signal?.aborted) onAbort();
});
}
const socket = new FakeSocket(
options.responseDelayMs ?? 0,
options.responsePid ?? target.pid,
options.responseGeneration,
options.responseErrorKind,
);
sockets.push(socket);
return socket;
},
};
return { host, processes, sockets, diagnostics, socketPaths };
}
class FakeProcess implements SnapshotSourceProcess {
alive = true;
signals: NodeJS.Signals[] = [];
readonly wait: Promise<{ stdout: string; stderr: string; exitCode: number }>;
private readonly processId: number;
private resolveWait!: (result: { stdout: string; stderr: string; exitCode: number }) => void;
constructor(pid: number) {
this.processId = pid;
this.wait = new Promise((resolve) => {
this.resolveWait = resolve;
});
}
get pid(): number {
return this.processId;
}
isAlive(): boolean {
return this.alive;
}
signal(signal: NodeJS.Signals): void {
this.signals.push(signal);
this.alive = false;
this.resolveWait({ stdout: '', stderr: '', exitCode: signal === 'SIGKILL' ? 137 : 0 });
}
crash(): void {
this.alive = false;
this.resolveWait({ stdout: '', stderr: 'crashed', exitCode: 1 });
}
readLog(): string {
return 'fixture log';
}
}
class FakeSocket extends EventEmitter implements SnapshotSourceSocket {
destroyed = false;
writes = 0;
private readonly responseDelayMs: number;
private readonly responsePid: number;
private readonly responseGeneration: string | undefined;
private readonly responseErrorKind: string | undefined;
constructor(
responseDelayMs: number,
responsePid: number,
responseGeneration: string | undefined,
responseErrorKind?: string,
) {
super();
this.responseDelayMs = responseDelayMs;
this.responsePid = responsePid;
this.responseGeneration = responseGeneration;
this.responseErrorKind = responseErrorKind;
}
write(frame: Buffer): boolean {
this.writes += 1;
const bodyLength = frame.readUInt32BE(0);
const request = JSON.parse(frame.subarray(4, bodyLength + 4).toString('utf8')) as {
requestId: string;
pid: number;
generation: string;
};
setTimeout(() => {
if (this.destroyed) return;
this.emit(
'data',
encodeSnapshotBridgeFrame(
this.responseErrorKind
? {
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
sourceVersion: SNAPSHOT_SOURCE_VERSION,
requestId: request.requestId,
ok: false,
pid: this.responsePid || request.pid,
generation: this.responseGeneration ?? request.generation,
error_kind: this.responseErrorKind,
error_code: 'fixture-error',
}
: {
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
sourceVersion: SNAPSHOT_SOURCE_VERSION,
requestId: request.requestId,
ok: true,
pid: this.responsePid || request.pid,
generation: this.responseGeneration ?? request.generation,
truncated: false,
automationEnabled: true,
tree: {
XC_kAXXCAttributeElementType: 'Application',
XC_kAXXCAttributeChildren: [],
},
},
limits,
),
);
}, this.responseDelayMs);
return true;
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
queueMicrotask(() => this.emit('close'));
}
}
@@ -0,0 +1,317 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { asSnapshotSourceError, snapshotSourceError, SnapshotSourceError } from './errors.ts';
import {
remainingSnapshotSourceMs,
waitForSnapshotSourceDelay,
type SnapshotSourceDeadline,
} from './deadline.ts';
import { snapshotSourceSocketPath } from './host.ts';
import { bridgeProcessExited } from './process.ts';
import { createSnapshotBridgeDescribeRequest, encodeSnapshotBridgeFrame } from './protocol.ts';
import { roundTripSnapshotBridge } from './transport.ts';
import type {
SnapshotSourceBridgeBinary,
SnapshotSourceHost,
SnapshotSourceLimits,
SnapshotSourceProcess,
SnapshotSourceSocket,
SnapshotSourceTarget,
} from './types.ts';
import type { SnapshotBridgeEnvelope } from './protocol.ts';
const CONNECT_RETRY_DELAY_MS = 20;
const CONNECT_ATTEMPT_TIMEOUT_MS = 250;
const SHUTDOWN_TERM_TIMEOUT_MS = 500;
const SHUTDOWN_KILL_TIMEOUT_MS = 500;
type BridgeSession = {
readonly udid: string;
readonly bridgePath: string;
readonly socketPath: string;
readonly process: SnapshotSourceProcess;
socket?: SnapshotSourceSocket;
};
type SnapshotBridgeRequest = Readonly<{
target: SnapshotSourceTarget;
bridge: SnapshotSourceBridgeBinary;
limits: SnapshotSourceLimits;
maxDepth: number;
deadline: SnapshotSourceDeadline;
}>;
export class SnapshotBridgeManager {
private readonly sessions = new Map<string, BridgeSession>();
private readonly requestQueues = new Map<string, Promise<void>>();
private readonly ownerId = randomUUID();
private closed = false;
private readonly host: SnapshotSourceHost;
constructor(host: SnapshotSourceHost) {
this.host = host;
}
async request(input: SnapshotBridgeRequest): Promise<SnapshotBridgeEnvelope> {
if (this.closed) throw snapshotSourceError('unsupported', 'source-closed');
return await this.withSimulatorLock(input.target.udid, input.deadline, () =>
this.requestInSimulator(input),
);
}
private async requestInSimulator(input: SnapshotBridgeRequest): Promise<SnapshotBridgeEnvelope> {
if (this.closed) throw snapshotSourceError('unsupported', 'source-closed');
const deadline = input.deadline;
remainingSnapshotSourceMs(deadline, 'bridge-request-deadline');
const previousSession = this.sessions.get(input.target.udid);
const session = await this.ensureSession(input, deadline);
try {
const targetStartTime = await this.readTargetStartTime(input.target, deadline);
const envelope = await this.exchange(session, input, deadline);
await this.assertTargetStillCurrent(input.target, targetStartTime, deadline);
return envelope;
} catch (error) {
const normalized = await this.handleRequestFailure(error, session, previousSession);
throw normalized;
}
}
private async handleRequestFailure(
error: unknown,
session: BridgeSession,
previousSession: BridgeSession | undefined,
): Promise<SnapshotSourceError> {
const normalized = asSnapshotSourceError(error);
if (shouldDiscardSession(normalized, session, previousSession)) {
await this.removeSession(session, true);
} else if (normalized.failureKind === 'process-crash') {
await this.removeSession(session, false);
} else if (normalized.failureKind === 'transport-failure') {
session.socket?.destroy();
session.socket = undefined;
}
return normalized;
}
private async withSimulatorLock<T>(
udid: string,
deadline: SnapshotSourceDeadline,
action: () => Promise<T>,
): Promise<T> {
const previous = this.requestQueues.get(udid) ?? Promise.resolve();
let release!: () => void;
const turnFinished = new Promise<void>((resolve) => {
release = resolve;
});
const current = previous.then(() => turnFinished);
this.requestQueues.set(udid, current);
try {
await waitForSimulatorTurn(previous, deadline);
return await action();
} finally {
release();
if (this.requestQueues.get(udid) === current) this.requestQueues.delete(udid);
}
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
const sessions = [...this.sessions.values()];
this.sessions.clear();
await Promise.all(sessions.map(async (session) => await this.dispose(session, true)));
}
private async ensureSession(
input: SnapshotBridgeRequest,
deadline: SnapshotSourceDeadline,
): Promise<BridgeSession> {
const key = input.target.udid;
const existing = this.sessions.get(key);
if (existing && existing.bridgePath === input.bridge.path && existing.process.isAlive()) {
if (!existing.socket || existing.socket.destroyed) {
existing.socket = await this.connectUntilReady(existing, deadline);
}
return existing;
}
if (existing) await this.removeSession(existing, true);
const socketPath = snapshotSourceSocketPath(this.host, input.target.udid, this.ownerId);
await this.host.ensureDirectory(path.dirname(socketPath));
await this.host.remove(socketPath);
const bridgeProcess = this.host.start(input.target.udid, input.bridge.path, socketPath, {
signal: deadline.signal,
});
const session: BridgeSession = {
udid: input.target.udid,
bridgePath: input.bridge.path,
socketPath,
process: bridgeProcess,
};
this.sessions.set(key, session);
try {
session.socket = await this.connectUntilReady(session, deadline);
return session;
} catch (error) {
await this.removeSession(session, true);
throw asSnapshotSourceError(error);
}
}
private async connectUntilReady(
session: BridgeSession,
deadline: SnapshotSourceDeadline,
): Promise<SnapshotSourceSocket> {
let lastError: unknown;
while (true) {
const remainingMs = remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline');
if (!session.process.isAlive()) {
throw await bridgeProcessExited(this.host, session.process);
}
try {
return await this.host.connect(session.socketPath, {
signal: deadline.signal,
timeoutMs: Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs),
});
} catch (error) {
lastError = error;
if (error instanceof SnapshotSourceError && error.failureKind === 'cancelled') {
throw error;
}
const delayMs = Math.min(
CONNECT_RETRY_DELAY_MS,
remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline'),
);
try {
await waitForSnapshotSourceDelay(deadline, delayMs, 'bridge-connect-deadline');
} catch (sleepError) {
throw asSnapshotSourceError(sleepError);
}
}
if (lastError instanceof SnapshotSourceError && lastError.failureKind === 'timeout') {
throw lastError;
}
}
}
private async exchange(
session: BridgeSession,
input: SnapshotBridgeRequest,
deadline: SnapshotSourceDeadline,
): Promise<SnapshotBridgeEnvelope> {
if (!session.socket || session.socket.destroyed) {
session.socket = await this.connectUntilReady(session, deadline);
}
const requestId = randomUUID();
const request = createSnapshotBridgeDescribeRequest({
requestId,
pid: input.target.pid,
generation: input.target.generation,
maxDepth: Math.min(input.limits.maxTraversalDepth, input.maxDepth),
maxNodes: input.limits.maxNodes,
maxDurationMs: remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'),
maxResponseBytes: input.limits.maxResponseBytes,
});
const frame = encodeSnapshotBridgeFrame(request, input.limits);
return await roundTripSnapshotBridge({
process: session.process,
socket: session.socket,
frame,
requestId,
deadline,
limits: input.limits,
expectedPid: input.target.pid,
expectedGeneration: input.target.generation,
host: this.host,
});
}
private async readTargetStartTime(
target: SnapshotSourceTarget,
deadline: SnapshotSourceDeadline,
): Promise<string> {
const startTime = await this.host.readTargetProcessStartTime(target.pid, {
signal: deadline.signal,
timeoutMs: remainingSnapshotSourceMs(deadline, 'target-identity-deadline'),
});
if (!startTime) {
throw snapshotSourceError('stale-target', 'target-process-unavailable', {
pid: target.pid,
generation: target.generation,
});
}
return startTime;
}
private async assertTargetStillCurrent(
target: SnapshotSourceTarget,
expectedStartTime: string,
deadline: SnapshotSourceDeadline,
): Promise<void> {
const observedStartTime = await this.host.readTargetProcessStartTime(target.pid, {
signal: deadline.signal,
timeoutMs: remainingSnapshotSourceMs(deadline, 'target-identity-deadline'),
});
if (observedStartTime !== expectedStartTime) {
throw snapshotSourceError('stale-target', 'target-process-changed', {
pid: target.pid,
generation: target.generation,
expectedStartTime,
observedStartTime,
});
}
}
private async removeSession(session: BridgeSession, stopProcess: boolean): Promise<void> {
if (this.sessions.get(session.udid) === session) this.sessions.delete(session.udid);
await this.dispose(session, stopProcess);
}
private async dispose(session: BridgeSession, stopProcess: boolean): Promise<void> {
session.socket?.destroy();
session.socket = undefined;
if (stopProcess && session.process.isAlive()) {
session.process.signal('SIGTERM');
await waitForProcess(session.process, SHUTDOWN_TERM_TIMEOUT_MS);
if (session.process.isAlive()) {
session.process.signal('SIGKILL');
await waitForProcess(session.process, SHUTDOWN_KILL_TIMEOUT_MS);
}
}
await this.host.remove(session.socketPath);
}
}
async function waitForSimulatorTurn(
previous: Promise<void>,
deadline: SnapshotSourceDeadline,
): Promise<void> {
const timeoutMs = remainingSnapshotSourceMs(deadline, 'bridge-request-deadline');
await Promise.race([
previous,
waitForSnapshotSourceDelay(deadline, timeoutMs, 'bridge-request-deadline').then(() => {
throw snapshotSourceError('timeout', 'bridge-request-deadline');
}),
]);
}
function shouldDiscardSession(
error: SnapshotSourceError,
session: BridgeSession,
previousSession: BridgeSession | undefined,
): boolean {
return (
(error.failureKind === 'cancelled' || error.failureKind === 'timeout') &&
(error.details?.dispatched === true || previousSession !== session)
);
}
async function waitForProcess(
bridgeProcess: SnapshotSourceProcess,
timeoutMs: number,
): Promise<void> {
await Promise.race([
bridgeProcess.wait.catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
}
@@ -0,0 +1,78 @@
import { AppError } from '@agent-device/kernel/errors';
import type { SnapshotSourceLimits } from './types.ts';
const FRAME_HEADER_BYTES = 4;
const MAXIMUM_FRAME_BYTES = 16 * 1024 * 1024;
const MAXIMUM_DURATION_MS = 120_000;
const MAXIMUM_DEPTH = 128;
const MAXIMUM_NODES = 10_000;
export const DEFAULT_SNAPSHOT_SOURCE_LIMITS: SnapshotSourceLimits = Object.freeze({
maxRequestBytes: 64 * 1024,
maxResponseBytes: 4 * 1024 * 1024,
maxNodes: 1500,
maxTraversalDepth: 64,
maxDurationMs: 5_000,
});
export function resolveSnapshotSourceLimits(
overrides: Partial<SnapshotSourceLimits> | undefined,
): SnapshotSourceLimits {
const limits = { ...DEFAULT_SNAPSHOT_SOURCE_LIMITS, ...(overrides ?? {}) };
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new AppError(
'INVALID_ARGS',
`Snapshot source limit ${name} must be a positive integer`,
{
name,
value,
},
);
}
}
if (limits.maxRequestBytes > limits.maxResponseBytes) {
throw new AppError(
'INVALID_ARGS',
'Snapshot source request bytes cannot exceed response bytes',
{
maxRequestBytes: limits.maxRequestBytes,
maxResponseBytes: limits.maxResponseBytes,
},
);
}
if (
limits.maxRequestBytes > MAXIMUM_FRAME_BYTES ||
limits.maxResponseBytes > MAXIMUM_FRAME_BYTES
) {
throw new AppError('INVALID_ARGS', 'Snapshot source frame limits exceed the bridge bound', {
maxRequestBytes: limits.maxRequestBytes,
maxResponseBytes: limits.maxResponseBytes,
maximumFrameBytes: MAXIMUM_FRAME_BYTES,
});
}
if (limits.maxNodes > MAXIMUM_NODES || limits.maxTraversalDepth > MAXIMUM_DEPTH) {
throw new AppError('INVALID_ARGS', 'Snapshot source tree limits exceed the bridge bound', {
maxNodes: limits.maxNodes,
maxTraversalDepth: limits.maxTraversalDepth,
maximumNodes: MAXIMUM_NODES,
maximumDepth: MAXIMUM_DEPTH,
});
}
if (limits.maxDurationMs > MAXIMUM_DURATION_MS) {
throw new AppError('INVALID_ARGS', 'Snapshot source duration exceeds the bridge bound', {
maxDurationMs: limits.maxDurationMs,
maximumDurationMs: MAXIMUM_DURATION_MS,
});
}
if (
limits.maxRequestBytes <= FRAME_HEADER_BYTES ||
limits.maxResponseBytes <= FRAME_HEADER_BYTES
) {
throw new AppError('INVALID_ARGS', 'Snapshot source frame limits must leave room for a body', {
maxRequestBytes: limits.maxRequestBytes,
maxResponseBytes: limits.maxResponseBytes,
});
}
return Object.freeze(limits);
}
@@ -0,0 +1,41 @@
import { Buffer } from 'node:buffer';
import type { ExecResult } from '@agent-device/host-kit/command';
import { snapshotSourceError, type SnapshotSourceError } from './errors.ts';
import type { SnapshotSourceHost, SnapshotSourceProcess } from './types.ts';
const MAX_PROCESS_LOG_BYTES = 64 * 1024;
const diagnosedProcesses = new WeakSet<SnapshotSourceProcess>();
export async function bridgeProcessExited(
host: SnapshotSourceHost,
bridgeProcess: SnapshotSourceProcess,
): Promise<SnapshotSourceError> {
let exitCode: ExecResult['exitCode'] | undefined;
try {
exitCode = (await bridgeProcess.wait).exitCode;
} catch {
exitCode = undefined;
}
if (!diagnosedProcesses.has(bridgeProcess)) {
diagnosedProcesses.add(bridgeProcess);
host.emitDiagnostic({
level: 'error',
phase: 'ios.snapshot-source.bridge-process-exit',
data: {
pid: bridgeProcess.pid,
...(exitCode === undefined ? {} : { exitCode }),
stderr: boundedProcessLog(bridgeProcess.readLog()),
},
});
}
return snapshotSourceError('process-crash', 'bridge-exited', {
pid: bridgeProcess.pid,
...(exitCode === undefined ? {} : { exitCode }),
});
}
function boundedProcessLog(log: string): string {
const bytes = Buffer.from(log);
if (bytes.length <= MAX_PROCESS_LOG_BYTES) return log;
return bytes.subarray(-MAX_PROCESS_LOG_BYTES).toString('utf8');
}
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { test } from 'vitest';
import {
assertSnapshotBridgeEnvelope,
bridgeFailureFromEnvelope,
createSnapshotBridgeDescribeRequest,
encodeSnapshotBridgeFrame,
parseSnapshotBridgeEnvelope,
SNAPSHOT_SOURCE_PROTOCOL_VERSION,
SNAPSHOT_SOURCE_ATTRIBUTE_KEYS,
SNAPSHOT_SOURCE_RESPONSE_KEYS,
SNAPSHOT_SOURCE_VERSION,
SNAPSHOT_SOURCE_WIRE_KEYS,
SnapshotBridgeFrameDecoder,
} from './protocol.ts';
import type { SnapshotSourceLimits } from './types.ts';
const limits: SnapshotSourceLimits = {
maxRequestBytes: 1024,
maxResponseBytes: 4096,
maxNodes: 20,
maxTraversalDepth: 10,
maxDurationMs: 1000,
};
const wireVocabulary = JSON.parse(
await readFile(path.join(import.meta.dirname, 'fixtures', 'wire-vocabulary.json'), 'utf8'),
) as {
protocolVersion: number;
sourceVersion: string;
requestKeys: string[];
responseKeys: string[];
attributeKeys: string[];
};
test('snapshot bridge frames decode a split frame and reject trailing frames', () => {
const first = encodeSnapshotBridgeFrame({ requestId: 'one', value: 1 }, limits);
const second = encodeSnapshotBridgeFrame({ requestId: 'two', value: 2 }, limits);
const decoder = new SnapshotBridgeFrameDecoder(limits.maxResponseBytes);
assert.equal(decoder.push(first.subarray(0, 3)), undefined);
assert.deepEqual(decoder.push(first.subarray(3)), Buffer.from('{"requestId":"one","value":1}'));
assert.deepEqual(decoder.finish(), Buffer.from('{"requestId":"one","value":1}'));
assert.throws(() => decoder.push(second), /multiple-frames/);
});
test('snapshot bridge frames reject bounded request and response violations', () => {
assert.throws(
() => encodeSnapshotBridgeFrame({ payload: 'x'.repeat(2000) }, limits),
(error: unknown) =>
error instanceof Error &&
'failureCode' in error &&
(error as { failureCode: string }).failureCode === 'request-limit-exceeded',
);
const decoder = new SnapshotBridgeFrameDecoder(10);
const oversized = Buffer.alloc(4);
oversized.writeUInt32BE(11, 0);
assert.throws(() => decoder.push(oversized), /frame-limit-exceeded/);
});
test('snapshot bridge envelopes pin protocol, source, and request identity', () => {
const request = createSnapshotBridgeDescribeRequest({
requestId: 'request-1',
pid: 123,
generation: 'generation-1',
maxDepth: 4,
maxNodes: 10,
maxDurationMs: 900,
maxResponseBytes: 4096,
});
assert.deepEqual(request, {
verb: 'describe',
requestId: 'request-1',
pid: 123,
generation: 'generation-1',
snapshotTree: true,
automationMode: true,
maxDepth: 4,
maxNodes: 10,
maxDurationMs: 900,
maxResponseBytes: 4096,
});
const envelope = parseSnapshotBridgeEnvelope(
Buffer.from(
JSON.stringify({
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
sourceVersion: SNAPSHOT_SOURCE_VERSION,
requestId: 'request-1',
}),
),
);
assert.doesNotThrow(() => assertSnapshotBridgeEnvelope(envelope, 'request-1'));
assert.throws(() => assertSnapshotBridgeEnvelope(envelope, 'request-2'), /request-id-mismatch/);
});
test('snapshot bridge failures stay typed at the guest boundary', () => {
for (const [guestKind, expectedKind] of [
['unsupported', 'unsupported'],
['malformed_tree', 'malformed-tree'],
['application_not_responding', 'timeout'],
['application_unavailable', 'transport-failure'],
['bad_request', 'malformed-tree'],
['response_limit_exceeded', 'transport-failure'],
] as const) {
assert.throws(
() =>
bridgeFailureFromEnvelope({
error_kind: guestKind,
error_code: 'fixture-code',
}),
(error: unknown) =>
error instanceof Error &&
'failureKind' in error &&
(error as { failureKind: string }).failureKind === expectedKind,
);
}
});
test('wire vocabulary guard keeps TS and Objective-C literals aligned', async () => {
const native = await Promise.all(
['SnapshotBridge.m', 'SnapshotBridgeRuntime.m', 'SnapshotBridgeRuntime.h'].map((fileName) =>
readFile(
path.join(import.meta.dirname, '../../../../apple/snapshot-bridge', fileName),
'utf8',
),
),
);
const nativeSource = native.join('\n');
assert.equal(wireVocabulary.protocolVersion, SNAPSHOT_SOURCE_PROTOCOL_VERSION);
assert.equal(wireVocabulary.sourceVersion, SNAPSHOT_SOURCE_VERSION);
assert.deepEqual(wireVocabulary.requestKeys, SNAPSHOT_SOURCE_WIRE_KEYS);
assert.deepEqual(wireVocabulary.responseKeys, SNAPSHOT_SOURCE_RESPONSE_KEYS);
assert.deepEqual(wireVocabulary.attributeKeys, SNAPSHOT_SOURCE_ATTRIBUTE_KEYS);
assert.match(nativeSource, /kProtocolVersion = 1/);
assert.match(nativeSource, /kSourceVersion = @"agent-device-simulator-ax-v1\.5\.3"/);
for (const key of [
...wireVocabulary.requestKeys,
...wireVocabulary.responseKeys,
...wireVocabulary.attributeKeys,
]) {
assert.match(
nativeSource,
new RegExp(`@"${key.replaceAll(/[.*+?^${}()|[\\]\\\\]/g, String.raw`\$&`)}"`),
);
}
});
@@ -0,0 +1,251 @@
import { Buffer } from 'node:buffer';
import { snapshotSourceError } from './errors.ts';
import type { SnapshotSourceLimits } from './types.ts';
export const SNAPSHOT_SOURCE_PROTOCOL_VERSION = 1;
export const SNAPSHOT_SOURCE_VERSION = 'agent-device-simulator-ax-v1.5.3';
const FRAME_HEADER_BYTES = 4;
export const SNAPSHOT_SOURCE_WIRE_KEYS = Object.freeze([
'verb',
'requestId',
'pid',
'generation',
'snapshotTree',
'automationMode',
'maxDepth',
'maxNodes',
'maxDurationMs',
'maxResponseBytes',
] as const);
export const SNAPSHOT_SOURCE_RESPONSE_KEYS = Object.freeze([
'protocolVersion',
'sourceVersion',
'requestId',
'generation',
'ok',
'pid',
'tree',
'truncated',
'automationEnabled',
'error_kind',
'error_code',
'error',
] as const);
export const SNAPSHOT_SOURCE_ATTRIBUTE_KEYS = Object.freeze([
'XC_kAXXCAttributeElementType',
'XC_kAXXCAttributeElementBaseType',
'XC_kAXXCAttributeLabel',
'XC_kAXXCAttributeValue',
'XC_kAXXCAttributeIdentifier',
'XC_kAXXCAttributeFrame',
'XC_kAXXCAttributeAutomationType',
'XC_kAXXCAttributeChildren',
] as const);
export type SnapshotBridgeEnvelope = Readonly<Record<string, unknown>>;
export function encodeSnapshotBridgeFrame(
value: unknown,
limits: Pick<SnapshotSourceLimits, 'maxRequestBytes'>,
): Buffer {
let body: Buffer;
try {
body = Buffer.from(JSON.stringify(value), 'utf8');
} catch (error) {
throw snapshotSourceError('malformed-tree', 'request-not-json', {}, error);
}
const frameBytes = body.length + FRAME_HEADER_BYTES;
if (frameBytes > limits.maxRequestBytes) {
throw snapshotSourceError('transport-failure', 'request-limit-exceeded', {
frameBytes,
maxRequestBytes: limits.maxRequestBytes,
});
}
const header = Buffer.alloc(FRAME_HEADER_BYTES);
header.writeUInt32BE(body.length, 0);
return Buffer.concat([header, body]);
}
export class SnapshotBridgeFrameDecoder {
private readonly header = Buffer.alloc(FRAME_HEADER_BYTES);
private headerBytes = 0;
private readonly chunks: Buffer[] = [];
private payloadBytes = 0;
private expectedBodyBytes: number | undefined;
private frame: Buffer | undefined;
private readonly maxFrameBytes: number;
constructor(maxFrameBytes: number) {
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
throw snapshotSourceError('malformed-tree', 'frame-limit-invalid', { maxFrameBytes });
}
this.maxFrameBytes = maxFrameBytes;
}
push(chunk: Buffer): Buffer | undefined {
if (this.frame) return this.acceptTrailingChunk(chunk);
const header = this.readHeader(chunk);
if (!header) return undefined;
this.appendPayload(chunk, header.offset, header.bodyBytes);
if (this.payloadBytes !== header.bodyBytes) return undefined;
this.frame = Buffer.concat(this.chunks, header.bodyBytes);
return this.frame;
}
private acceptTrailingChunk(chunk: Buffer): Buffer {
if (chunk.length > 0) {
throw snapshotSourceError('malformed-tree', 'multiple-frames', {
trailingBytes: chunk.length,
});
}
return this.frame!;
}
private readHeader(chunk: Buffer): { offset: number; bodyBytes: number } | undefined {
if (this.headerBytes === FRAME_HEADER_BYTES) {
return { offset: 0, bodyBytes: this.expectedBodyBytes! };
}
const headerBytes = Math.min(FRAME_HEADER_BYTES - this.headerBytes, chunk.length);
chunk.copy(this.header, this.headerBytes, 0, headerBytes);
this.headerBytes += headerBytes;
if (this.headerBytes < FRAME_HEADER_BYTES) return undefined;
const bodyBytes = this.header.readUInt32BE(0);
if (bodyBytes === 0 || bodyBytes > this.maxFrameBytes) {
throw snapshotSourceError('malformed-tree', 'frame-limit-exceeded', {
bodyBytes,
maxFrameBytes: this.maxFrameBytes,
});
}
this.expectedBodyBytes = bodyBytes;
return { offset: headerBytes, bodyBytes };
}
private appendPayload(chunk: Buffer, offset: number, bodyBytes: number): void {
const remainingBytes = bodyBytes - this.payloadBytes;
const chunkBytes = chunk.length - offset;
if (chunkBytes > remainingBytes) {
throw snapshotSourceError('malformed-tree', 'multiple-frames', {
trailingBytes: chunkBytes - remainingBytes,
});
}
if (chunkBytes === 0) return;
this.chunks.push(chunk.subarray(offset));
this.payloadBytes += chunkBytes;
}
finish(): Buffer {
if (this.frame) return this.frame;
throw snapshotSourceError('transport-failure', 'bridge-frame-incomplete', {
headerBytes: this.headerBytes,
payloadBytes: this.payloadBytes,
expectedBodyBytes: this.expectedBodyBytes,
});
}
}
export function parseSnapshotBridgeEnvelope(body: Buffer): SnapshotBridgeEnvelope {
let parsed: unknown;
try {
parsed = JSON.parse(body.toString('utf8'));
} catch (error) {
throw snapshotSourceError('malformed-tree', 'response-not-json', {}, error);
}
if (!isRecord(parsed)) {
throw snapshotSourceError('malformed-tree', 'response-not-object');
}
return parsed;
}
export function createSnapshotBridgeDescribeRequest(
input: Readonly<{
requestId: string;
pid: number;
generation: string;
maxDepth: number;
maxNodes: number;
maxDurationMs: number;
maxResponseBytes: number;
}>,
): Readonly<Record<string, unknown>> {
return Object.freeze({
verb: 'describe',
requestId: input.requestId,
pid: input.pid,
generation: input.generation,
snapshotTree: true,
automationMode: true,
maxDepth: input.maxDepth,
maxNodes: input.maxNodes,
maxDurationMs: input.maxDurationMs,
maxResponseBytes: input.maxResponseBytes,
});
}
export function assertSnapshotBridgeEnvelope(
envelope: SnapshotBridgeEnvelope,
requestId: string,
): void {
if (envelope.protocolVersion !== SNAPSHOT_SOURCE_PROTOCOL_VERSION) {
throw snapshotSourceError('transport-failure', 'protocol-version-mismatch', {
expected: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
observed: envelope.protocolVersion,
});
}
if (envelope.sourceVersion !== SNAPSHOT_SOURCE_VERSION) {
throw snapshotSourceError('transport-failure', 'source-version-mismatch', {
expected: SNAPSHOT_SOURCE_VERSION,
observed: envelope.sourceVersion,
});
}
if (envelope.requestId !== requestId) {
throw snapshotSourceError('transport-failure', 'request-id-mismatch', {
expected: requestId,
observed: envelope.requestId,
});
}
}
export function assertSnapshotBridgeTargetIdentity(
envelope: SnapshotBridgeEnvelope,
expected: Readonly<{ pid: number; generation: string }>,
): void {
if (typeof envelope.pid !== 'number' || envelope.pid !== expected.pid) {
throw snapshotSourceError('stale-target', 'bridge-pid-mismatch', {
expectedPid: expected.pid,
observedPid: envelope.pid,
});
}
if (envelope.generation !== expected.generation) {
throw snapshotSourceError('stale-target', 'bridge-generation-mismatch', {
expectedGeneration: expected.generation,
observedGeneration: envelope.generation,
});
}
}
export function bridgeFailureFromEnvelope(envelope: SnapshotBridgeEnvelope): never {
const kind = envelope.error_kind;
const code = typeof envelope.error_code === 'string' ? envelope.error_code : 'guest-error';
const message = typeof envelope.error === 'string' ? envelope.error : undefined;
const details = message ? { guestMessage: message.slice(0, 1024) } : {};
if (kind === 'unsupported') throw snapshotSourceError('unsupported', code, details);
if (kind === 'malformed_tree') throw snapshotSourceError('malformed-tree', code, details);
if (kind === 'application_not_responding') {
throw snapshotSourceError('timeout', code, details);
}
if (kind === 'application_unavailable') {
throw snapshotSourceError('transport-failure', code, details);
}
if (kind === 'bad_request') throw snapshotSourceError('malformed-tree', code, details);
if (kind === 'response_limit_exceeded') {
throw snapshotSourceError('transport-failure', code, details);
}
throw snapshotSourceError('transport-failure', code, details);
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
@@ -0,0 +1,113 @@
import { asSnapshotSourceError, snapshotSourceError } from './errors.ts';
import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts';
import { bridgeProcessExited } from './process.ts';
import {
assertSnapshotBridgeEnvelope,
assertSnapshotBridgeTargetIdentity,
bridgeFailureFromEnvelope,
parseSnapshotBridgeEnvelope,
SnapshotBridgeFrameDecoder,
type SnapshotBridgeEnvelope,
} from './protocol.ts';
import type {
SnapshotSourceHost,
SnapshotSourceLimits,
SnapshotSourceProcess,
SnapshotSourceSocket,
} from './types.ts';
export async function roundTripSnapshotBridge(
input: Readonly<{
process: SnapshotSourceProcess;
socket: SnapshotSourceSocket;
frame: Buffer;
requestId: string;
deadline: SnapshotSourceDeadline;
limits: SnapshotSourceLimits;
expectedPid: number;
expectedGeneration: string;
host: SnapshotSourceHost;
}>,
): Promise<SnapshotBridgeEnvelope> {
const decoder = new SnapshotBridgeFrameDecoder(input.limits.maxResponseBytes - 4);
const timeoutMs = remainingSnapshotSourceMs(input.deadline, 'bridge-request-deadline');
return await new Promise<SnapshotBridgeEnvelope>((resolve, reject) => {
let settled = false;
let dispatched = false;
const timer = setTimeout(() => {
finishReject(snapshotSourceError('timeout', 'bridge-request-deadline', { dispatched }));
input.socket.destroy();
}, timeoutMs);
const onAbort = () => {
finishReject(snapshotSourceError('cancelled', 'abort-signal', { dispatched }));
input.socket.destroy();
};
const onData = (chunk: unknown) => {
try {
if (!Buffer.isBuffer(chunk))
throw snapshotSourceError('transport-failure', 'bridge-data-invalid');
const body = decoder.push(chunk);
if (body) {
const envelope = parseSnapshotBridgeEnvelope(body);
assertSnapshotBridgeEnvelope(envelope, input.requestId);
assertSnapshotBridgeTargetIdentity(envelope, {
pid: input.expectedPid,
generation: input.expectedGeneration,
});
if (envelope.ok !== true) bridgeFailureFromEnvelope(envelope);
if (typeof envelope.truncated !== 'boolean') {
throw snapshotSourceError('malformed-tree', 'truncated-invalid');
}
finishResolve(envelope);
return;
}
} catch (error) {
finishReject(error);
input.socket.destroy();
}
};
const onError = (error: unknown) => finishReject(asSnapshotSourceError(error));
const onClose = () => {
if (!settled) {
if (input.process.isAlive()) {
finishReject(snapshotSourceError('transport-failure', 'bridge-connection-closed'));
} else {
void bridgeProcessExited(input.host, input.process).then(finishReject);
}
}
};
input.process.wait.then(
() => {
if (!settled) void bridgeProcessExited(input.host, input.process).then(finishReject);
},
(error: unknown) => {
if (!settled) finishReject(asSnapshotSourceError(error));
},
);
const finishResolve = (value: SnapshotBridgeEnvelope) => finish(() => resolve(value));
const finishReject = (error: unknown) => finish(() => reject(error));
const finish = (action: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
input.deadline.signal?.removeEventListener('abort', onAbort);
input.socket.off('data', onData);
input.socket.off('error', onError);
input.socket.off('close', onClose);
action();
};
input.socket.on('data', onData);
input.socket.on('error', onError);
input.socket.on('close', onClose);
input.deadline.signal?.addEventListener('abort', onAbort, { once: true });
try {
if (input.deadline.signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal');
dispatched = true;
input.socket.write(input.frame);
} catch (error) {
finishReject(asSnapshotSourceError(error));
input.socket.destroy();
}
});
}
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { decodeSnapshotBridgeTree } from './tree.ts';
import type { SnapshotSourceLimits } from './types.ts';
const limits: SnapshotSourceLimits = {
maxRequestBytes: 1024,
maxResponseBytes: 4096,
maxNodes: 20,
maxTraversalDepth: 10,
maxDurationMs: 1000,
};
const application = 'XC_kAXXCAttributeElementType';
const baseType = 'XC_kAXXCAttributeElementBaseType';
const frame = 'XC_kAXXCAttributeFrame';
const children = 'XC_kAXXCAttributeChildren';
const label = 'XC_kAXXCAttributeLabel';
const automationType = 'XC_kAXXCAttributeAutomationType';
test('the bridge tree becomes one depth-first raw snapshot with viewport evidence', () => {
const result = decodeSnapshotBridgeTree(
{
[application]: 'Application',
[frame]: { X: 0, Y: 0, Width: 390, Height: 844 },
[children]: [
{
[application]: 'Window',
[baseType]: 'UIWindow',
[frame]: { X: 0, Y: 0, Width: 390, Height: 844 },
[children]: [
{
[automationType]: 9,
[label]: 'Continue',
[frame]: { X: 20, Y: 700, Width: 120, Height: 48 },
[children]: [],
},
],
},
],
},
{ truncated: false },
limits,
);
assert.deepEqual(result.nodes, [
{
index: 0,
type: 'Application',
role: 'Application',
rect: { x: 0, y: 0, width: 390, height: 844 },
depth: 0,
},
{
index: 1,
parentIndex: 0,
type: 'Window',
role: 'Window',
subrole: 'UIWindow',
rect: { x: 0, y: 0, width: 390, height: 844 },
depth: 1,
},
{
index: 2,
parentIndex: 1,
type: 'Button',
label: 'Continue',
rect: { x: 20, y: 700, width: 120, height: 48 },
depth: 2,
},
]);
assert.deepEqual(result.viewport, {
kind: 'reported',
rect: { x: 0, y: 0, width: 390, height: 844 },
});
assert.equal(result.maxTraversalDepth, 2);
});
test('the bridge tree rejects unknown fields, invalid frames, and bounded overflows', () => {
assert.throws(
() => decodeSnapshotBridgeTree({ [children]: [], unknown: true }, { truncated: false }, limits),
/node-contains-unknown-field/,
);
assert.throws(
() =>
decodeSnapshotBridgeTree(
{ [frame]: { X: 0, Y: 0, Width: -1, Height: 1 }, [children]: [] },
{ truncated: false },
limits,
),
/frame-invalid/,
);
assert.throws(
() =>
decodeSnapshotBridgeTree(
{
[children]: Array.from({ length: limits.maxNodes + 1 }, () => ({ [children]: [] })),
},
{ truncated: false },
limits,
),
/node-limit-exceeded/,
);
});
test('the bridge tree requires a typed truncation flag', () => {
assert.throws(
() => decodeSnapshotBridgeTree({ [children]: [] }, { truncated: 'yes' }, limits),
/truncated-invalid/,
);
});
@@ -0,0 +1,266 @@
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
import type { IosViewportEvidence } from '@agent-device/contracts/ios-snapshot';
import { snapshotSourceError } from './errors.ts';
import type { SnapshotSourceDecodedTree, SnapshotSourceLimits } from './types.ts';
import { isRecord } from './protocol.ts';
// fallow-ignore-next-line code-duplication
const ATTRIBUTE = Object.freeze({
elementType: 'XC_kAXXCAttributeElementType',
elementBaseType: 'XC_kAXXCAttributeElementBaseType',
label: 'XC_kAXXCAttributeLabel',
value: 'XC_kAXXCAttributeValue',
identifier: 'XC_kAXXCAttributeIdentifier',
frame: 'XC_kAXXCAttributeFrame',
automationType: 'XC_kAXXCAttributeAutomationType',
children: 'XC_kAXXCAttributeChildren',
});
const ELEMENT_TYPE_NAMES: readonly string[] = [
'Other',
'Other',
'Application',
'Group',
'Window',
'Sheet',
'Drawer',
'Alert',
'Dialog',
'Button',
'RadioButton',
'RadioGroup',
'CheckBox',
'DisclosureTriangle',
'PopUpButton',
'ComboBox',
'MenuButton',
'ToolbarButton',
'Popover',
'Keyboard',
'Key',
'NavigationBar',
'TabBar',
'TabGroup',
'Toolbar',
'StatusBar',
'Table',
'TableRow',
'TableColumn',
'Outline',
'OutlineRow',
'Browser',
'CollectionView',
'Slider',
'PageIndicator',
'ProgressIndicator',
'ActivityIndicator',
'SegmentedControl',
'Picker',
'PickerWheel',
'Switch',
'Toggle',
'Link',
'Image',
'Icon',
'SearchField',
'ScrollView',
'ScrollBar',
'StaticText',
'TextField',
'SecureTextField',
'DatePicker',
'TextView',
'Menu',
'MenuItem',
'MenuBar',
'MenuBarItem',
'Map',
'WebView',
'IncrementArrow',
'DecrementArrow',
'Timeline',
'RatingIndicator',
'ValueIndicator',
'SplitGroup',
'Splitter',
'RelevanceIndicator',
'ColorWell',
'HelpTag',
'Matte',
'DockItem',
'Ruler',
'RulerMarker',
'Grid',
'LevelIndicator',
'Cell',
'LayoutArea',
'LayoutItem',
'Handle',
'Stepper',
'Tab',
'TouchBar',
'StatusItem',
];
const CLASS_PROMOTED_TYPES: Readonly<Record<string, string>> = {
UIApplication: 'Application',
UIWindow: 'Window',
};
const NODE_KEYS = new Set<string>(Object.values(ATTRIBUTE));
export function decodeSnapshotBridgeTree(
tree: unknown,
envelope: Readonly<{ truncated: unknown }>,
limits: SnapshotSourceLimits,
): SnapshotSourceDecodedTree {
const roots = Array.isArray(tree) ? tree : [tree];
if (roots.length === 0 || roots.some((root) => !isRecord(root))) {
throw snapshotSourceError('malformed-tree', 'guest-tree-root-invalid');
}
const nodes: RawSnapshotNode[] = [];
let maxTraversalDepth = 0;
for (const root of roots) {
visitNode(root, undefined, 0);
}
if (nodes.length > limits.maxNodes) {
throw snapshotSourceError('malformed-tree', 'node-limit-exceeded', {
nodeCount: nodes.length,
maxNodes: limits.maxNodes,
});
}
if (maxTraversalDepth > limits.maxTraversalDepth) {
throw snapshotSourceError('malformed-tree', 'traversal-depth-exceeded', {
maxTraversalDepth,
maxAllowedDepth: limits.maxTraversalDepth,
});
}
if (typeof envelope.truncated !== 'boolean') {
throw snapshotSourceError('malformed-tree', 'truncated-invalid');
}
return {
nodes,
maxTraversalDepth,
viewport: viewportFromRoot(
nodes.find((node) => node.type === 'Application' || node.type === 'Window'),
),
};
function visitNode(
value: Record<string, unknown>,
parentIndex: number | undefined,
depth: number,
): void {
if (nodes.length >= limits.maxNodes) {
throw snapshotSourceError('malformed-tree', 'node-limit-exceeded', {
maxNodes: limits.maxNodes,
});
}
for (const key of Object.keys(value)) {
if (!NODE_KEYS.has(key)) {
throw snapshotSourceError('malformed-tree', 'node-contains-unknown-field', { key });
}
}
const children = value[ATTRIBUTE.children];
if (!Array.isArray(children)) {
throw snapshotSourceError('malformed-tree', 'children-invalid');
}
const index = nodes.length;
const node = nodeFacts(value, index, parentIndex, depth);
nodes.push(node);
maxTraversalDepth = Math.max(maxTraversalDepth, depth);
for (const child of children) {
if (!isRecord(child)) throw snapshotSourceError('malformed-tree', 'child-invalid');
visitNode(child, index, depth + 1);
}
}
}
// fallow-ignore-next-line complexity
function nodeFacts(
value: Record<string, unknown>,
index: number,
parentIndex: number | undefined,
depth: number,
): RawSnapshotNode {
const elementClass = optionalString(value[ATTRIBUTE.elementType]);
const baseClass = optionalString(value[ATTRIBUTE.elementBaseType]);
const automationType = optionalInteger(value[ATTRIBUTE.automationType]);
const frame = frameFromGuest(value[ATTRIBUTE.frame]);
return {
index,
...(parentIndex === undefined ? {} : { parentIndex }),
...(elementTypeName(elementClass, automationType)
? { type: elementTypeName(elementClass, automationType) }
: {}),
...(elementClass ? { role: elementClass } : {}),
...(baseClass && baseClass !== elementClass ? { subrole: baseClass } : {}),
...(optionalString(value[ATTRIBUTE.label])
? { label: optionalString(value[ATTRIBUTE.label]) }
: {}),
...(optionalScalar(value[ATTRIBUTE.value])
? { value: optionalScalar(value[ATTRIBUTE.value]) }
: {}),
...(optionalString(value[ATTRIBUTE.identifier])
? { identifier: optionalString(value[ATTRIBUTE.identifier]) }
: {}),
...(frame ? { rect: frame } : {}),
depth,
};
}
function elementTypeName(
elementClass: string | undefined,
automationType: number | undefined,
): string | undefined {
if (elementClass !== undefined && CLASS_PROMOTED_TYPES[elementClass]) {
return CLASS_PROMOTED_TYPES[elementClass];
}
if (elementClass !== undefined && ELEMENT_TYPE_NAMES.includes(elementClass)) {
return elementClass;
}
if (automationType === undefined) return undefined;
return ELEMENT_TYPE_NAMES[automationType] ?? 'Other';
}
function frameFromGuest(value: unknown): Rect | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) throw snapshotSourceError('malformed-tree', 'frame-invalid');
const numbers = ['X', 'Y', 'Width', 'Height'].map((key) => value[key]);
if (!numbers.every((entry) => typeof entry === 'number' && Number.isFinite(entry))) {
throw snapshotSourceError('malformed-tree', 'frame-invalid');
}
const [x, y, width, height] = numbers as [number, number, number, number];
if (width < 0 || height < 0) throw snapshotSourceError('malformed-tree', 'frame-invalid');
return { x, y, width, height };
}
function viewportFromRoot(root: RawSnapshotNode | undefined): IosViewportEvidence {
if (!root || (root.type !== 'Application' && root.type !== 'Window')) {
return { kind: 'missing', reason: 'not-provided' };
}
if (isPositiveFiniteRect(root.rect)) return { kind: 'reported', rect: root.rect };
return { kind: 'missing', reason: root.rect ? 'invalid' : 'not-provided' };
}
// fallow-ignore-next-line code-duplication
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function optionalScalar(value: unknown): string | undefined {
if (typeof value === 'string') return value.length > 0 ? value : undefined;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (value !== undefined && value !== null) {
throw snapshotSourceError('malformed-tree', 'scalar-invalid');
}
return undefined;
}
function optionalInteger(value: unknown): number | undefined {
if (value === undefined || value === null) return undefined;
if (!Number.isSafeInteger(value))
throw snapshotSourceError('malformed-tree', 'automation-type-invalid');
return value as number;
}
@@ -0,0 +1,133 @@
import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command';
import type {
CaptureHint,
IosSnapshotAcquisition,
IosViewportEvidence,
} from '@agent-device/contracts/ios-snapshot';
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import type { SnapshotSourceDeadline } from './deadline.ts';
export type SnapshotSourceLimits = Readonly<{
maxRequestBytes: number;
maxResponseBytes: number;
maxNodes: number;
maxTraversalDepth: number;
maxDurationMs: number;
}>;
export type SnapshotSourceTarget = Readonly<{
udid: string;
runtime: string;
pid: number;
generation: string;
targetId?: string;
}>;
export type SnapshotSourceRequest = Readonly<{
target: SnapshotSourceTarget;
hint: CaptureHint;
limits?: Partial<SnapshotSourceLimits>;
signal?: AbortSignal;
}>;
export type SnapshotSourceSuccess = Readonly<{
stage: 'acquired';
acquisition: IosSnapshotAcquisition;
}>;
export type SnapshotSourceFailureKind =
| 'unsupported'
| 'malformed-tree'
| 'stale-target'
| 'timeout'
| 'cancelled'
| 'process-crash'
| 'transport-failure';
export type SnapshotSourceFailure = Readonly<{
kind: SnapshotSourceFailureKind;
code: string;
details?: Readonly<Record<string, unknown>>;
}>;
export type SnapshotSourceOutcome =
| SnapshotSourceSuccess
| Readonly<{
stage: 'failed';
failure: SnapshotSourceFailure;
}>;
export type SnapshotSourceProcess = Readonly<{
pid: number;
wait: Promise<ExecResult>;
isAlive(): boolean;
signal(signal: NodeJS.Signals): void;
readLog(): string;
}>;
export type SnapshotSourceSocket = Readonly<{
destroyed: boolean;
on(event: string, listener: (...args: unknown[]) => void): void;
once(event: string, listener: (...args: unknown[]) => void): void;
off(event: string, listener: (...args: unknown[]) => void): void;
write(data: Buffer): boolean;
destroy(error?: Error): void;
}>;
export type SnapshotSourceHost = Readonly<{
projectRoot(): string;
homeDirectory(): string;
run(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
start(
udid: string,
bridgePath: string,
socketPath: string,
options?: { signal?: AbortSignal },
): SnapshotSourceProcess;
connect(
socketPath: string,
options: { signal?: AbortSignal; timeoutMs: number },
): Promise<SnapshotSourceSocket>;
readText(path: string): Promise<string>;
readBinary(path: string): Promise<Buffer>;
writeText(path: string, contents: string): Promise<void>;
ensureDirectory(path: string): Promise<void>;
chmod(path: string, mode: number): Promise<void>;
exists(path: string): boolean;
rename(sourcePath: string, destinationPath: string): Promise<void>;
remove(path: string): Promise<void>;
acquireLock(
path: string,
options: { deadline: SnapshotSourceDeadline },
): Promise<() => Promise<void>>;
emitDiagnostic(event: {
level?: 'debug' | 'info' | 'warn' | 'error';
phase: string;
durationMs?: number;
data?: Record<string, unknown>;
}): void;
withDiagnosticTimer<T>(
phase: string,
action: () => Promise<T> | T,
data?: Record<string, unknown>,
): Promise<T>;
processId(): number;
readTargetProcessStartTime(
pid: number,
options: { signal?: AbortSignal; timeoutMs: number },
): Promise<string | null>;
}>;
export type SnapshotSourceBridgeBinary = Readonly<{
path: string;
sourceHash: string;
cacheKey: string;
protocolVersion: number;
sourceVersion: string;
}>;
export type SnapshotSourceDecodedTree = Readonly<{
nodes: readonly RawSnapshotNode[];
viewport: IosViewportEvidence;
maxTraversalDepth: number;
}>;
@@ -5,6 +5,9 @@
{ "path": "dist/src/index.d.ts", "size": 102 },
{ "path": "dist/apple/runner/RunnerTests.swift", "size": 503 },
{ "path": "dist/apple/snapshot-presentation/Package.swift", "size": 113 },
{ "path": "apple/snapshot-bridge/SnapshotBridge.m", "size": 0 },
{ "path": "apple/snapshot-bridge/SnapshotBridgeRuntime.m", "size": 0 },
{ "path": "apple/snapshot-bridge/SnapshotBridgeRuntime.h", "size": 0 },
{ "path": "apple/macos-helper/Sources/main.swift", "size": 211 },
{ "path": "android/snapshot-helper/dist/helper.apk", "size": 307 },
{ "path": "android/snapshot-helper/dist/helper.manifest.json", "size": 99 },
@@ -23,6 +23,9 @@ test('classifies every shipped entry into one named component', () => {
['dist/src/index.d.ts', 'js'],
['dist/apple/runner/RunnerTests.swift', 'apple-runner'],
['dist/apple/snapshot-presentation/Package.swift', 'apple-snapshot-presentation'],
['apple/snapshot-bridge/SnapshotBridge.m', 'apple-snapshot-bridge'],
['apple/snapshot-bridge/SnapshotBridgeRuntime.m', 'apple-snapshot-bridge'],
['apple/snapshot-bridge/SnapshotBridgeRuntime.h', 'apple-snapshot-bridge'],
['apple/macos-helper/Sources/main.swift', 'macos-helper'],
['android/snapshot-helper/dist/helper.apk', 'android-helpers'],
['android/snapshot-helper/dist/helper.manifest.json', 'android-helpers'],
@@ -50,6 +53,21 @@ test('publish package requires both Android helpers and excludes benchmark scrip
),
/android\/ime-helper/,
);
assert.throws(
() =>
assertPublishPackageContents(
fixturePack.files.filter(
(entry) => entry.path !== 'apple/snapshot-bridge/SnapshotBridgeRuntime.m',
),
{ requireSnapshotBridge: true },
),
/SnapshotBridgeRuntime\.m/,
);
assert.doesNotThrow(() =>
assertPublishPackageContents(
fixturePack.files.filter((entry) => !entry.path.startsWith('apple/snapshot-bridge/')),
),
);
assert.throws(
() =>
assertPublishPackageContents([
@@ -73,6 +91,7 @@ test('component bytes sum exactly to npm pack unpackedSize', () => {
js: 503,
'apple-runner': 503,
'apple-snapshot-presentation': 113,
'apple-snapshot-bridge': 0,
'macos-helper': 211,
'android-helpers': 812,
other: 177,
+23
View File
@@ -26,6 +26,7 @@ import {
auditDependencyClosure,
type PackedManifest as PackedDependencies,
} from './lib/shipped-imports.ts';
import { assertInstalledSnapshotBridge } from './size-report-install.mjs';
type PackedManifest = PackedDependencies & {
exports: Record<string, unknown>;
@@ -34,6 +35,9 @@ type PackedManifest = PackedDependencies & {
const repoRoot = path.resolve(import.meta.dirname, '..');
const packDestinationFlag = '--pack-destination';
const verifySnapshotBridgePreparation = process.argv.includes(
'--verify-snapshot-bridge-preparation',
);
const suppliedPackDestination = process.argv
.slice(2)
.find((arg, index, args) => (args[index - 1] === packDestinationFlag ? arg : undefined));
@@ -177,6 +181,25 @@ try {
const tarball = packTarball();
lintTarball(tarball);
const installedRoot = installIntoCleanConsumer(tarball);
assertInstalledSnapshotBridge(installedRoot);
if (verifySnapshotBridgePreparation) {
if (process.platform !== 'darwin') {
throw new Error('--verify-snapshot-bridge-preparation requires macOS and Xcode.');
}
run(
'pnpm',
[
'--filter',
'@agent-device/platform-apple',
'run',
'verify-installed-snapshot-bridge',
installedRoot,
path.join(workDir, 'snapshot-bridge-cache'),
],
repoRoot,
);
step('Prepared the Simulator snapshot bridge from the clean-installed package.');
}
const manifest = JSON.parse(
fs.readFileSync(path.join(installedRoot, 'package.json'), 'utf8'),
) as PackedManifest;
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { test } from 'vitest';
import { mkdtempForTest } from '../../src/__tests__/test-utils/tmp-dir.ts';
import { measureDirectory } from '../size-report-install.mjs';
import { assertInstalledSnapshotBridge, measureDirectory } from '../size-report-install.mjs';
test('measures the clean-installed package tree without counting the consumer', async () => {
const root = await mkdtempForTest('agent-device-size-tree-');
@@ -12,3 +12,20 @@ test('measures the clean-installed package tree without counting the consumer',
await writeFile(join(root, 'nested', 'data.json'), '{}');
assert.deepEqual(measureDirectory(root), { packageBytes: 6, files: 2 });
});
test('clean-installed snapshot bridge validates all native assets when present', async () => {
const root = await mkdtempForTest('agent-device-size-bridge-');
const bridge = join(root, 'apple', 'snapshot-bridge');
try {
assert.doesNotThrow(() => assertInstalledSnapshotBridge(root));
await mkdir(bridge, { recursive: true });
await writeFile(join(bridge, 'SnapshotBridge.m'), 'native source');
await writeFile(join(bridge, 'SnapshotBridgeRuntime.m'), 'native runtime');
await writeFile(join(bridge, 'SnapshotBridgeRuntime.h'), 'native header');
assert.doesNotThrow(() => assertInstalledSnapshotBridge(root));
await rm(join(bridge, 'SnapshotBridgeRuntime.h'));
assert.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridgeRuntime\.h/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -552,6 +552,7 @@ test('the real tree parses, declares, and passes R11', () => {
'@agent-device/platform-apple/runner/test-host',
'@agent-device/platform-apple/simctl',
'@agent-device/platform-apple/simulator',
'@agent-device/platform-apple/snapshot-source',
'@agent-device/platform-apple/tool-provider',
]);
assert.deepEqual([...platformApplePackage.workspaceDependencies].sort(), [
@@ -40,6 +40,7 @@ function declarations(): PlatformPackageDeclaration[] {
'@agent-device/platform-apple/runner/operations',
'@agent-device/platform-apple/runner-owner',
'@agent-device/platform-apple/simctl',
'@agent-device/platform-apple/snapshot-source',
'@agent-device/platform-apple/simulator',
'@agent-device/platform-apple/tool-provider',
]
@@ -315,6 +316,7 @@ test('the Apple domain facades preserve synchronous helpers without widening the
'@agent-device/platform-apple/physical-device',
'@agent-device/platform-apple/runner-owner',
'@agent-device/platform-apple/simctl',
'@agent-device/platform-apple/snapshot-source',
'@agent-device/platform-apple/simulator',
'@agent-device/platform-apple/tool-provider',
]) {
@@ -87,6 +87,7 @@ const MECHANICS_FACET_SUBPATHS: Readonly<Partial<Record<PlatformFamily, readonly
'@agent-device/platform-apple/physical-device',
'@agent-device/platform-apple/runner-owner',
'@agent-device/platform-apple/runner/operations',
'@agent-device/platform-apple/snapshot-source',
'@agent-device/platform-apple/simctl',
'@agent-device/platform-apple/simulator',
'@agent-device/platform-apple/tool-provider',
+11
View File
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { assertSnapshotBridgeAssets, SNAPSHOT_BRIDGE_ASSET_PATHS } from './size-report-package.mjs';
export function measureCleanInstalledPackage(tarballPath, packageName) {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-size-install-'));
@@ -33,12 +34,22 @@ export function measureCleanInstalledPackage(tarballPath, packageName) {
if (!fs.existsSync(packageDir)) {
throw new Error(`Clean install did not create node_modules/${packageName}.`);
}
assertInstalledSnapshotBridge(packageDir);
return measureDirectory(packageDir);
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
}
export function assertInstalledSnapshotBridge(packageDir) {
const bridgeRoot = path.join(packageDir, 'apple', 'snapshot-bridge');
if (!fs.existsSync(bridgeRoot)) return;
const present = SNAPSHOT_BRIDGE_ASSET_PATHS.filter((assetPath) =>
fs.existsSync(path.join(packageDir, assetPath)),
);
assertSnapshotBridgeAssets(present, 'Clean-installed snapshot bridge');
}
export function measureDirectory(root) {
const entries = fs.readdirSync(root, { withFileTypes: true });
return entries.reduce(
+40 -8
View File
@@ -8,6 +8,20 @@ import {
formatSignedBytes,
} from './size-report-format.mjs';
export const SNAPSHOT_BRIDGE_ASSET_PATHS = Object.freeze([
'apple/snapshot-bridge/SnapshotBridge.m',
'apple/snapshot-bridge/SnapshotBridgeRuntime.m',
'apple/snapshot-bridge/SnapshotBridgeRuntime.h',
]);
export function assertSnapshotBridgeAssets(presentPaths, context) {
const present = new Set(presentPaths);
const missing = SNAPSHOT_BRIDGE_ASSET_PATHS.filter((assetPath) => !present.has(assetPath));
if (missing.length > 0) {
throw new Error(`${context} is missing: ${missing.join(', ')}`);
}
}
const PACKAGE_COMPONENTS = [
{
id: 'js',
@@ -27,6 +41,12 @@ const PACKAGE_COMPONENTS = [
entryPath === 'dist/apple/snapshot-presentation' ||
entryPath.startsWith('dist/apple/snapshot-presentation/'),
},
{
id: 'apple-snapshot-bridge',
label: 'Apple Simulator snapshot bridge source',
matches: (entryPath) =>
entryPath === 'apple/snapshot-bridge' || entryPath.startsWith('apple/snapshot-bridge/'),
},
{
id: 'macos-helper',
label: 'macOS helper source',
@@ -52,7 +72,9 @@ export function collectNpmPack(root) {
);
const pack = parseNpmPackOutput(stdout);
const entries = normalizeNpmPackEntries(pack);
assertPublishPackageContents(entries);
assertPublishPackageContents(entries, {
requireSnapshotBridge: fs.existsSync(path.join(root, 'apple', 'snapshot-bridge')),
});
return {
filename: pack.filename,
tarballPath: path.join(cachePath, pack.filename),
@@ -64,7 +86,7 @@ export function collectNpmPack(root) {
};
}
export function assertPublishPackageContents(entries) {
export function assertPublishPackageContents(entries, options = {}) {
const paths = entries.map((entry) => entry.path);
const requiredAssets = [
{ directory: 'android/snapshot-helper/dist/', suffix: '.apk' },
@@ -72,16 +94,26 @@ export function assertPublishPackageContents(entries) {
{ directory: 'android/ime-helper/dist/', suffix: '.apk' },
{ directory: 'android/ime-helper/dist/', suffix: '.manifest.json' },
];
const missingAssets = requiredAssets.filter(
(asset) =>
!paths.some(
(entryPath) => entryPath.startsWith(asset.directory) && entryPath.endsWith(asset.suffix),
),
if (
options.requireSnapshotBridge ??
paths.some((entryPath) => entryPath.startsWith('apple/snapshot-bridge/'))
) {
assertSnapshotBridgeAssets(
paths.filter((entryPath) => SNAPSHOT_BRIDGE_ASSET_PATHS.includes(entryPath)),
'npm pack snapshot bridge',
);
}
const missingAssets = requiredAssets.filter((asset) =>
asset.path
? !paths.includes(asset.path)
: !paths.some(
(entryPath) => entryPath.startsWith(asset.directory) && entryPath.endsWith(asset.suffix),
),
);
if (missingAssets.length > 0) {
throw new Error(
`npm pack is missing publish assets: ${missingAssets
.map((asset) => `${asset.directory}*${asset.suffix}`)
.map((asset) => asset.path ?? `${asset.directory}*${asset.suffix}`)
.join(', ')}`,
);
}