From 5223968064c91dac4191b0b5d25534b102a928e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 09:27:09 +0200 Subject: [PATCH 1/9] feat(ios): productionize Simulator AX snapshot bridge --- .fallowrc.json | 5 + apple/snapshot-bridge/LICENSE.idb | 23 ++ apple/snapshot-bridge/README.md | 24 ++ apple/snapshot-bridge/SnapshotBridge.m | 249 +++++++++++++++ apple/snapshot-bridge/SnapshotBridgeRuntime.h | 30 ++ apple/snapshot-bridge/SnapshotBridgeRuntime.m | 297 +++++++++++++++++ ...os-simulator-snapshot-source-2026-09-04.md | 42 +++ package.json | 1 + packages/platform-apple/package.json | 4 + .../src/snapshot-source-facade.ts | 41 +++ .../src/snapshot-source/adapter.test.ts | 199 ++++++++++++ .../src/snapshot-source/adapter.ts | 241 ++++++++++++++ .../src/snapshot-source/cache.test.ts | 145 +++++++++ .../src/snapshot-source/cache.ts | 269 ++++++++++++++++ .../src/snapshot-source/errors.ts | 62 ++++ .../src/snapshot-source/host.test.ts | 13 + .../src/snapshot-source/host.ts | 199 ++++++++++++ .../src/snapshot-source/lifecycle.test.ts | 269 ++++++++++++++++ .../src/snapshot-source/lifecycle.ts | 298 ++++++++++++++++++ .../src/snapshot-source/limits.ts | 50 +++ .../src/snapshot-source/protocol.test.ts | 100 ++++++ .../src/snapshot-source/protocol.ts | 139 ++++++++ .../src/snapshot-source/tree.test.ts | 111 +++++++ .../src/snapshot-source/tree.ts | 266 ++++++++++++++++ .../src/snapshot-source/types.ts | 131 ++++++++ .../fixtures/size-report-npm-pack.json | 1 + scripts/__tests__/size-report-package.test.ts | 2 + scripts/layering/package-boundaries.test.ts | 1 + .../layering/platform-package-policy.test.ts | 2 + scripts/layering/platform-package-policy.ts | 1 + scripts/size-report-package.mjs | 7 + 31 files changed, 3222 insertions(+) create mode 100644 apple/snapshot-bridge/LICENSE.idb create mode 100644 apple/snapshot-bridge/README.md create mode 100644 apple/snapshot-bridge/SnapshotBridge.m create mode 100644 apple/snapshot-bridge/SnapshotBridgeRuntime.h create mode 100644 apple/snapshot-bridge/SnapshotBridgeRuntime.m create mode 100644 docs/evidence/ios-simulator-snapshot-source-2026-09-04.md create mode 100644 packages/platform-apple/src/snapshot-source-facade.ts create mode 100644 packages/platform-apple/src/snapshot-source/adapter.test.ts create mode 100644 packages/platform-apple/src/snapshot-source/adapter.ts create mode 100644 packages/platform-apple/src/snapshot-source/cache.test.ts create mode 100644 packages/platform-apple/src/snapshot-source/cache.ts create mode 100644 packages/platform-apple/src/snapshot-source/errors.ts create mode 100644 packages/platform-apple/src/snapshot-source/host.test.ts create mode 100644 packages/platform-apple/src/snapshot-source/host.ts create mode 100644 packages/platform-apple/src/snapshot-source/lifecycle.test.ts create mode 100644 packages/platform-apple/src/snapshot-source/lifecycle.ts create mode 100644 packages/platform-apple/src/snapshot-source/limits.ts create mode 100644 packages/platform-apple/src/snapshot-source/protocol.test.ts create mode 100644 packages/platform-apple/src/snapshot-source/protocol.ts create mode 100644 packages/platform-apple/src/snapshot-source/tree.test.ts create mode 100644 packages/platform-apple/src/snapshot-source/tree.ts create mode 100644 packages/platform-apple/src/snapshot-source/types.ts diff --git a/.fallowrc.json b/.fallowrc.json index 452b17754..97b5230c5 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -300,6 +300,11 @@ "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, so keep the complete named facet surface explicit.", + "file": "packages/platform-apple/src/snapshot-source-facade.ts", + "exports": ["*"] + }, { "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", diff --git a/apple/snapshot-bridge/LICENSE.idb b/apple/snapshot-bridge/LICENSE.idb new file mode 100644 index 000000000..e95d126e9 --- /dev/null +++ b/apple/snapshot-bridge/LICENSE.idb @@ -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. diff --git a/apple/snapshot-bridge/README.md b/apple/snapshot-bridge/README.md new file mode 100644 index 000000000..5d73929b7 --- /dev/null +++ b/apple/snapshot-bridge/README.md @@ -0,0 +1,24 @@ +# 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. diff --git a/apple/snapshot-bridge/SnapshotBridge.m b/apple/snapshot-bridge/SnapshotBridge.m new file mode 100644 index 000000000..2adadd3b0 --- /dev/null +++ b/apple/snapshot-bridge/SnapshotBridge.m @@ -0,0 +1,249 @@ +/* + * The framed server and request validation for the private Simulator AX reader. + * The runtime binding is isolated in SnapshotBridgeRuntime.m. + */ + +#import "SnapshotBridgeRuntime.h" + +#import + +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import + +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"); + } + id snapshotTree = request[@"snapshotTree"]; + if (snapshotTree != nil && (![snapshotTree isKindOfClass:NSNumber.class] || ![snapshotTree boolValue])) { + return failureResponse(requestId, @"bad_request", @"snapshot-tree-required", @"snapshotTree must be enabled"); + } + NSUInteger maxDepth = 0; + NSUInteger maxNodes = 0; + if (!validBoundInteger(request[@"maxDepth"], 0, kMaximumDepth, &maxDepth) || + !validBoundInteger(request[@"maxNodes"], 1, kMaximumNodes, &maxNodes)) { + return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"maxDepth and maxNodes are outside bridge bounds"); + } + + NSString *setupError = nil; + BridgeRuntime *runtime = sharedRuntime(&setupError); + if (!runtime) { + NSMutableDictionary *unavailable = [failureResponse(requestId, @"unsupported", @"runtime-unavailable", setupError) mutableCopy]; + unavailable[@"pid"] = pidValue; + return unavailable; + } + NSDictionary *error = nil; + NSDictionary *response = [runtime snapshotForProcess:pidValue.intValue + maxDepth:maxDepth + maxNodes:maxNodes + requestId:requestId + error:&error]; + if (response) return response; + if (error) { + NSMutableDictionary *annotated = [error mutableCopy]; + annotated[@"pid"] = pidValue; + 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) +{ + NSError *error = nil; + @try { + NSData *data = [NSJSONSerialization dataWithJSONObject:response options:0 error:&error]; + if (data) return data; + } @catch (NSException *exception) { + bridgeLog(exception.reason ?: @"response serialization raised an exception"); + } + NSDictionary *fallback = failureResponse( + response[kRequestIdKey], @"malformed_tree", @"response-not-json-safe", error.localizedDescription ?: @"response was not JSON serializable"); + return [NSJSONSerialization dataWithJSONObject:fallback options:0 error:NULL]; +} + +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); + 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 *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 *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 [--idle-timeout ] [--exit-on-disconnect ]\n", argv[0]); + return 2; + } + NSMutableArray *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 *flags = [arguments subarrayWithRange:NSMakeRange(1, arguments.count - 1)]; + return serve(socketPath, + integerArgument(flags, @"--idle-timeout", kDefaultIdleTimeoutSeconds), + boolArgument(flags, @"--exit-on-disconnect", YES)); + } +} diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.h b/apple/snapshot-bridge/SnapshotBridgeRuntime.h new file mode 100644 index 000000000..0dcdeb027 --- /dev/null +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.h @@ -0,0 +1,30 @@ +#import + +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; + +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 + error:(NSDictionary *_Nullable *_Nonnull)error; +@end + +BridgeRuntime *_Nullable sharedRuntime(NSString *_Nullable *_Nullable error); + +NS_ASSUME_NONNULL_END diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m new file mode 100644 index 000000000..fb6cb4387 --- /dev/null +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -0,0 +1,297 @@ +/* + * 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 +#import +#import + +#import + +NSString *const kProtocolVersionKey = @"protocolVersion"; +NSString *const kSourceVersionKey = @"sourceVersion"; +NSString *const kRequestIdKey = @"requestId"; +NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.2"; +const NSUInteger kProtocolVersion = 1; +const uint32_t kMaximumFrameBytes = 16 * 1024 * 1024; +const NSUInteger kMaximumDepth = 128; +const NSUInteger kMaximumNodes = 10000; + +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 *_Nullable (*DefaultSnapshotParametersFn)(void); +typedef NSArray *_Nullable (*AttributeNumbersForNamesFn)(NSArray *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 *)options + error:(NSError **)error; +@end + +@interface XCAccessibilityElement : NSObject +- (nullable void *)AXUIElement; +@end + +@protocol XCAccessibilityElementFactory ++ (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 }; +} + +@implementation BridgeRuntime { + XCTAccessibilityFramework *_framework; + Class _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)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 *)namesByNumber + depth:(NSUInteger)depth + maxDepth:(NSUInteger)maxDepth + maxNodes:(NSUInteger)maxNodes + count:(NSUInteger *)count + truncated:(BOOL *)truncated +{ + if (![snapshot isKindOfClass:NSDictionary.class]) return nil; + if (*count >= maxNodes) { + *truncated = YES; + return nil; + } + (*count)++; + + NSDictionary *attributes = ((NSDictionary *)snapshot)[kSnapshotAttributes]; + if (![attributes isKindOfClass:NSDictionary.class]) 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]) children = @[]; + 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]; + if (built) [builtChildren addObject:built]; + 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 + error:(NSDictionary *_Nullable *_Nonnull)error +{ + XCAccessibilityElement *root = [_elementClass elementWithProcessIdentifier:pid]; + if (!root) { + if (error) *error = failureResponse(requestId, @"application_unavailable", @"application-element-missing", @"application element is unavailable"); + return nil; + } + void *raw = [root AXUIElement]; + if (!raw) { + if (error) *error = failureResponse(requestId, @"application_unavailable", @"application-element-missing", @"application element is unavailable"); + return nil; + } + + NSArray *names = @[ + kAttributeElementType, + kAttributeElementBaseType, + kAttributeLabel, + kAttributeValue, + kAttributeIdentifier, + kAttributeFrame, + kAttributeAutomationType, + kAttributeChildren, + ]; + NSArray *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"); + return nil; + } + NSMutableDictionary *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"); + 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); + return nil; + } + BOOL truncated = NO; + NSUInteger count = 0; + NSDictionary *tree = [self nodeFromSnapshot:snapshot + namesByNumber:namesByNumber + depth:0 + maxDepth:maxDepth + maxNodes:maxNodes + count:&count + truncated:&truncated]; + if (!tree) { + if (error) *error = failureResponse(requestId, @"malformed_tree", @"snapshot-root-invalid", @"AX snapshot did not contain a materialized root node"); + return nil; + } + return @{ + kProtocolVersionKey : @(kProtocolVersion), + kSourceVersionKey : kSourceVersion, + kRequestIdKey : requestId ?: @"", + @"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; +} diff --git a/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md new file mode 100644 index 000000000..6cab6fea9 --- /dev/null +++ b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md @@ -0,0 +1,42 @@ +# iOS Simulator snapshot-source live evidence + +- Issue: #2196 +- Observed: 2026-09-04T07:16:37Z +- Target: verified booted `iPhone 17 Pro` Simulator, iOS 26.2 +- UDID: `F7D6F9A4-4FCC-4DD7-AC0B-3280C9319CB9` +- App: `Agent Device Tester` (`com.callstack.agentdevicelab`), PID `60439` +- Workflow: `agent-device open` established the session; the private facet was then called directly with an injected host and raw projection. No production routing or proxy path was used. + +## Result + +| Measurement | Observed | +|---|---:| +| Acquisition latency | 1202 ms | +| Raw nodes | 158 | +| Truncated | false | +| Viewport | 402 x 874 | +| Producer | `simulator-ax-bridge` | +| Intent | `full` | +| Residue | `hittability` unavailable | + +The returned lineage carried the supplied target id and opaque generation. The source returned raw nodes with the observed target PID and did not claim hittability or interaction-query facts. + +## Build and cache + +- Protocol version: `1` +- Source version: `agent-device-simulator-ax-v1.5.2` +- Source hash: `f9e9b741fc354e82d1fa1a78d5a92cacc5d6bb81f1eb2ab3bcb5748fc150d432` +- Cache key: `a9807a2888a1dad82709665d837dc0d9` +- The live acquisition used the prepared cache entry; deterministic tests cover cold publish, atomic concurrent publish, corrupt-entry rejection, source invalidation, and toolchain invalidation. + +## Package size + +- Measured npm artifact: 482 files, 1,034,728-byte tarball, 3,507,967 unpacked and clean-installed bytes. +- Compared with the #2189 published baseline recorded by PR #2204: +20,923 tarball bytes and +69,029 unpacked/clean-installed bytes (baseline 1,013,805 / 3,438,938 bytes). +- The new `apple-snapshot-bridge` component contributes 25,532 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. diff --git a/package.json b/package.json index d349a7043..5d036d324 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/platform-apple/package.json b/packages/platform-apple/package.json index 1d62f65a4..49a35fe88 100644 --- a/packages/platform-apple/package.json +++ b/packages/platform-apple/package.json @@ -53,6 +53,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" diff --git a/packages/platform-apple/src/snapshot-source-facade.ts b/packages/platform-apple/src/snapshot-source-facade.ts new file mode 100644 index 000000000..e0dacdde3 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source-facade.ts @@ -0,0 +1,41 @@ +/** + * 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 { + SnapshotSourceBridgeBinary, + SnapshotSourceFailure, + SnapshotSourceFailureKind, + SnapshotSourceHost, + SnapshotSourceLimits, + SnapshotSourceOutcome, + SnapshotSourceRequest, + SnapshotSourceSuccess, + SnapshotSourceTarget, +} from './snapshot-source/types.ts'; +export type { + SimulatorSnapshotSource, + SimulatorSnapshotSourceOptions, +} from './snapshot-source/adapter.ts'; + +export function createSimulatorSnapshotSource( + options: import('./snapshot-source/adapter.ts').SimulatorSnapshotSourceOptions = {}, +): import('./snapshot-source/adapter.ts').SimulatorSnapshotSource { + let implementation: + | Promise + | undefined; + const load = async () => { + implementation ??= import('./snapshot-source/adapter.ts').then( + ({ createSimulatorSnapshotSource: create }) => create(options), + ); + return await implementation; + }; + return { + prepare: async (input) => await (await load()).prepare(input), + acquire: async (request) => await (await load()).acquire(request), + acquireOutcome: async (request) => await (await load()).acquireOutcome(request), + close: async () => { + if (implementation) await (await implementation).close(); + }, + }; +} diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts new file mode 100644 index 000000000..643e7155b --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -0,0 +1,199 @@ +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 'node:test'; +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'); + 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(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' }, + ]); + + const prepared = await source.prepare({ runtime: 'iOS 26.2' }); + assert.equal(prepared.path.length > 0, true); + assert.equal(fixture.builds, 1); + + fixture.responsePid = 999; + const outcome = await source.acquireOutcome({ + 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'); + } finally { + await source.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +type AdapterFixture = { + host: SnapshotSourceHost; + builds: number; + responsePid: number; +}; + +function createAdapterHost(): AdapterFixture { + const realHost = createSnapshotSourceHost(); + const fixture: AdapterFixture = { host: undefined as never, builds: 0, responsePid: 321 }; + const host: SnapshotSourceHost = { + ...realHost, + run: async (command, args) => { + if (command === 'xcrun' && args.includes('clang')) { + fixture.builds += 1; + 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), + }; + 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; + }; + 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(), + 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')); + } +} diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts new file mode 100644 index 000000000..459973acb --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -0,0 +1,241 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { CaptureHint, IosSnapshotAcquisition } from '@agent-device/contracts/ios-snapshot'; +import { ensureSnapshotBridgeBinary } from './cache.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 { + SnapshotSourceBridgeBinary, + SnapshotSourceHost, + SnapshotSourceLimits, + SnapshotSourceOutcome, + SnapshotSourceRequest, + SnapshotSourceSuccess, +} from './types.ts'; + +const SNAPSHOT_SOURCE_PRODUCER = 'simulator-ax-bridge' as const; + +export type SimulatorSnapshotSourceOptions = Readonly<{ + host?: SnapshotSourceHost; + limits?: Partial; + sourceRoot?: string; + cacheRoot?: string; +}>; + +export type SimulatorSnapshotSource = Readonly<{ + prepare( + input: Readonly<{ runtime: string; signal?: AbortSignal }>, + ): Promise; + acquire(request: SnapshotSourceRequest): Promise; + acquireOutcome(request: SnapshotSourceRequest): Promise; + close(): Promise; +}>; + +export function createSimulatorSnapshotSource( + options: SimulatorSnapshotSourceOptions = {}, +): SimulatorSnapshotSource { + const host = options.host ?? createSnapshotSourceHost(); + const manager = new SnapshotBridgeManager(host); + const prepared = new Map>(); + let closed = false; + + const prepare = async ( + input: Readonly<{ runtime: string; limits: SnapshotSourceLimits; signal?: AbortSignal }>, + ) => { + if (closed) throw snapshotSourceError('unsupported', 'source-closed'); + const key = `${input.runtime}\0${input.limits.maxNodes}\0${input.limits.maxTraversalDepth}`; + let preparation = prepared.get(key); + if (!preparation) { + preparation = host.withDiagnosticTimer( + 'ios.snapshot-source.prepare', + async () => + await ensureSnapshotBridgeBinary({ + host, + runtime: input.runtime, + limits: input.limits, + signal: input.signal, + sourceRoot: options.sourceRoot, + cacheRoot: options.cacheRoot, + }), + { producer: SNAPSHOT_SOURCE_PRODUCER }, + ); + prepared.set(key, preparation); + preparation.catch(() => { + if (prepared.get(key) === preparation) prepared.delete(key); + }); + } + return await preparation; + }; + + const acquire = async (request: SnapshotSourceRequest): Promise => { + if (closed) throw snapshotSourceError('unsupported', 'source-closed'); + validateRequest(request); + const limits = resolveSnapshotSourceLimits({ ...options.limits, ...request.limits }); + const maxDepth = resolveRequestedDepth(request.hint, limits.maxTraversalDepth); + const bridge = await prepare({ + runtime: request.target.runtime, + limits, + signal: request.signal, + }); + return await host.withDiagnosticTimer( + 'ios.snapshot-source.acquire', + async () => { + const envelope = await manager.request({ + target: request.target, + bridge, + limits, + maxDepth, + signal: request.signal, + }); + return { + stage: 'acquired', + acquisition: createAcquisition(request.hint, request.target, envelope, limits, maxDepth), + }; + }, + { producer: SNAPSHOT_SOURCE_PRODUCER }, + ); + }; + + const acquireOutcome = async (request: SnapshotSourceRequest): Promise => { + try { + return await acquire(request); + } catch (error) { + const failure = asSnapshotSourceError(error); + return { + stage: 'failed', + failure: { + kind: failure.failureKind, + code: failure.failureCode, + ...(failure.details ? { details: failure.details } : {}), + }, + } satisfies SnapshotSourceOutcome; + } + }; + + return { + prepare: async (input) => + await prepare({ + runtime: input.runtime, + limits: resolveSnapshotSourceLimits(options.limits), + signal: input.signal, + }), + acquire, + acquireOutcome, + close: async () => { + if (closed) return; + closed = true; + prepared.clear(); + 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 nodes = Object.freeze( + decoded.nodes.map((node) => Object.freeze({ ...node, pid: target.pid })), + ); + const residue = Object.freeze([ + { kind: 'unavailable-fact', fact: 'hittability' } as const, + ...(hint.interactiveOnly + ? ([{ kind: 'unavailable-fact', fact: 'interactive-query' }] as const) + : []), + ...(truncated + ? [truncationResidue(decoded.maxTraversalDepth, nodes.length, limits, maxDepth)] + : []), + ...(decoded.viewport.kind === 'missing' + ? ([{ kind: 'missing-viewport', reason: decoded.viewport.reason }] as const) + : []), + ]); + const lineage = Object.freeze({ + ...(target.targetId ? { targetId: target.targetId } : {}), + generation: target.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 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 }; +} diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts new file mode 100644 index 000000000..a07764b3f --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -0,0 +1,145 @@ +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 'node:test'; +import { createSnapshotSourceHost } from './host.ts'; +import { ensureSnapshotBridgeBinary } from './cache.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'); + + 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, + 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, + sourceRoot, + cacheRoot, + }); + assert.equal(hit.path, first.path); + assert.equal(builds, 1); + + await writeFile(first.path, 'corrupt'); + await ensureSnapshotBridgeBinary({ + host, + runtime: 'iOS 26.2', + limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + 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, + 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, + 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'); + 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, + 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 }); + } +}); + +function createFakeBuildHost( + binary: string | (() => string | Promise), + 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' + ? '15.6' + : command === 'uname' + ? 'arm64' + : '26.2'; + return { stdout, stderr: '', exitCode: 0 }; + }, + }; +} diff --git a/packages/platform-apple/src/snapshot-source/cache.ts b/packages/platform-apple/src/snapshot-source/cache.ts new file mode 100644 index 000000000..8725fa85a --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/cache.ts @@ -0,0 +1,269 @@ +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { snapshotSourceError } from './errors.ts'; +import { SNAPSHOT_SOURCE_PROTOCOL_VERSION, SNAPSHOT_SOURCE_VERSION } from './protocol.ts'; +import type { + SnapshotSourceBridgeBinary, + SnapshotSourceHost, + SnapshotSourceLimits, +} from './types.ts'; + +type ToolchainIdentity = Readonly<{ + xcode: string; + macos: string; + architecture: 'arm64' | 'x86_64'; + simulatorSdk: string; + simulatorRuntime: string; +}>; + +type SnapshotBridgeCacheManifest = Readonly<{ + schemaVersion: 1; + protocolVersion: number; + sourceVersion: string; + sourceHash: string; + cacheKey: string; + toolchain: ToolchainIdentity; + binarySha256: string; +}>; + +const CACHE_SCHEMA_VERSION = 1 as const; +const BRIDGE_FILENAME = 'snapshot-bridge'; +const MANIFEST_FILENAME = 'manifest.json'; +const SOURCE_FILENAMES = ['SnapshotBridge.m', 'SnapshotBridgeRuntime.m'] as const; +const BUILD_TIMEOUT_MS = 120_000; + +export async function ensureSnapshotBridgeBinary( + input: Readonly<{ + host: SnapshotSourceHost; + runtime: string; + limits: SnapshotSourceLimits; + signal?: AbortSignal; + sourceRoot?: string; + cacheRoot?: string; + }>, +): Promise { + const sourceRoot = input.sourceRoot ?? resolveSnapshotBridgeSourceRoot(input.host); + const sourceHash = await fingerprintSource(input.host, sourceRoot); + const toolchain = await readToolchainIdentity(input.host, input.runtime, input.signal); + const cacheKey = hashJson({ + schemaVersion: CACHE_SCHEMA_VERSION, + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + sourceHash, + toolchain, + limits: { + maxNodes: input.limits.maxNodes, + maxTraversalDepth: input.limits.maxTraversalDepth, + }, + }); + 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`)); + try { + const cached = await readValidCache(input.host, entryPath, { + sourceHash, + cacheKey, + toolchain, + }); + if (cached) return cached; + if (input.host.exists(entryPath)) await input.host.remove(entryPath); + + await input.host.ensureDirectory(cacheRoot); + const temporaryPath = path.join(cacheRoot, `.${cacheKey}.${input.host.processId()}.tmp`); + await input.host.remove(temporaryPath); + try { + 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', + ...SOURCE_FILENAMES.map((sourceFile) => path.join(sourceRoot, sourceFile)), + '-o', + outputPath, + ], + { signal: input.signal, timeoutMs: BUILD_TIMEOUT_MS, 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), + }); + } + await input.host.chmod(outputPath, 0o755); + const binarySha256 = await sha256File(input.host, outputPath); + 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`, + ); + 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 (host.exists(path.join(checkoutRoot, SOURCE_FILENAMES[0]))) return checkoutRoot; + const packagedRoot = path.join(projectRoot, 'dist', 'apple', 'snapshot-bridge'); + if (host.exists(path.join(packagedRoot, SOURCE_FILENAMES[0]))) 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: ToolchainIdentity; + }>, +): Promise { + 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; + 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)) !== manifest.binarySha256) return undefined; + return { + path: binaryPath, + sourceHash: expected.sourceHash, + cacheKey: expected.cacheKey, + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + }; + } catch { + return undefined; + } +} + +async function fingerprintSource(host: SnapshotSourceHost, root: string): Promise { + const files = await sourceFiles(host, root); + const hash = createHash('sha256'); + for (const filePath of files) { + hash.update(path.relative(root, filePath)); + hash.update('\0'); + hash.update(await host.readBinary(filePath)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +async function sourceFiles(host: SnapshotSourceHost, root: string): Promise { + const entries = await host.listDirectory(root); + const files: string[] = []; + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) files.push(...(await sourceFiles(host, entryPath))); + else if (entry.isFile()) files.push(entryPath); + } + return files.sort(); +} + +async function readToolchainIdentity( + host: SnapshotSourceHost, + simulatorRuntime: string, + signal: AbortSignal | undefined, +): Promise { + const [xcode, macos, architecture, simulatorSdk] = await Promise.all([ + toolOutput(host, 'xcodebuild', ['-version'], signal), + toolOutput(host, 'sw_vers', ['-productVersion'], signal), + toolOutput(host, 'uname', ['-m'], signal), + toolOutput(host, 'xcrun', ['--sdk', 'iphonesimulator', '--show-sdk-version'], signal), + ]); + const runtime = simulatorRuntime.trim(); + if (!runtime) throw snapshotSourceError('unsupported', 'simulator-runtime-missing'); + return { + xcode, + macos, + architecture: simulatorArchitecture(architecture), + simulatorSdk, + simulatorRuntime: runtime, + }; +} + +function simulatorArchitecture(value: string): 'arm64' | 'x86_64' { + if (value === 'arm64' || value === 'x86_64') return value; + throw snapshotSourceError('unsupported', 'simulator-architecture-unsupported', { + architecture: value, + }); +} + +async function toolOutput( + host: SnapshotSourceHost, + command: string, + args: string[], + signal: AbortSignal | undefined, +): Promise { + const result = await host.run(command, args, { allowFailure: true, signal, timeoutMs: 10_000 }); + 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; +} + +async function sha256File(host: SnapshotSourceHost, filePath: string): Promise { + 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); +} diff --git a/packages/platform-apple/src/snapshot-source/errors.ts b/packages/platform-apple/src/snapshot-source/errors.ts new file mode 100644 index 000000000..048e4c9aa --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/errors.ts @@ -0,0 +1,62 @@ +import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; +import type { SnapshotSourceFailureKind } from './types.ts'; + +const APP_ERROR_CODE_BY_KIND: Readonly> = { + 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> = {}, + 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> = {}, + 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); + } + return snapshotSourceError( + 'transport-failure', + 'unexpected-host-error', + { error: error instanceof Error ? error.message : String(error) }, + error, + ); +} diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts new file mode 100644 index 000000000..b0956015e --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +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'); + const second = snapshotSourceSocketPath(host, 'simulator-2'); + + assert.equal(first.length < 104, true); + assert.equal(second.length < 104, true); + assert.notEqual(first, second); +}); diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts new file mode 100644 index 000000000..178f43981 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -0,0 +1,199 @@ +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, + readHostDirectory, + readHostTextFile, + removeHostPath, + renameHostPath, + hostTemporaryDirectory, + 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 { withKeyedLock } from '@agent-device/kernel/keyed-lock'; +import { findProjectRoot } from '@agent-device/host-kit/version'; +import { snapshotSourceError } from './errors.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'; +const snapshotSourceLocks = new Map>(); + +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, + listDirectory: async (directoryPath) => + await readHostDirectory(directoryPath, { withFileTypes: true }), + ensureDirectory: ensureHostDirectory, + chmod: chmodHostFile, + exists: hostFileExistsSync, + rename: renameHostPath, + remove: removeHostPath, + acquireLock: acquireSnapshotSourceLock, + withKeyedLock: async (key, action) => await withKeyedLock(snapshotSourceLocks, key, action), + emitDiagnostic, + withDiagnosticTimer, + processId: hostProcessId, + readProcessStartTime, + temporaryDirectory: hostTemporaryDirectory, + }; +} + +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 { + if (options.signal?.aborted) { + throw snapshotSourceError('cancelled', 'abort-signal'); + } + const timeoutMs = Math.max(1, Math.floor(options.timeoutMs)); + return await new Promise((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 }); + }); +} + +async function acquireSnapshotSourceLock(lockPath: string): Promise<() => Promise> { + const pid = hostProcessId(); + return await acquireProcessLock({ + lockDirPath: lockPath, + owner: { + pid, + startTime: readProcessStartTime(pid), + acquiredAtMs: Date.now(), + }, + timeoutMs: 180_000, + pollMs: 100, + ownerGraceMs: 5_000, + description: 'iOS Simulator snapshot bridge cache', + }); +} + +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): string { + const targetKey = createHash('sha256').update(udid).digest('hex').slice(0, 12); + return path.join( + SNAPSHOT_SOCKET_ROOT, + `agent-device-ax-${targetKey}-${host.processId()}`, + 'snapshot.sock', + ); +} diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts new file mode 100644 index 000000000..c78b5093a --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { createSnapshotSourceHost } from './host.ts'; +import { SnapshotSourceError } from './errors.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 }); + await manager.request({ target, bridge, limits, maxDepth: 10 }); + + 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 does not reuse the previous helper', async () => { + const fixture = createLifecycleFixture(); + const manager = new SnapshotBridgeManager(fixture.host); + + await manager.request({ target, bridge, limits, maxDepth: 10 }); + await manager.request({ + target: { ...target, generation: 'generation-2' }, + bridge, + limits, + maxDepth: 10, + }); + + assert.equal(fixture.processes.length, 2); + assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']); + await manager.close(); + assert.deepEqual(fixture.processes[1]!.signals, ['SIGTERM']); +}); + +test('request cancellation destroys only the socket and leaves a healthy helper alive', 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, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 10); + + await assert.rejects( + request, + (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled', + ); + assert.equal(fixture.processes[0]?.signals.length, 0); + assert.equal(fixture.processes[0]?.alive, true); + 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 }); + 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 }); + assert.equal(fixture.processes.length, 2); + 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 }), + (error: unknown) => + error instanceof SnapshotSourceError && error.failureKind === 'stale-target', + ); + 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 }), + (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout', + ); + await manager.close(); +}); + +type LifecycleFixture = { + host: SnapshotSourceHost; + processes: FakeProcess[]; + sockets: FakeSocket[]; +}; + +function createLifecycleFixture( + options: { + responseDelayMs?: number; + responsePid?: number; + responseErrorKind?: string; + } = {}, +): LifecycleFixture { + const processes: FakeProcess[] = []; + const sockets: FakeSocket[] = []; + const realHost = createSnapshotSourceHost(); + const host: SnapshotSourceHost = { + ...realHost, + start: () => { + const process = new FakeProcess(700 + processes.length); + processes.push(process); + return process; + }, + connect: async () => { + const socket = new FakeSocket( + options.responseDelayMs ?? 0, + options.responsePid ?? target.pid, + options.responseErrorKind, + ); + sockets.push(socket); + return socket; + }, + }; + return { host, processes, sockets }; +} + +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; + private readonly responseDelayMs: number; + private readonly responsePid: number; + private readonly responseErrorKind: string | undefined; + + constructor(responseDelayMs: number, responsePid: number, responseErrorKind?: string) { + super(); + this.responseDelayMs = responseDelayMs; + this.responsePid = responsePid; + this.responseErrorKind = responseErrorKind; + } + + 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; + }; + 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, + 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, + 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')); + } +} diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts new file mode 100644 index 000000000..6027bee95 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -0,0 +1,298 @@ +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { sleep } from '@agent-device/host-kit/retry'; +import { asSnapshotSourceError, snapshotSourceError, SnapshotSourceError } from './errors.ts'; +import { + assertSnapshotBridgeEnvelope, + bridgeFailureFromEnvelope, + createSnapshotBridgeDescribeRequest, + encodeSnapshotBridgeFrame, + parseSnapshotBridgeEnvelope, + SnapshotBridgeFrameDecoder, +} from './protocol.ts'; +import { snapshotSourceSocketPath } from './host.ts'; +import type { + SnapshotSourceBridgeBinary, + SnapshotSourceHost, + SnapshotSourceLimits, + SnapshotSourceProcess, + SnapshotSourceSocket, + SnapshotSourceTarget, +} from './types.ts'; +import type { SnapshotBridgeEnvelope } from './protocol.ts'; +const CONNECT_RETRY_DELAY_MS = 20, + CONNECT_ATTEMPT_TIMEOUT_MS = 250; +const SHUTDOWN_TERM_TIMEOUT_MS = 500, + SHUTDOWN_KILL_TIMEOUT_MS = 500; +type BridgeSession = { + readonly udid: string; + readonly generation: string; + readonly bridgePath: string; + readonly socketPath: string; + readonly process: SnapshotSourceProcess; + socket?: SnapshotSourceSocket; +}; +type SnapshotBridgeRequest = Readonly<{ + target: SnapshotSourceTarget; + bridge: SnapshotSourceBridgeBinary; + limits: SnapshotSourceLimits; + maxDepth: number; + signal?: AbortSignal; +}>; +export class SnapshotBridgeManager { + private readonly sessions = new Map(); + private closed = false; + private readonly host: SnapshotSourceHost; + constructor(host: SnapshotSourceHost) { + this.host = host; + } + async request(input: SnapshotBridgeRequest): Promise { + if (this.closed) throw snapshotSourceError('unsupported', 'source-closed'); + return await this.host.withKeyedLock(`simulator:${input.target.udid}`, async () => { + const session = await this.ensureSession(input, input.signal); + try { + return await this.exchange( + session, + input.target, + input.maxDepth, + input.limits, + input.signal, + ); + } catch (error) { + const normalized = asSnapshotSourceError(error); + if (normalized.failureKind === 'process-crash') { + await this.removeSession(session, false); + } else if (normalized.failureKind === 'transport-failure') { + session.socket?.destroy(); + session.socket = undefined; + } + throw normalized; + } + }); + } + async close(): Promise { + 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, + signal: AbortSignal | undefined, + ): Promise { + const key = input.target.udid; + const existing = this.sessions.get(key); + if ( + existing && + existing.generation === input.target.generation && + existing.bridgePath === input.bridge.path && + existing.process.isAlive() + ) { + if (!existing.socket || existing.socket.destroyed) { + existing.socket = await this.connectUntilReady(existing, input.limits, signal); + } + return existing; + } + if (existing) await this.removeSession(existing, true); + + const socketPath = snapshotSourceSocketPath(this.host, input.target.udid); + await this.host.ensureDirectory(path.dirname(socketPath)); + await this.host.remove(socketPath); + const { target, bridge } = input; + const bridgeProcess = this.host.start(target.udid, bridge.path, socketPath, { signal }); + const session: BridgeSession = { + udid: input.target.udid, + generation: input.target.generation, + bridgePath: input.bridge.path, + socketPath, + process: bridgeProcess, + }; + this.sessions.set(key, session); + try { + session.socket = await this.connectUntilReady(session, input.limits, signal); + return session; + } catch (error) { + await this.removeSession(session, true); + throw asSnapshotSourceError(error); + } + } + + private async connectUntilReady( + session: BridgeSession, + limits: SnapshotSourceLimits, + signal: AbortSignal | undefined, + ): Promise { + const deadline = Date.now() + limits.maxDurationMs; + let lastError: unknown; + while (Date.now() < deadline) { + if (signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal'); + if (!session.process.isAlive()) throw bridgeProcessExited(session.process); + const remainingMs = deadline - Date.now(); + try { + return await this.host.connect(session.socketPath, { + signal, + timeoutMs: Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs), + }); + } catch (error) { + lastError = error; + if (error instanceof SnapshotSourceError && error.failureKind === 'cancelled') + throw asSnapshotSourceError(error); + await sleep(Math.min(CONNECT_RETRY_DELAY_MS, Math.max(1, remainingMs))); + } + } + throw snapshotSourceError('timeout', 'bridge-connect-deadline', { + udid: session.udid, + ...(lastError instanceof Error ? { lastError: lastError.message } : {}), + }); + } + + private async exchange( + session: BridgeSession, + target: SnapshotSourceTarget, + maxDepth: number, + limits: SnapshotSourceLimits, + signal: AbortSignal | undefined, + ): Promise { + if (!session.socket || session.socket.destroyed) { + session.socket = await this.connectUntilReady(session, limits, signal); + } + const socket = session.socket; + const requestId = randomUUID(); + const request = createSnapshotBridgeDescribeRequest({ + requestId, + pid: target.pid, + maxDepth: Math.min(limits.maxTraversalDepth, maxDepth), + maxNodes: limits.maxNodes, + }); + const frame = encodeSnapshotBridgeFrame(request, limits); + return await this.roundTrip(session, socket, frame, requestId, limits, signal, target.pid); + } + + private async roundTrip( + session: BridgeSession, + socket: SnapshotSourceSocket, + frame: Buffer, + requestId: string, + limits: SnapshotSourceLimits, + signal: AbortSignal | undefined, + expectedPid: number, + ): Promise { + const decoder = new SnapshotBridgeFrameDecoder(limits.maxResponseBytes - 4); + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + socket.destroy(); + finishReject(snapshotSourceError('timeout', 'bridge-request-deadline')); + }, limits.maxDurationMs); + const onAbort = () => { + socket.destroy(); + finishReject(snapshotSourceError('cancelled', 'abort-signal')); + }; + const onData = (chunk: unknown) => { + try { + if (!Buffer.isBuffer(chunk)) + throw snapshotSourceError('transport-failure', 'bridge-data-invalid'); + const frames = decoder.push(chunk); + for (const body of frames) { + const envelope = parseSnapshotBridgeEnvelope(body); + assertSnapshotBridgeEnvelope(envelope, requestId); + if (typeof envelope.pid !== 'number' || envelope.pid !== expectedPid) { + throw snapshotSourceError('stale-target', 'bridge-pid-mismatch', { + expectedPid, + observedPid: envelope.pid, + }); + } + if (envelope.ok !== true) bridgeFailureFromEnvelope(envelope); + if (typeof envelope.truncated !== 'boolean') { + throw snapshotSourceError('malformed-tree', 'truncated-invalid'); + } + finishResolve(envelope); + return; + } + } catch (error) { + socket.destroy(); + finishReject(error); + } + }; + const onError = (error: unknown) => finishReject(asSnapshotSourceError(error)); + const onClose = () => { + if (!settled) { + finishReject( + session.process.isAlive() + ? snapshotSourceError('transport-failure', 'bridge-connection-closed') + : bridgeProcessExited(session.process), + ); + } + }; + session.process.wait.then( + () => { + if (!settled) finishReject(bridgeProcessExited(session.process)); + }, + (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); + signal?.removeEventListener('abort', onAbort); + socket.off('data', onData); + socket.off('error', onError); + socket.off('close', onClose); + action(); + }; + + socket.on('data', onData); + socket.on('error', onError); + socket.on('close', onClose); + signal?.addEventListener('abort', onAbort, { once: true }); + try { + if (signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal'); + socket.write(frame); + } catch (error) { + socket.destroy(); + finishReject(asSnapshotSourceError(error)); + } + }); + } + + private async removeSession(session: BridgeSession, stopProcess: boolean): Promise { + 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 { + 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); + } +} + +function bridgeProcessExited(bridgeProcess: SnapshotSourceProcess): SnapshotSourceError { + return snapshotSourceError('process-crash', 'bridge-exited', { + pid: bridgeProcess.pid, + log: bridgeProcess.readLog().slice(-64 * 1024), + }); +} + +async function waitForProcess( + bridgeProcess: SnapshotSourceProcess, + timeoutMs: number, +): Promise { + await Promise.race([ + bridgeProcess.wait.catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +} diff --git a/packages/platform-apple/src/snapshot-source/limits.ts b/packages/platform-apple/src/snapshot-source/limits.ts new file mode 100644 index 000000000..3896a74cc --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/limits.ts @@ -0,0 +1,50 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { SnapshotSourceLimits } from './types.ts'; + +const FRAME_HEADER_BYTES = 4; + +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 | 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 <= 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); +} diff --git a/packages/platform-apple/src/snapshot-source/protocol.test.ts b/packages/platform-apple/src/snapshot-source/protocol.test.ts new file mode 100644 index 000000000..462a9f004 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/protocol.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + assertSnapshotBridgeEnvelope, + bridgeFailureFromEnvelope, + createSnapshotBridgeDescribeRequest, + encodeSnapshotBridgeFrame, + parseSnapshotBridgeEnvelope, + SNAPSHOT_SOURCE_PROTOCOL_VERSION, + SNAPSHOT_SOURCE_VERSION, + SnapshotBridgeFrameDecoder, +} from './protocol.ts'; +import type { SnapshotSourceLimits } from './types.ts'; + +const limits: SnapshotSourceLimits = { + maxRequestBytes: 1024, + maxResponseBytes: 4096, + maxNodes: 20, + maxTraversalDepth: 10, + maxDurationMs: 1000, +}; + +test('snapshot bridge frames decode across split and coalesced socket chunks', () => { + const first = encodeSnapshotBridgeFrame({ requestId: 'one', value: 1 }, limits); + const second = encodeSnapshotBridgeFrame({ requestId: 'two', value: 2 }, limits); + const decoder = new SnapshotBridgeFrameDecoder(limits.maxResponseBytes); + + assert.deepEqual(decoder.push(first.subarray(0, 3)), []); + assert.deepEqual(decoder.push(Buffer.concat([first.subarray(3), second])), [ + Buffer.from('{"requestId":"one","value":1}'), + Buffer.from('{"requestId":"two","value":2}'), + ]); +}); + +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, + maxDepth: 4, + maxNodes: 10, + }); + assert.deepEqual(request, { + verb: 'describe', + requestId: 'request-1', + pid: 123, + snapshotTree: true, + automationMode: true, + maxDepth: 4, + maxNodes: 10, + }); + + 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'], + ] 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, + ); + } +}); diff --git a/packages/platform-apple/src/snapshot-source/protocol.ts b/packages/platform-apple/src/snapshot-source/protocol.ts new file mode 100644 index 000000000..63045cc7c --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/protocol.ts @@ -0,0 +1,139 @@ +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.2'; +const FRAME_HEADER_BYTES = 4; + +export type SnapshotBridgeEnvelope = Readonly>; + +export function encodeSnapshotBridgeFrame( + value: unknown, + limits: Pick, +): 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 buffer = Buffer.alloc(0); + 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[] { + this.buffer = Buffer.concat([this.buffer, chunk]); + const frames: Buffer[] = []; + while (this.buffer.length >= FRAME_HEADER_BYTES) { + const bodyBytes = this.buffer.readUInt32BE(0); + if (bodyBytes === 0 || bodyBytes > this.maxFrameBytes) { + throw snapshotSourceError('malformed-tree', 'frame-limit-exceeded', { + bodyBytes, + maxFrameBytes: this.maxFrameBytes, + }); + } + const frameBytes = FRAME_HEADER_BYTES + bodyBytes; + if (this.buffer.length < frameBytes) break; + frames.push(this.buffer.subarray(FRAME_HEADER_BYTES, frameBytes)); + this.buffer = this.buffer.subarray(frameBytes); + } + return frames; + } +} + +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; + maxDepth: number; + maxNodes: number; + }>, +): Readonly> { + return Object.freeze({ + verb: 'describe', + requestId: input.requestId, + pid: input.pid, + snapshotTree: true, + automationMode: true, + maxDepth: input.maxDepth, + maxNodes: input.maxNodes, + }); +} + +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 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); + throw snapshotSourceError('transport-failure', code, details); +} + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/platform-apple/src/snapshot-source/tree.test.ts b/packages/platform-apple/src/snapshot-source/tree.test.ts new file mode 100644 index 000000000..8b33af9b3 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/tree.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +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/, + ); +}); diff --git a/packages/platform-apple/src/snapshot-source/tree.ts b/packages/platform-apple/src/snapshot-source/tree.ts new file mode 100644 index 000000000..99649aec6 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/tree.ts @@ -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> = { + UIApplication: 'Application', + UIWindow: 'Window', +}; + +const NODE_KEYS = new Set(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, + 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, + 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; +} diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts new file mode 100644 index 000000000..ab34fdfbc --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -0,0 +1,131 @@ +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'; + +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; + 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>; +}>; + +export type SnapshotSourceOutcome = + | SnapshotSourceSuccess + | Readonly<{ + stage: 'failed'; + failure: SnapshotSourceFailure; + }>; + +export type SnapshotSourceProcess = Readonly<{ + pid: number; + wait: Promise; + 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; + temporaryDirectory(): string; + run(command: string, args: string[], options?: ExecOptions): Promise; + start( + udid: string, + bridgePath: string, + socketPath: string, + options?: { signal?: AbortSignal }, + ): SnapshotSourceProcess; + connect( + socketPath: string, + options: { signal?: AbortSignal; timeoutMs: number }, + ): Promise; + readText(path: string): Promise; + readBinary(path: string): Promise; + writeText(path: string, contents: string): Promise; + listDirectory( + path: string, + ): Promise>; + ensureDirectory(path: string): Promise; + chmod(path: string, mode: number): Promise; + exists(path: string): boolean; + rename(sourcePath: string, destinationPath: string): Promise; + remove(path: string): Promise; + acquireLock(path: string): Promise<() => Promise>; + withKeyedLock(key: string, action: () => Promise): Promise; + emitDiagnostic(event: { + level?: 'debug' | 'info' | 'warn' | 'error'; + phase: string; + durationMs?: number; + data?: Record; + }): void; + withDiagnosticTimer( + phase: string, + action: () => Promise | T, + data?: Record, + ): Promise; + processId(): number; + readProcessStartTime(pid: number): 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; +}>; diff --git a/scripts/__tests__/fixtures/size-report-npm-pack.json b/scripts/__tests__/fixtures/size-report-npm-pack.json index 3bac3f782..94615281f 100644 --- a/scripts/__tests__/fixtures/size-report-npm-pack.json +++ b/scripts/__tests__/fixtures/size-report-npm-pack.json @@ -5,6 +5,7 @@ { "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/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 }, diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index b5db3c066..3a0586431 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -23,6 +23,7 @@ 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/macos-helper/Sources/main.swift', 'macos-helper'], ['android/snapshot-helper/dist/helper.apk', 'android-helpers'], ['android/snapshot-helper/dist/helper.manifest.json', 'android-helpers'], @@ -73,6 +74,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, diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index e4e57a356..8de978c83 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -550,6 +550,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(), [ diff --git a/scripts/layering/platform-package-policy.test.ts b/scripts/layering/platform-package-policy.test.ts index b2af7e8fc..e70ffecf1 100644 --- a/scripts/layering/platform-package-policy.test.ts +++ b/scripts/layering/platform-package-policy.test.ts @@ -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', ]) { diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts index 3d18e6916..bffa1956f 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -87,6 +87,7 @@ const MECHANICS_FACET_SUBPATHS: Readonly + entryPath === 'apple/snapshot-bridge' || entryPath.startsWith('apple/snapshot-bridge/'), + }, { id: 'macos-helper', label: 'macOS helper source', @@ -67,6 +73,7 @@ export function collectNpmPack(root) { export function assertPublishPackageContents(entries) { const paths = entries.map((entry) => entry.path); const requiredAssets = [ + { directory: 'apple/snapshot-bridge/', suffix: 'SnapshotBridge.m' }, { directory: 'android/snapshot-helper/dist/', suffix: '.apk' }, { directory: 'android/snapshot-helper/dist/', suffix: '.manifest.json' }, { directory: 'android/ime-helper/dist/', suffix: '.apk' }, From 6d39faaa6a36a010b346d36bd576194d936115ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 11:02:14 +0200 Subject: [PATCH 2/9] fix: address Simulator AX bridge review comments --- .fallowrc.json | 22 +- apple/snapshot-bridge/SnapshotBridge.m | 49 +++- apple/snapshot-bridge/SnapshotBridgeRuntime.h | 5 +- apple/snapshot-bridge/SnapshotBridgeRuntime.m | 80 ++++++- ...os-simulator-snapshot-source-2026-09-04.md | 22 ++ .../src/snapshot-source-facade.ts | 34 +-- .../src/snapshot-source/adapter.test.ts | 51 +++- .../src/snapshot-source/adapter.ts | 167 +++++++------- .../src/snapshot-source/cache-identity.ts | 99 ++++++++ .../src/snapshot-source/cache.test.ts | 94 +++++++- .../src/snapshot-source/cache.ts | 175 ++++++-------- .../src/snapshot-source/deadline.ts | 45 ++++ .../src/snapshot-source/errors.ts | 8 + .../fixtures/native-wire-golden.json | 51 ++++ .../src/snapshot-source/host.test.ts | 2 +- .../src/snapshot-source/host.ts | 42 +++- .../src/snapshot-source/lifecycle.test.ts | 115 +++++++++- .../src/snapshot-source/lifecycle.ts | 217 ++++++------------ .../src/snapshot-source/limits.ts | 28 +++ .../src/snapshot-source/protocol.test.ts | 74 +++++- .../src/snapshot-source/protocol.ts | 68 +++++- .../src/snapshot-source/transport.ts | 113 +++++++++ .../src/snapshot-source/tree.test.ts | 2 +- .../src/snapshot-source/types.ts | 6 +- .../fixtures/size-report-npm-pack.json | 1 + scripts/__tests__/size-report-package.test.ts | 16 ++ scripts/check-package.ts | 2 + .../size-install.test.ts | 19 +- scripts/size-report-install.mjs | 19 ++ scripts/size-report-package.mjs | 29 ++- 30 files changed, 1263 insertions(+), 392 deletions(-) create mode 100644 packages/platform-apple/src/snapshot-source/cache-identity.ts create mode 100644 packages/platform-apple/src/snapshot-source/deadline.ts create mode 100644 packages/platform-apple/src/snapshot-source/fixtures/native-wire-golden.json create mode 100644 packages/platform-apple/src/snapshot-source/transport.ts diff --git a/.fallowrc.json b/.fallowrc.json index 97b5230c5..330141786 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -301,9 +301,27 @@ "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, so keep the complete named facet surface explicit.", + "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": ["*"] + "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/.", diff --git a/apple/snapshot-bridge/SnapshotBridge.m b/apple/snapshot-bridge/SnapshotBridge.m index 2adadd3b0..681ae93c8 100644 --- a/apple/snapshot-bridge/SnapshotBridge.m +++ b/apple/snapshot-bridge/SnapshotBridge.m @@ -63,15 +63,27 @@ static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, 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 != nil && (![snapshotTree isKindOfClass:NSNumber.class] || ![snapshotTree boolValue])) { + 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)) { - return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"maxDepth and maxNodes are outside bridge bounds"); + !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; @@ -79,6 +91,7 @@ static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, if (!runtime) { NSMutableDictionary *unavailable = [failureResponse(requestId, @"unsupported", @"runtime-unavailable", setupError) mutableCopy]; unavailable[@"pid"] = pidValue; + unavailable[@"generation"] = generation; return unavailable; } NSDictionary *error = nil; @@ -86,11 +99,14 @@ static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, 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"); @@ -126,20 +142,35 @@ static BOOL writeFully(int fd, const void *buffer, size_t length) return YES; } -static NSData *serializedResponse(NSDictionary *response) +static NSData *serializedResponse(NSDictionary *response, NSUInteger maxResponseBytes) { NSError *error = nil; + NSData *data = nil; @try { - NSData *data = [NSJSONSerialization dataWithJSONObject:response options:0 error:&error]; - if (data) return data; + 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"); } - NSDictionary *fallback = failureResponse( - response[kRequestIdKey], @"malformed_tree", @"response-not-json-safe", error.localizedDescription ?: @"response was not JSON serializable"); + 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)) { @@ -193,7 +224,7 @@ static int serve(NSString *socketPath, int idleTimeoutSeconds, BOOL exitOnDiscon 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); + 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)) || diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.h b/apple/snapshot-bridge/SnapshotBridgeRuntime.h index 0dcdeb027..9a2bebf86 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.h +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.h @@ -10,6 +10,7 @@ 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, @@ -22,7 +23,9 @@ NSDictionary *failureResponse(NSString *requestId, maxDepth:(NSUInteger)maxDepth maxNodes:(NSUInteger)maxNodes requestId:(NSString *)requestId - error:(NSDictionary *_Nullable *_Nonnull)error; + generation:(NSString *)generation + maxDurationMs:(NSUInteger)maxDurationMs + error:(NSDictionary *_Nullable *_Nonnull)error; @end BridgeRuntime *_Nullable sharedRuntime(NSString *_Nullable *_Nullable error); diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index fb6cb4387..323539491 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -11,15 +11,18 @@ #import #import +#import +#import NSString *const kProtocolVersionKey = @"protocolVersion"; NSString *const kSourceVersionKey = @"sourceVersion"; NSString *const kRequestIdKey = @"requestId"; -NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.2"; +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"; @@ -75,6 +78,38 @@ + (nullable XCAccessibilityElement *)elementWithProcessIdentifier:(pid_t)pid; 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 _elementClass; @@ -154,8 +189,12 @@ - (nullable NSDictionary *)nodeFromSnapshot:(id)snapshot maxNodes:(NSUInteger)maxNodes count:(NSUInteger *)count truncated:(BOOL *)truncated + malformed:(BOOL *)malformed { - if (![snapshot isKindOfClass:NSDictionary.class]) return nil; + if (![snapshot isKindOfClass:NSDictionary.class]) { + *malformed = YES; + return nil; + } if (*count >= maxNodes) { *truncated = YES; return nil; @@ -163,7 +202,10 @@ - (nullable NSDictionary *)nodeFromSnapshot:(id)snapshot (*count)++; NSDictionary *attributes = ((NSDictionary *)snapshot)[kSnapshotAttributes]; - if (![attributes isKindOfClass:NSDictionary.class]) return nil; + if (![attributes isKindOfClass:NSDictionary.class]) { + *malformed = YES; + return nil; + } NSMutableDictionary *node = [NSMutableDictionary dictionary]; for (NSNumber *number in attributes) { NSString *name = namesByNumber[number]; @@ -173,7 +215,10 @@ - (nullable NSDictionary *)nodeFromSnapshot:(id)snapshot } NSArray *children = ((NSDictionary *)snapshot)[kSnapshotChildren]; - if (![children isKindOfClass:NSArray.class]) children = @[]; + if (![children isKindOfClass:NSArray.class]) { + *malformed = YES; + return nil; + } NSMutableArray *builtChildren = [NSMutableArray array]; if (depth >= maxDepth) { if (children.count > 0) *truncated = YES; @@ -183,10 +228,13 @@ - (nullable NSDictionary *)nodeFromSnapshot:(id)snapshot namesByNumber:namesByNumber depth:depth + 1 maxDepth:maxDepth - maxNodes:maxNodes + maxNodes:maxNodes count:count - truncated:truncated]; + 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; @@ -201,16 +249,22 @@ - (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; } @@ -227,6 +281,7 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid NSArray *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 *namesByNumber = [NSMutableDictionary dictionary]; @@ -247,6 +302,7 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid 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) { @@ -255,25 +311,31 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid 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 + maxNodes:maxNodes count:&count - truncated:&truncated]; + truncated:&truncated + malformed:&malformed]; if (!tree) { - if (error) *error = failureResponse(requestId, @"malformed_tree", @"snapshot-root-invalid", @"AX snapshot did not contain a materialized root node"); + 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, diff --git a/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md index 6cab6fea9..79674c032 100644 --- a/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md +++ b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md @@ -40,3 +40,25 @@ The returned lineage carried the supplied target id and opaque generation. The s - 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 a native-source wire parity fixture. 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 validation | +| 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. diff --git a/packages/platform-apple/src/snapshot-source-facade.ts b/packages/platform-apple/src/snapshot-source-facade.ts index e0dacdde3..eb056ea38 100644 --- a/packages/platform-apple/src/snapshot-source-facade.ts +++ b/packages/platform-apple/src/snapshot-source-facade.ts @@ -3,38 +3,44 @@ * creates the source; importing this facet keeps the platform package's startup surface inert. */ export type { - SnapshotSourceBridgeBinary, SnapshotSourceFailure, SnapshotSourceFailureKind, - SnapshotSourceHost, SnapshotSourceLimits, SnapshotSourceOutcome, SnapshotSourceRequest, - SnapshotSourceSuccess, SnapshotSourceTarget, } from './snapshot-source/types.ts'; -export type { - SimulatorSnapshotSource, - SimulatorSnapshotSourceOptions, -} from './snapshot-source/adapter.ts'; -export function createSimulatorSnapshotSource( - options: import('./snapshot-source/adapter.ts').SimulatorSnapshotSourceOptions = {}, -): import('./snapshot-source/adapter.ts').SimulatorSnapshotSource { +import type { SnapshotSourceOutcome, SnapshotSourceRequest } from './snapshot-source/types.ts'; + +export type SimulatorSnapshotSource = Readonly<{ + acquire(request: SnapshotSourceRequest): Promise; + close(): Promise; +}>; + +export function createSimulatorSnapshotSource(): SimulatorSnapshotSource { let implementation: | Promise | undefined; + let closed = false; const load = async () => { implementation ??= import('./snapshot-source/adapter.ts').then( - ({ createSimulatorSnapshotSource: create }) => create(options), + ({ createSimulatorSnapshotSource: create }) => create(), ); return await implementation; }; return { - prepare: async (input) => await (await load()).prepare(input), - acquire: async (request) => await (await load()).acquire(request), - acquireOutcome: async (request) => await (await load()).acquireOutcome(request), + 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(); }, }; diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index 643e7155b..82845fc7d 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -3,7 +3,7 @@ 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 'node:test'; +import { test } from 'vitest'; import { createIosSnapshotRequest, deriveIosCaptureHint, @@ -23,6 +23,8 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp 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, @@ -63,12 +65,8 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp { kind: 'unavailable-fact', fact: 'interactive-query' }, ]); - const prepared = await source.prepare({ runtime: 'iOS 26.2' }); - assert.equal(prepared.path.length > 0, true); - assert.equal(fixture.builds, 1); - fixture.responsePid = 999; - const outcome = await source.acquireOutcome({ + const outcome = await source.acquire({ target: { udid: 'simulator-1', runtime: 'iOS 26.2', @@ -85,13 +83,49 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp } }); +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; responsePid: number; }; -function createAdapterHost(): AdapterFixture { +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, responsePid: 321 }; const host: SnapshotSourceHost = { @@ -99,6 +133,7 @@ function createAdapterHost(): AdapterFixture { run: async (command, args) => { 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 }; } @@ -162,6 +197,7 @@ class AdapterSocket extends EventEmitter implements SnapshotSourceSocket { const request = JSON.parse(frame.subarray(4, bodyLength + 4).toString('utf8')) as { requestId: string; pid: number; + generation: string; }; queueMicrotask(() => { if (this.destroyed) return; @@ -174,6 +210,7 @@ class AdapterSocket extends EventEmitter implements SnapshotSourceSocket { requestId: request.requestId, ok: true, pid: this.readResponsePid(), + generation: request.generation, truncated: false, automationEnabled: true, tree: { diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts index 459973acb..b52cf19bc 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -1,6 +1,7 @@ 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'; @@ -8,12 +9,10 @@ import type { SnapshotBridgeEnvelope } from './protocol.ts'; import { decodeSnapshotBridgeTree } from './tree.ts'; import { createSnapshotSourceHost } from './host.ts'; import type { - SnapshotSourceBridgeBinary, SnapshotSourceHost, SnapshotSourceLimits, SnapshotSourceOutcome, SnapshotSourceRequest, - SnapshotSourceSuccess, } from './types.ts'; const SNAPSHOT_SOURCE_PRODUCER = 'simulator-ax-bridge' as const; @@ -26,11 +25,7 @@ export type SimulatorSnapshotSourceOptions = Readonly<{ }>; export type SimulatorSnapshotSource = Readonly<{ - prepare( - input: Readonly<{ runtime: string; signal?: AbortSignal }>, - ): Promise; - acquire(request: SnapshotSourceRequest): Promise; - acquireOutcome(request: SnapshotSourceRequest): Promise; + acquire(request: SnapshotSourceRequest): Promise; close(): Promise; }>; @@ -39,69 +34,67 @@ export function createSimulatorSnapshotSource( ): SimulatorSnapshotSource { const host = options.host ?? createSnapshotSourceHost(); const manager = new SnapshotBridgeManager(host); - const prepared = new Map>(); let closed = false; const prepare = async ( - input: Readonly<{ runtime: string; limits: SnapshotSourceLimits; signal?: AbortSignal }>, + input: Readonly<{ + runtime: string; + limits: SnapshotSourceLimits; + deadline: import('./deadline.ts').SnapshotSourceDeadline; + }>, ) => { if (closed) throw snapshotSourceError('unsupported', 'source-closed'); - const key = `${input.runtime}\0${input.limits.maxNodes}\0${input.limits.maxTraversalDepth}`; - let preparation = prepared.get(key); - if (!preparation) { - preparation = host.withDiagnosticTimer( - 'ios.snapshot-source.prepare', - async () => - await ensureSnapshotBridgeBinary({ - host, - runtime: input.runtime, - limits: input.limits, - signal: input.signal, - sourceRoot: options.sourceRoot, - cacheRoot: options.cacheRoot, - }), - { producer: SNAPSHOT_SOURCE_PRODUCER }, - ); - prepared.set(key, preparation); - preparation.catch(() => { - if (prepared.get(key) === preparation) prepared.delete(key); - }); - } - return await preparation; - }; - - const acquire = async (request: SnapshotSourceRequest): Promise => { - if (closed) throw snapshotSourceError('unsupported', 'source-closed'); - validateRequest(request); - const limits = resolveSnapshotSourceLimits({ ...options.limits, ...request.limits }); - const maxDepth = resolveRequestedDepth(request.hint, limits.maxTraversalDepth); - const bridge = await prepare({ - runtime: request.target.runtime, - limits, - signal: request.signal, - }); return await host.withDiagnosticTimer( - 'ios.snapshot-source.acquire', - async () => { - const envelope = await manager.request({ - target: request.target, - bridge, - limits, - maxDepth, - signal: request.signal, - }); - return { - stage: 'acquired', - acquisition: createAcquisition(request.hint, request.target, envelope, limits, maxDepth), - }; - }, + '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 }, ); }; - const acquireOutcome = async (request: SnapshotSourceRequest): Promise => { + const acquire = async (request: SnapshotSourceRequest): Promise => { try { - return await acquire(request); + 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 { @@ -116,18 +109,10 @@ export function createSimulatorSnapshotSource( }; return { - prepare: async (input) => - await prepare({ - runtime: input.runtime, - limits: resolveSnapshotSourceLimits(options.limits), - signal: input.signal, - }), acquire, - acquireOutcome, close: async () => { if (closed) return; closed = true; - prepared.clear(); await manager.close(); }, }; @@ -188,24 +173,24 @@ function createAcquisition( 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 = Object.freeze([ - { kind: 'unavailable-fact', fact: 'hittability' } as const, - ...(hint.interactiveOnly - ? ([{ kind: 'unavailable-fact', fact: 'interactive-query' }] as const) - : []), - ...(truncated - ? [truncationResidue(decoded.maxTraversalDepth, nodes.length, limits, maxDepth)] - : []), - ...(decoded.viewport.kind === 'missing' - ? ([{ kind: 'missing-viewport', reason: decoded.viewport.reason }] as const) - : []), - ]); + const residue = createAcquisitionResidue( + hint, + truncated, + decoded, + limits, + maxDepth, + nodes.length, + ); const lineage = Object.freeze({ ...(target.targetId ? { targetId: target.targetId } : {}), - generation: target.generation, + generation, }); const common = { producer: SNAPSHOT_SOURCE_PRODUCER, @@ -225,6 +210,28 @@ function createAcquisition( }; } +function createAcquisitionResidue( + hint: CaptureHint, + truncated: boolean, + decoded: ReturnType, + 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, diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.ts b/packages/platform-apple/src/snapshot-source/cache-identity.ts new file mode 100644 index 000000000..8cb5ca9af --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/cache-identity.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index a07764b3f..aaf962ad7 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -2,7 +2,7 @@ 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 'node:test'; +import { test } from 'vitest'; import { createSnapshotSourceHost } from './host.ts'; import { ensureSnapshotBridgeBinary } from './cache.ts'; import { DEFAULT_SNAPSHOT_SOURCE_LIMITS } from './limits.ts'; @@ -16,6 +16,8 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt 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'; @@ -48,6 +50,31 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt 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 }, + 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, + 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, @@ -90,6 +117,8 @@ test('concurrent snapshot bridge preparation publishes one cache entry', async ( 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; @@ -117,6 +146,65 @@ test('concurrent snapshot bridge preparation publishes one cache entry', async ( } }); +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((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 }, + signal: controller.signal, + sourceRoot, + cacheRoot, + }); + await started; + controller.abort(); + const survivor = ensureSnapshotBridgeBinary({ + host, + runtime: 'iOS 26.2', + limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + 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): Promise { + await assert.rejects(value, (error: unknown) => { + return ( + error instanceof Error && + 'failureKind' in error && + (error as { failureKind: string }).failureKind === 'cancelled' + ); + }); +} + function createFakeBuildHost( binary: string | (() => string | Promise), getXcode: () => string = () => 'Xcode 16.4\nBuild version 16F6', @@ -135,7 +223,9 @@ function createFakeBuildHost( command === 'xcodebuild' ? getXcode() : command === 'sw_vers' - ? '15.6' + ? args.includes('-buildVersion') + ? '24G90' + : '15.6' : command === 'uname' ? 'arm64' : '26.2'; diff --git a/packages/platform-apple/src/snapshot-source/cache.ts b/packages/platform-apple/src/snapshot-source/cache.ts index 8725fa85a..d5f73efaf 100644 --- a/packages/platform-apple/src/snapshot-source/cache.ts +++ b/packages/platform-apple/src/snapshot-source/cache.ts @@ -1,6 +1,14 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; -import { snapshotSourceError } from './errors.ts'; +import { SnapshotSourceError, snapshotSourceError } from './errors.ts'; +import { createSnapshotSourceDeadline, remainingSnapshotSourceMs } 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, @@ -8,28 +16,19 @@ import type { SnapshotSourceLimits, } from './types.ts'; -type ToolchainIdentity = Readonly<{ - xcode: string; - macos: string; - architecture: 'arm64' | 'x86_64'; - simulatorSdk: string; - simulatorRuntime: string; -}>; - type SnapshotBridgeCacheManifest = Readonly<{ schemaVersion: 1; protocolVersion: number; sourceVersion: string; sourceHash: string; cacheKey: string; - toolchain: ToolchainIdentity; + toolchain: SnapshotSourceToolchainIdentity; binarySha256: string; }>; const CACHE_SCHEMA_VERSION = 1 as const; const BRIDGE_FILENAME = 'snapshot-bridge'; const MANIFEST_FILENAME = 'manifest.json'; -const SOURCE_FILENAMES = ['SnapshotBridge.m', 'SnapshotBridgeRuntime.m'] as const; const BUILD_TIMEOUT_MS = 120_000; export async function ensureSnapshotBridgeBinary( @@ -38,41 +37,51 @@ export async function ensureSnapshotBridgeBinary( runtime: string; limits: SnapshotSourceLimits; signal?: AbortSignal; + deadline?: import('./deadline.ts').SnapshotSourceDeadline; sourceRoot?: string; cacheRoot?: string; }>, ): Promise { + const deadline = + input.deadline ?? createSnapshotSourceDeadline(input.limits.maxDurationMs, input.signal); const sourceRoot = input.sourceRoot ?? resolveSnapshotBridgeSourceRoot(input.host); - const sourceHash = await fingerprintSource(input.host, sourceRoot); - const toolchain = await readToolchainIdentity(input.host, input.runtime, input.signal); + 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, - limits: { - maxNodes: input.limits.maxNodes, - maxTraversalDepth: input.limits.maxTraversalDepth, - }, }); 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`)); + const releaseLock = await input.host.acquireLock(path.join(cacheRoot, `${cacheKey}.lock`), { + deadline, + }); try { - const cached = await readValidCache(input.host, entryPath, { - sourceHash, - cacheKey, - toolchain, - }); + 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( @@ -92,11 +101,20 @@ export async function ensureSnapshotBridgeBinary( 'Foundation', '-framework', 'CoreGraphics', - ...SOURCE_FILENAMES.map((sourceFile) => path.join(sourceRoot, sourceFile)), + ...SNAPSHOT_BRIDGE_COMPILE_FILENAMES.map((sourceFile) => + path.join(sourceRoot, sourceFile), + ), '-o', outputPath, ], - { signal: input.signal, timeoutMs: BUILD_TIMEOUT_MS, allowFailure: true }, + { + 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', { @@ -104,8 +122,9 @@ export async function ensureSnapshotBridgeBinary( stderr: result.stderr.slice(0, 4096), }); } + remainingSnapshotSourceMs(deadline, 'native-build-deadline'); await input.host.chmod(outputPath, 0o755); - const binarySha256 = await sha256File(input.host, outputPath); + const binarySha256 = await sha256File(input.host, outputPath, deadline); const manifest: SnapshotBridgeCacheManifest = { schemaVersion: CACHE_SCHEMA_VERSION, protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, @@ -119,6 +138,7 @@ export async function ensureSnapshotBridgeBinary( 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), @@ -139,9 +159,21 @@ export async function ensureSnapshotBridgeBinary( function resolveSnapshotBridgeSourceRoot(host: SnapshotSourceHost): string { const projectRoot = host.projectRoot(); const checkoutRoot = path.join(projectRoot, 'apple', 'snapshot-bridge'); - if (host.exists(path.join(checkoutRoot, SOURCE_FILENAMES[0]))) return checkoutRoot; + 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 (host.exists(path.join(packagedRoot, SOURCE_FILENAMES[0]))) return packagedRoot; + if ( + SNAPSHOT_BRIDGE_SOURCE_FILENAMES.every((sourceFile) => + host.exists(path.join(packagedRoot, sourceFile)), + ) + ) { + return packagedRoot; + } throw snapshotSourceError('unsupported', 'native-source-missing', { projectRoot }); } @@ -152,8 +184,9 @@ async function readValidCache( expected: Readonly<{ sourceHash: string; cacheKey: string; - toolchain: ToolchainIdentity; + toolchain: SnapshotSourceToolchainIdentity; }>, + deadline: import('./deadline.ts').SnapshotSourceDeadline, ): Promise { const binaryPath = path.join(entryPath, BRIDGE_FILENAME); if (!host.exists(binaryPath) || !host.exists(path.join(entryPath, MANIFEST_FILENAME))) { @@ -174,7 +207,7 @@ async function readValidCache( ) { return undefined; } - if ((await sha256File(host, binaryPath)) !== manifest.binarySha256) return undefined; + if ((await sha256File(host, binaryPath, deadline)) !== manifest.binarySha256) return undefined; return { path: binaryPath, sourceHash: expected.sourceHash, @@ -182,83 +215,23 @@ async function readValidCache( protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, sourceVersion: SNAPSHOT_SOURCE_VERSION, }; - } catch { + } catch (error) { + if ( + error instanceof SnapshotSourceError && + (error.failureKind === 'cancelled' || error.failureKind === 'timeout') + ) { + throw error; + } return undefined; } } -async function fingerprintSource(host: SnapshotSourceHost, root: string): Promise { - const files = await sourceFiles(host, root); - const hash = createHash('sha256'); - for (const filePath of files) { - hash.update(path.relative(root, filePath)); - hash.update('\0'); - hash.update(await host.readBinary(filePath)); - hash.update('\0'); - } - return hash.digest('hex'); -} - -async function sourceFiles(host: SnapshotSourceHost, root: string): Promise { - const entries = await host.listDirectory(root); - const files: string[] = []; - for (const entry of entries) { - const entryPath = path.join(root, entry.name); - if (entry.isDirectory()) files.push(...(await sourceFiles(host, entryPath))); - else if (entry.isFile()) files.push(entryPath); - } - return files.sort(); -} - -async function readToolchainIdentity( +async function sha256File( host: SnapshotSourceHost, - simulatorRuntime: string, - signal: AbortSignal | undefined, -): Promise { - const [xcode, macos, architecture, simulatorSdk] = await Promise.all([ - toolOutput(host, 'xcodebuild', ['-version'], signal), - toolOutput(host, 'sw_vers', ['-productVersion'], signal), - toolOutput(host, 'uname', ['-m'], signal), - toolOutput(host, 'xcrun', ['--sdk', 'iphonesimulator', '--show-sdk-version'], signal), - ]); - const runtime = simulatorRuntime.trim(); - if (!runtime) throw snapshotSourceError('unsupported', 'simulator-runtime-missing'); - return { - xcode, - macos, - architecture: simulatorArchitecture(architecture), - simulatorSdk, - simulatorRuntime: runtime, - }; -} - -function simulatorArchitecture(value: string): 'arm64' | 'x86_64' { - if (value === 'arm64' || value === 'x86_64') return value; - throw snapshotSourceError('unsupported', 'simulator-architecture-unsupported', { - architecture: value, - }); -} - -async function toolOutput( - host: SnapshotSourceHost, - command: string, - args: string[], - signal: AbortSignal | undefined, + filePath: string, + deadline?: import('./deadline.ts').SnapshotSourceDeadline, ): Promise { - const result = await host.run(command, args, { allowFailure: true, signal, timeoutMs: 10_000 }); - 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; -} - -async function sha256File(host: SnapshotSourceHost, filePath: string): Promise { + if (deadline) remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline'); return createHash('sha256') .update(await host.readBinary(filePath)) .digest('hex'); diff --git a/packages/platform-apple/src/snapshot-source/deadline.ts b/packages/platform-apple/src/snapshot-source/deadline.ts new file mode 100644 index 000000000..c77ccd98e --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/deadline.ts @@ -0,0 +1,45 @@ +import { Deadline } from '@agent-device/host-kit/retry'; +import { snapshotSourceError } from './errors.ts'; + +export type SnapshotSourceDeadline = Readonly<{ + clock: Deadline; + signal?: AbortSignal; +}>; + +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 { + const delayMs = Math.min(requestedMs, remainingSnapshotSourceMs(deadline, code)); + await new Promise((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 }); + }); +} diff --git a/packages/platform-apple/src/snapshot-source/errors.ts b/packages/platform-apple/src/snapshot-source/errors.ts index 048e4c9aa..07a2b64b7 100644 --- a/packages/platform-apple/src/snapshot-source/errors.ts +++ b/packages/platform-apple/src/snapshot-source/errors.ts @@ -53,6 +53,14 @@ export function asSnapshotSourceError(error: unknown): SnapshotSourceError { 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', diff --git a/packages/platform-apple/src/snapshot-source/fixtures/native-wire-golden.json b/packages/platform-apple/src/snapshot-source/fixtures/native-wire-golden.json new file mode 100644 index 000000000..004494867 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/fixtures/native-wire-golden.json @@ -0,0 +1,51 @@ +{ + "producer": "SnapshotBridge.m + SnapshotBridgeRuntime.m", + "request": { + "verb": "describe", + "requestId": "golden-request", + "pid": 321, + "generation": "generation-current", + "snapshotTree": true, + "automationMode": true, + "maxDepth": 8, + "maxNodes": 1500, + "maxDurationMs": 5000, + "maxResponseBytes": 4194304 + }, + "success": { + "protocolVersion": 1, + "sourceVersion": "agent-device-simulator-ax-v1.5.3", + "requestId": "golden-request", + "generation": "generation-current", + "ok": true, + "pid": 321, + "truncated": false, + "automationEnabled": true + }, + "malformed": { + "protocolVersion": 1, + "sourceVersion": "agent-device-simulator-ax-v1.5.3", + "requestId": "golden-request", + "generation": "generation-current", + "ok": false, + "pid": 321, + "error_kind": "malformed_tree", + "error_code": "snapshot-tree-malformed" + }, + "versionMismatch": { + "protocolVersion": 0, + "sourceVersion": "agent-device-simulator-ax-v1.5.3", + "requestId": "golden-request", + "generation": "generation-current" + }, + "staleGeneration": { + "protocolVersion": 1, + "sourceVersion": "agent-device-simulator-ax-v1.5.3", + "requestId": "golden-request", + "generation": "generation-previous", + "ok": true, + "pid": 321, + "truncated": false, + "automationEnabled": true + } +} diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index b0956015e..9d9f4a524 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { test } from 'node:test'; +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', () => { diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index 178f43981..a5d74d95a 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -24,7 +24,8 @@ import { import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; import { withKeyedLock } from '@agent-device/kernel/keyed-lock'; import { findProjectRoot } from '@agent-device/host-kit/version'; -import { snapshotSourceError } from './errors.ts'; +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; @@ -166,20 +167,53 @@ async function connectSnapshotBridge( }); } -async function acquireSnapshotSourceLock(lockPath: string): Promise<() => Promise> { +async function acquireSnapshotSourceLock( + lockPath: string, + options: Parameters[1] = {}, +): Promise<() => Promise> { const pid = hostProcessId(); - return await acquireProcessLock({ + const deadline = options.deadline; + const pending = acquireProcessLock({ lockDirPath: lockPath, owner: { pid, startTime: readProcessStartTime(pid), acquiredAtMs: Date.now(), }, - timeoutMs: 180_000, + timeoutMs: deadline ? remainingSnapshotSourceMs(deadline, 'cache-lock-deadline') : 180_000, pollMs: 100, ownerGraceMs: 5_000, description: 'iOS Simulator snapshot bridge cache', }); + if (!deadline?.signal) return await pending; + + let canceled = false; + let onAbort!: () => void; + const aborted = new Promise((_, reject) => { + onAbort = () => { + canceled = true; + reject(snapshotSourceError('cancelled', 'abort-signal')); + }; + deadline.signal!.addEventListener('abort', onAbort, { once: true }); + }); + 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 { + deadline.signal.removeEventListener('abort', onAbort); + } } function appendBoundedLog(current: string, addition: string): string { diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index c78b5093a..8762906da 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; -import { test } from 'node:test'; +import { test } from 'vitest'; import { createSnapshotSourceHost } from './host.ts'; -import { SnapshotSourceError } from './errors.ts'; +import { SnapshotSourceError, snapshotSourceError } from './errors.ts'; import { encodeSnapshotBridgeFrame, SNAPSHOT_SOURCE_PROTOCOL_VERSION, @@ -70,7 +70,7 @@ test('a new target generation does not reuse the previous helper', async () => { assert.deepEqual(fixture.processes[1]!.signals, ['SIGTERM']); }); -test('request cancellation destroys only the socket and leaves a healthy helper alive', async () => { +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(); @@ -87,8 +87,68 @@ test('request cancellation destroys only the socket and leaves a healthy helper request, (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled', ); - assert.equal(fixture.processes[0]?.signals.length, 0); + assert.deepEqual(fixture.processes[0]?.signals, ['SIGTERM']); + assert.equal(fixture.processes[0]?.alive, false); + await manager.request({ target, bridge, limits, maxDepth: 10 }); + 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, + }); + fixture.sockets[0]!.destroy(); + + const controller = new AbortController(); + const request = manager.request({ + target, + bridge, + limits: { ...limits, maxDurationMs: 500 }, + maxDepth: 10, + signal: controller.signal, + }); + 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, + }); + 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, + }), + (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout', + ); + assert.deepEqual(fixture.processes[0]?.signals, ['SIGTERM']); await manager.close(); }); @@ -122,6 +182,20 @@ test('the manager rejects a response for a different target process as stale', a 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 }), + (error: unknown) => + error instanceof SnapshotSourceError && + error.failureKind === 'stale-target' && + error.failureCode === 'bridge-generation-mismatch', + ); + 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); @@ -141,8 +215,10 @@ type LifecycleFixture = { function createLifecycleFixture( options: { + connectDelayMs?: number; responseDelayMs?: number; responsePid?: number; + responseGeneration?: string; responseErrorKind?: string; } = {}, ): LifecycleFixture { @@ -156,10 +232,27 @@ function createLifecycleFixture( processes.push(process); return process; }, - connect: async () => { + connect: async (_socketPath, connectOptions) => { + if (options.connectDelayMs) { + await new Promise((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); @@ -211,12 +304,19 @@ class FakeSocket extends EventEmitter implements SnapshotSourceSocket { destroyed = false; private readonly responseDelayMs: number; private readonly responsePid: number; + private readonly responseGeneration: string | undefined; private readonly responseErrorKind: string | undefined; - constructor(responseDelayMs: number, responsePid: number, responseErrorKind?: string) { + constructor( + responseDelayMs: number, + responsePid: number, + responseGeneration: string | undefined, + responseErrorKind?: string, + ) { super(); this.responseDelayMs = responseDelayMs; this.responsePid = responsePid; + this.responseGeneration = responseGeneration; this.responseErrorKind = responseErrorKind; } @@ -225,6 +325,7 @@ class FakeSocket extends EventEmitter implements SnapshotSourceSocket { const request = JSON.parse(frame.subarray(4, bodyLength + 4).toString('utf8')) as { requestId: string; pid: number; + generation: string; }; setTimeout(() => { if (this.destroyed) return; @@ -238,6 +339,7 @@ class FakeSocket extends EventEmitter implements SnapshotSourceSocket { requestId: request.requestId, ok: false, pid: this.responsePid || request.pid, + generation: this.responseGeneration ?? request.generation, error_kind: this.responseErrorKind, error_code: 'fixture-error', } @@ -247,6 +349,7 @@ class FakeSocket extends EventEmitter implements SnapshotSourceSocket { requestId: request.requestId, ok: true, pid: this.responsePid || request.pid, + generation: this.responseGeneration ?? request.generation, truncated: false, automationEnabled: true, tree: { diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 6027bee95..40ca699c6 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -1,16 +1,15 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; -import { sleep } from '@agent-device/host-kit/retry'; import { asSnapshotSourceError, snapshotSourceError, SnapshotSourceError } from './errors.ts'; import { - assertSnapshotBridgeEnvelope, - bridgeFailureFromEnvelope, - createSnapshotBridgeDescribeRequest, - encodeSnapshotBridgeFrame, - parseSnapshotBridgeEnvelope, - SnapshotBridgeFrameDecoder, -} from './protocol.ts'; + createSnapshotSourceDeadline, + remainingSnapshotSourceMs, + waitForSnapshotSourceDelay, + type SnapshotSourceDeadline, +} from './deadline.ts'; import { snapshotSourceSocketPath } from './host.ts'; +import { createSnapshotBridgeDescribeRequest, encodeSnapshotBridgeFrame } from './protocol.ts'; +import { roundTripSnapshotBridge } from './transport.ts'; import type { SnapshotSourceBridgeBinary, SnapshotSourceHost, @@ -20,10 +19,12 @@ import type { SnapshotSourceTarget, } from './types.ts'; import type { SnapshotBridgeEnvelope } from './protocol.ts'; -const CONNECT_RETRY_DELAY_MS = 20, - CONNECT_ATTEMPT_TIMEOUT_MS = 250; -const SHUTDOWN_TERM_TIMEOUT_MS = 500, - SHUTDOWN_KILL_TIMEOUT_MS = 500; + +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 generation: string; @@ -32,35 +33,43 @@ type BridgeSession = { readonly process: SnapshotSourceProcess; socket?: SnapshotSourceSocket; }; + type SnapshotBridgeRequest = Readonly<{ target: SnapshotSourceTarget; bridge: SnapshotSourceBridgeBinary; limits: SnapshotSourceLimits; maxDepth: number; signal?: AbortSignal; + deadline?: SnapshotSourceDeadline; }>; + export class SnapshotBridgeManager { private readonly sessions = new Map(); private closed = false; private readonly host: SnapshotSourceHost; + constructor(host: SnapshotSourceHost) { this.host = host; } + async request(input: SnapshotBridgeRequest): Promise { if (this.closed) throw snapshotSourceError('unsupported', 'source-closed'); + const deadline = + input.deadline ?? createSnapshotSourceDeadline(input.limits.maxDurationMs, input.signal); return await this.host.withKeyedLock(`simulator:${input.target.udid}`, async () => { - const session = await this.ensureSession(input, input.signal); + remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'); + const previousSession = this.sessions.get(input.target.udid); + const session = await this.ensureSession(input, deadline); try { - return await this.exchange( - session, - input.target, - input.maxDepth, - input.limits, - input.signal, - ); + return await this.exchange(session, input, deadline); } catch (error) { const normalized = asSnapshotSourceError(error); - if (normalized.failureKind === 'process-crash') { + if ( + (normalized.failureKind === 'cancelled' || normalized.failureKind === 'timeout') && + (normalized.details?.dispatched === true || previousSession !== session) + ) { + 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(); @@ -70,6 +79,7 @@ export class SnapshotBridgeManager { } }); } + async close(): Promise { if (this.closed) return; this.closed = true; @@ -77,9 +87,10 @@ export class SnapshotBridgeManager { this.sessions.clear(); await Promise.all(sessions.map(async (session) => await this.dispose(session, true))); } + private async ensureSession( input: SnapshotBridgeRequest, - signal: AbortSignal | undefined, + deadline: SnapshotSourceDeadline, ): Promise { const key = input.target.udid; const existing = this.sessions.get(key); @@ -90,7 +101,7 @@ export class SnapshotBridgeManager { existing.process.isAlive() ) { if (!existing.socket || existing.socket.destroyed) { - existing.socket = await this.connectUntilReady(existing, input.limits, signal); + existing.socket = await this.connectUntilReady(existing, deadline); } return existing; } @@ -99,8 +110,9 @@ export class SnapshotBridgeManager { const socketPath = snapshotSourceSocketPath(this.host, input.target.udid); await this.host.ensureDirectory(path.dirname(socketPath)); await this.host.remove(socketPath); - const { target, bridge } = input; - const bridgeProcess = this.host.start(target.udid, bridge.path, socketPath, { signal }); + const bridgeProcess = this.host.start(input.target.udid, input.bridge.path, socketPath, { + signal: deadline.signal, + }); const session: BridgeSession = { udid: input.target.udid, generation: input.target.generation, @@ -110,7 +122,7 @@ export class SnapshotBridgeManager { }; this.sessions.set(key, session); try { - session.socket = await this.connectUntilReady(session, input.limits, signal); + session.socket = await this.connectUntilReady(session, deadline); return session; } catch (error) { await this.removeSession(session, true); @@ -120,143 +132,66 @@ export class SnapshotBridgeManager { private async connectUntilReady( session: BridgeSession, - limits: SnapshotSourceLimits, - signal: AbortSignal | undefined, + deadline: SnapshotSourceDeadline, ): Promise { - const deadline = Date.now() + limits.maxDurationMs; let lastError: unknown; - while (Date.now() < deadline) { - if (signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal'); + while (true) { + const remainingMs = remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline'); if (!session.process.isAlive()) throw bridgeProcessExited(session.process); - const remainingMs = deadline - Date.now(); try { return await this.host.connect(session.socketPath, { - signal, + signal: deadline.signal, timeoutMs: Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs), }); } catch (error) { lastError = error; - if (error instanceof SnapshotSourceError && error.failureKind === 'cancelled') - throw asSnapshotSourceError(error); - await sleep(Math.min(CONNECT_RETRY_DELAY_MS, Math.max(1, remainingMs))); + 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; } } - throw snapshotSourceError('timeout', 'bridge-connect-deadline', { - udid: session.udid, - ...(lastError instanceof Error ? { lastError: lastError.message } : {}), - }); } private async exchange( session: BridgeSession, - target: SnapshotSourceTarget, - maxDepth: number, - limits: SnapshotSourceLimits, - signal: AbortSignal | undefined, + input: SnapshotBridgeRequest, + deadline: SnapshotSourceDeadline, ): Promise { if (!session.socket || session.socket.destroyed) { - session.socket = await this.connectUntilReady(session, limits, signal); + session.socket = await this.connectUntilReady(session, deadline); } - const socket = session.socket; const requestId = randomUUID(); const request = createSnapshotBridgeDescribeRequest({ requestId, - pid: target.pid, - maxDepth: Math.min(limits.maxTraversalDepth, maxDepth), - maxNodes: limits.maxNodes, + 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, limits); - return await this.roundTrip(session, socket, frame, requestId, limits, signal, target.pid); - } - - private async roundTrip( - session: BridgeSession, - socket: SnapshotSourceSocket, - frame: Buffer, - requestId: string, - limits: SnapshotSourceLimits, - signal: AbortSignal | undefined, - expectedPid: number, - ): Promise { - const decoder = new SnapshotBridgeFrameDecoder(limits.maxResponseBytes - 4); - return await new Promise((resolve, reject) => { - let settled = false; - const timer = setTimeout(() => { - socket.destroy(); - finishReject(snapshotSourceError('timeout', 'bridge-request-deadline')); - }, limits.maxDurationMs); - const onAbort = () => { - socket.destroy(); - finishReject(snapshotSourceError('cancelled', 'abort-signal')); - }; - const onData = (chunk: unknown) => { - try { - if (!Buffer.isBuffer(chunk)) - throw snapshotSourceError('transport-failure', 'bridge-data-invalid'); - const frames = decoder.push(chunk); - for (const body of frames) { - const envelope = parseSnapshotBridgeEnvelope(body); - assertSnapshotBridgeEnvelope(envelope, requestId); - if (typeof envelope.pid !== 'number' || envelope.pid !== expectedPid) { - throw snapshotSourceError('stale-target', 'bridge-pid-mismatch', { - expectedPid, - observedPid: envelope.pid, - }); - } - if (envelope.ok !== true) bridgeFailureFromEnvelope(envelope); - if (typeof envelope.truncated !== 'boolean') { - throw snapshotSourceError('malformed-tree', 'truncated-invalid'); - } - finishResolve(envelope); - return; - } - } catch (error) { - socket.destroy(); - finishReject(error); - } - }; - const onError = (error: unknown) => finishReject(asSnapshotSourceError(error)); - const onClose = () => { - if (!settled) { - finishReject( - session.process.isAlive() - ? snapshotSourceError('transport-failure', 'bridge-connection-closed') - : bridgeProcessExited(session.process), - ); - } - }; - session.process.wait.then( - () => { - if (!settled) finishReject(bridgeProcessExited(session.process)); - }, - (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); - signal?.removeEventListener('abort', onAbort); - socket.off('data', onData); - socket.off('error', onError); - socket.off('close', onClose); - action(); - }; - - socket.on('data', onData); - socket.on('error', onError); - socket.on('close', onClose); - signal?.addEventListener('abort', onAbort, { once: true }); - try { - if (signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal'); - socket.write(frame); - } catch (error) { - socket.destroy(); - finishReject(asSnapshotSourceError(error)); - } + 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, }); } diff --git a/packages/platform-apple/src/snapshot-source/limits.ts b/packages/platform-apple/src/snapshot-source/limits.ts index 3896a74cc..07308f186 100644 --- a/packages/platform-apple/src/snapshot-source/limits.ts +++ b/packages/platform-apple/src/snapshot-source/limits.ts @@ -2,6 +2,10 @@ 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, @@ -37,6 +41,30 @@ export function resolveSnapshotSourceLimits( }, ); } + 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 diff --git a/packages/platform-apple/src/snapshot-source/protocol.test.ts b/packages/platform-apple/src/snapshot-source/protocol.test.ts index 462a9f004..a30d40a71 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.test.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.test.ts @@ -1,13 +1,19 @@ import assert from 'node:assert/strict'; -import { test } from 'node:test'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { test } from 'vitest'; import { assertSnapshotBridgeEnvelope, + assertSnapshotBridgeTargetIdentity, 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'; @@ -20,6 +26,10 @@ const limits: SnapshotSourceLimits = { maxDurationMs: 1000, }; +const nativeWireGolden = JSON.parse( + await readFile(path.join(import.meta.dirname, 'fixtures', 'native-wire-golden.json'), 'utf8'), +); + test('snapshot bridge frames decode across split and coalesced socket chunks', () => { const first = encodeSnapshotBridgeFrame({ requestId: 'one', value: 1 }, limits); const second = encodeSnapshotBridgeFrame({ requestId: 'two', value: 2 }, limits); @@ -51,17 +61,23 @@ 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( @@ -84,6 +100,7 @@ test('snapshot bridge failures stay typed at the guest boundary', () => { ['application_not_responding', 'timeout'], ['application_unavailable', 'transport-failure'], ['bad_request', 'malformed-tree'], + ['response_limit_exceeded', 'transport-failure'], ] as const) { assert.throws( () => @@ -98,3 +115,58 @@ test('snapshot bridge failures stay typed at the guest boundary', () => { ); } }); + +test('native wire golden keeps TS and Objective-C protocol vocabularies in parity', async () => { + const native = await Promise.all( + ['SnapshotBridge.m', 'SnapshotBridgeRuntime.m'].map((fileName) => + readFile( + path.join(import.meta.dirname, '../../../../apple/snapshot-bridge', fileName), + 'utf8', + ), + ), + ); + const nativeSource = native.join('\n'); + assert.match(nativeSource, /kProtocolVersion = 1/); + assert.match(nativeSource, /kSourceVersion = @"agent-device-simulator-ax-v1\.5\.3"/); + assert.match(nativeSource, /snapshot-tree-malformed/); + assert.match(nativeSource, /if \(\*malformed\) return nil/); + for (const key of [ + ...SNAPSHOT_SOURCE_WIRE_KEYS, + ...SNAPSHOT_SOURCE_RESPONSE_KEYS, + ...SNAPSHOT_SOURCE_ATTRIBUTE_KEYS, + ]) { + assert.match( + nativeSource, + new RegExp(`@"${key.replaceAll(/[.*+?^${}()|[\\]\\\\]/g, String.raw`\$&`)}"`), + ); + } + + const request = createSnapshotBridgeDescribeRequest(nativeWireGolden.request); + assert.deepEqual(request, nativeWireGolden.request); + const success = parseSnapshotBridgeEnvelope( + Buffer.from(JSON.stringify(nativeWireGolden.success)), + ); + assert.doesNotThrow(() => assertSnapshotBridgeEnvelope(success, 'golden-request')); + assert.doesNotThrow(() => + assertSnapshotBridgeTargetIdentity(success, { pid: 321, generation: 'generation-current' }), + ); + assert.throws( + () => assertSnapshotBridgeEnvelope(nativeWireGolden.versionMismatch, 'golden-request'), + /protocol-version-mismatch/, + ); + assert.throws( + () => + assertSnapshotBridgeTargetIdentity(nativeWireGolden.staleGeneration, { + pid: 321, + generation: 'generation-current', + }), + /bridge-generation-mismatch/, + ); + assert.throws( + () => bridgeFailureFromEnvelope(nativeWireGolden.malformed), + (error: unknown) => + error instanceof Error && + 'failureKind' in error && + (error as { failureKind: string }).failureKind === 'malformed-tree', + ); +}); diff --git a/packages/platform-apple/src/snapshot-source/protocol.ts b/packages/platform-apple/src/snapshot-source/protocol.ts index 63045cc7c..3d06cfae3 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.ts @@ -3,9 +3,48 @@ 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.2'; +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>; export function encodeSnapshotBridgeFrame( @@ -78,18 +117,24 @@ export function createSnapshotBridgeDescribeRequest( input: Readonly<{ requestId: string; pid: number; + generation: string; maxDepth: number; maxNodes: number; + maxDurationMs: number; + maxResponseBytes: number; }>, ): Readonly> { 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, }); } @@ -117,6 +162,24 @@ export function assertSnapshotBridgeEnvelope( } } +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'; @@ -131,6 +194,9 @@ export function bridgeFailureFromEnvelope(envelope: SnapshotBridgeEnvelope): nev 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); } diff --git a/packages/platform-apple/src/snapshot-source/transport.ts b/packages/platform-apple/src/snapshot-source/transport.ts new file mode 100644 index 000000000..7d5c4260d --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/transport.ts @@ -0,0 +1,113 @@ +import { asSnapshotSourceError, snapshotSourceError } from './errors.ts'; +import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts'; +import { + assertSnapshotBridgeEnvelope, + assertSnapshotBridgeTargetIdentity, + bridgeFailureFromEnvelope, + parseSnapshotBridgeEnvelope, + SnapshotBridgeFrameDecoder, + type SnapshotBridgeEnvelope, +} from './protocol.ts'; +import type { 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; + }>, +): Promise { + const decoder = new SnapshotBridgeFrameDecoder(input.limits.maxResponseBytes - 4); + const timeoutMs = remainingSnapshotSourceMs(input.deadline, 'bridge-request-deadline'); + return await new Promise((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 frames = decoder.push(chunk); + for (const body of frames) { + 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) { + finishReject( + input.process.isAlive() + ? snapshotSourceError('transport-failure', 'bridge-connection-closed') + : bridgeProcessExited(input.process), + ); + } + }; + input.process.wait.then( + () => { + if (!settled) finishReject(bridgeProcessExited(input.process)); + }, + (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(); + } + }); +} + +function bridgeProcessExited(bridgeProcess: SnapshotSourceProcess) { + return snapshotSourceError('process-crash', 'bridge-exited', { + pid: bridgeProcess.pid, + log: bridgeProcess.readLog().slice(-64 * 1024), + }); +} diff --git a/packages/platform-apple/src/snapshot-source/tree.test.ts b/packages/platform-apple/src/snapshot-source/tree.test.ts index 8b33af9b3..08c6fb6da 100644 --- a/packages/platform-apple/src/snapshot-source/tree.test.ts +++ b/packages/platform-apple/src/snapshot-source/tree.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { test } from 'node:test'; +import { test } from 'vitest'; import { decodeSnapshotBridgeTree } from './tree.ts'; import type { SnapshotSourceLimits } from './types.ts'; diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts index ab34fdfbc..a194ea069 100644 --- a/packages/platform-apple/src/snapshot-source/types.ts +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -5,6 +5,7 @@ import type { 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; @@ -99,7 +100,10 @@ export type SnapshotSourceHost = Readonly<{ exists(path: string): boolean; rename(sourcePath: string, destinationPath: string): Promise; remove(path: string): Promise; - acquireLock(path: string): Promise<() => Promise>; + acquireLock( + path: string, + options?: { deadline?: SnapshotSourceDeadline }, + ): Promise<() => Promise>; withKeyedLock(key: string, action: () => Promise): Promise; emitDiagnostic(event: { level?: 'debug' | 'info' | 'warn' | 'error'; diff --git a/scripts/__tests__/fixtures/size-report-npm-pack.json b/scripts/__tests__/fixtures/size-report-npm-pack.json index 94615281f..a7c6ae86c 100644 --- a/scripts/__tests__/fixtures/size-report-npm-pack.json +++ b/scripts/__tests__/fixtures/size-report-npm-pack.json @@ -6,6 +6,7 @@ { "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/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 }, diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index 3a0586431..32c5020a0 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -24,6 +24,7 @@ test('classifies every shipped entry into one named component', () => { ['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/macos-helper/Sources/main.swift', 'macos-helper'], ['android/snapshot-helper/dist/helper.apk', 'android-helpers'], ['android/snapshot-helper/dist/helper.manifest.json', 'android-helpers'], @@ -51,6 +52,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([ diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 980ab2c5f..9f929761e 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -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; @@ -177,6 +178,7 @@ try { const tarball = packTarball(); lintTarball(tarball); const installedRoot = installIntoCleanConsumer(tarball); + assertInstalledSnapshotBridge(installedRoot); const manifest = JSON.parse( fs.readFileSync(path.join(installedRoot, 'package.json'), 'utf8'), ) as PackedManifest; diff --git a/scripts/ios-snapshot-benchmark/size-install.test.ts b/scripts/ios-snapshot-benchmark/size-install.test.ts index 1e4471746..d36abfc97 100644 --- a/scripts/ios-snapshot-benchmark/size-install.test.ts +++ b/scripts/ios-snapshot-benchmark/size-install.test.ts @@ -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,18 @@ 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 keeps both native prepare/acquire sources', async () => { + const root = await mkdtempForTest('agent-device-size-bridge-'); + const bridge = join(root, 'apple', 'snapshot-bridge'); + await mkdir(bridge, { recursive: true }); + await writeFile(join(bridge, 'SnapshotBridge.m'), 'serve'); + await writeFile(join(bridge, 'SnapshotBridgeRuntime.m'), 'snapshotForProcess'); + try { + assert.doesNotThrow(() => assertInstalledSnapshotBridge(root)); + await rm(join(bridge, 'SnapshotBridgeRuntime.m')); + assert.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridgeRuntime\.m/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/size-report-install.mjs b/scripts/size-report-install.mjs index 5f4482cf2..f1b8fee0a 100644 --- a/scripts/size-report-install.mjs +++ b/scripts/size-report-install.mjs @@ -33,12 +33,31 @@ 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 requiredSources = ['SnapshotBridge.m', 'SnapshotBridgeRuntime.m']; + const missing = requiredSources.filter((source) => !fs.existsSync(path.join(bridgeRoot, source))); + if (missing.length > 0) { + throw new Error(`Clean-installed snapshot bridge is missing: ${missing.join(', ')}`); + } + const source = requiredSources + .map((fileName) => fs.readFileSync(path.join(bridgeRoot, fileName), 'utf8')) + .join('\n'); + if (!source.includes('serve') || !source.includes('snapshotForProcess')) { + throw new Error( + 'Clean-installed snapshot bridge does not contain its serve/acquire implementation.', + ); + } +} + export function measureDirectory(root) { const entries = fs.readdirSync(root, { withFileTypes: true }); return entries.reduce( diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 6af52276d..fbd9e22c1 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -58,7 +58,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), @@ -70,25 +72,34 @@ export function collectNpmPack(root) { }; } -export function assertPublishPackageContents(entries) { +export function assertPublishPackageContents(entries, options = {}) { const paths = entries.map((entry) => entry.path); const requiredAssets = [ - { directory: 'apple/snapshot-bridge/', suffix: 'SnapshotBridge.m' }, { directory: 'android/snapshot-helper/dist/', suffix: '.apk' }, { directory: 'android/snapshot-helper/dist/', suffix: '.manifest.json' }, { 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/')) + ) { + requiredAssets.unshift( + { path: 'apple/snapshot-bridge/SnapshotBridge.m' }, + { path: 'apple/snapshot-bridge/SnapshotBridgeRuntime.m' }, + ); + } + 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(', ')}`, ); } From cbd94e49d5122ee1d1816e91a97bad191f1f9591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 11:08:42 +0200 Subject: [PATCH 3/9] docs: refresh Simulator AX evidence --- ...os-simulator-snapshot-source-2026-09-04.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md index 79674c032..22df3515e 100644 --- a/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md +++ b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md @@ -1,7 +1,8 @@ # iOS Simulator snapshot-source live evidence - Issue: #2196 -- Observed: 2026-09-04T07:16:37Z +- Observed: 2026-09-04T09:03:35Z +- Revision: `6d39faaa6a36a010b346d36bd576194d936115ac` - Target: verified booted `iPhone 17 Pro` Simulator, iOS 26.2 - UDID: `F7D6F9A4-4FCC-4DD7-AC0B-3280C9319CB9` - App: `Agent Device Tester` (`com.callstack.agentdevicelab`), PID `60439` @@ -11,8 +12,8 @@ | Measurement | Observed | |---|---:| -| Acquisition latency | 1202 ms | -| Raw nodes | 158 | +| Acquisition latency | 2560 ms | +| Raw nodes | 77 | | Truncated | false | | Viewport | 402 x 874 | | Producer | `simulator-ax-bridge` | @@ -24,16 +25,16 @@ The returned lineage carried the supplied target id and opaque generation. The s ## Build and cache - Protocol version: `1` -- Source version: `agent-device-simulator-ax-v1.5.2` -- Source hash: `f9e9b741fc354e82d1fa1a78d5a92cacc5d6bb81f1eb2ab3bcb5748fc150d432` -- Cache key: `a9807a2888a1dad82709665d837dc0d9` +- Source version: `agent-device-simulator-ax-v1.5.3` +- Source hash: `44e0c10dd5f0bf236c35293999e05d6bfaa740b492a98206da6dc1dec6f7d879` +- Cache key: `0c73362db09451e54089e40d42c8f263` - The live acquisition used the prepared cache entry; deterministic tests cover cold publish, atomic concurrent publish, corrupt-entry rejection, source invalidation, and toolchain invalidation. ## Package size -- Measured npm artifact: 482 files, 1,034,728-byte tarball, 3,507,967 unpacked and clean-installed bytes. -- Compared with the #2189 published baseline recorded by PR #2204: +20,923 tarball bytes and +69,029 unpacked/clean-installed bytes (baseline 1,013,805 / 3,438,938 bytes). -- The new `apple-snapshot-bridge` component contributes 25,532 unpacked bytes across five published source/license/readme files. +- 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 From 597cb16db1edef1b1d772b6d48574496ad82e705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 12:26:29 +0200 Subject: [PATCH 4/9] fix: address new Simulator AX bridge review comments --- .../src/snapshot-source/adapter.test.ts | 11 +- .../src/snapshot-source/adapter.ts | 8 +- .../src/snapshot-source/cache.test.ts | 19 ++- .../src/snapshot-source/cache.ts | 14 +- .../src/snapshot-source/deadline.ts | 3 +- .../fixtures/native-wire-golden.json | 51 ------- .../fixtures/wire-vocabulary.json | 40 ++++++ .../src/snapshot-source/host.ts | 22 ++-- .../src/snapshot-source/lifecycle.test.ts | 109 ++++++++++++--- .../src/snapshot-source/lifecycle.ts | 124 ++++++++++++------ .../src/snapshot-source/process.ts | 41 ++++++ .../src/snapshot-source/protocol.test.ts | 70 ++++------ .../src/snapshot-source/protocol.ts | 80 ++++++++--- .../src/snapshot-source/transport.ts | 32 ++--- .../src/snapshot-source/types.ts | 8 +- .../fixtures/size-report-npm-pack.json | 1 + scripts/__tests__/size-report-package.test.ts | 1 + .../size-install.test.ts | 14 +- scripts/lib/snapshot-bridge-assets.mjs | 13 ++ scripts/size-report-install.mjs | 23 ++-- scripts/size-report-package.mjs | 10 +- 21 files changed, 452 insertions(+), 242 deletions(-) delete mode 100644 packages/platform-apple/src/snapshot-source/fixtures/native-wire-golden.json create mode 100644 packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json create mode 100644 packages/platform-apple/src/snapshot-source/process.ts create mode 100644 scripts/lib/snapshot-bridge-assets.mjs diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index 82845fc7d..457b198ec 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -47,6 +47,7 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp 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'); @@ -77,6 +78,7 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp }); 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 }); @@ -114,6 +116,7 @@ test('preparation consumes the same acquisition deadline as bridge I/O', async ( type AdapterFixture = { host: SnapshotSourceHost; builds: number; + runs: number; responsePid: number; }; @@ -127,10 +130,16 @@ function targetForTest() { function createAdapterHost(buildDelayMs = 0): AdapterFixture { const realHost = createSnapshotSourceHost(); - const fixture: AdapterFixture = { host: undefined as never, builds: 0, responsePid: 321 }; + 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)); diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts index b52cf19bc..ae3449602 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -10,6 +10,7 @@ import { decodeSnapshotBridgeTree } from './tree.ts'; import { createSnapshotSourceHost } from './host.ts'; import type { SnapshotSourceHost, + SnapshotSourceBridgeBinary, SnapshotSourceLimits, SnapshotSourceOutcome, SnapshotSourceRequest, @@ -34,6 +35,7 @@ export function createSimulatorSnapshotSource( ): SimulatorSnapshotSource { const host = options.host ?? createSnapshotSourceHost(); const manager = new SnapshotBridgeManager(host); + const preparedBinaries = new Map(); let closed = false; const prepare = async ( @@ -44,7 +46,9 @@ export function createSimulatorSnapshotSource( }>, ) => { if (closed) throw snapshotSourceError('unsupported', 'source-closed'); - return await host.withDiagnosticTimer( + const prepared = preparedBinaries.get(input.runtime); + if (prepared) return prepared; + const completed = await host.withDiagnosticTimer( 'ios.snapshot-source.prepare', async () => await ensureSnapshotBridgeBinary({ @@ -57,6 +61,8 @@ export function createSimulatorSnapshotSource( }), { producer: SNAPSHOT_SOURCE_PRODUCER }, ); + preparedBinaries.set(input.runtime, completed); + return completed; }; const acquire = async (request: SnapshotSourceRequest): Promise => { diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index aaf962ad7..6042f731b 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -5,6 +5,7 @@ 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'; @@ -34,6 +35,7 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt host, runtime: 'iOS 26.2', limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), sourceRoot, cacheRoot, }); @@ -44,6 +46,7 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt host, runtime: 'iOS 26.2', limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), sourceRoot, cacheRoot, }); @@ -55,6 +58,7 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt host, runtime: 'iOS 26.2', limits: { ...DEFAULT_SNAPSHOT_SOURCE_LIMITS, maxNodes: 200, maxTraversalDepth: 12 }, + deadline: testDeadline(), sourceRoot, cacheRoot, }); @@ -65,6 +69,7 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt host, runtime: 'iOS 26.2', limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), sourceRoot, cacheRoot, }); @@ -80,6 +85,7 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt host, runtime: 'iOS 26.2', limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), sourceRoot, cacheRoot, }); @@ -90,6 +96,7 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt host, runtime: 'iOS 26.2', limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), sourceRoot, cacheRoot, }); @@ -101,6 +108,7 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt host, runtime: 'iOS 26.2', limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), sourceRoot, cacheRoot, }); @@ -133,6 +141,7 @@ test('concurrent snapshot bridge preparation publishes one cache entry', async ( host, runtime: 'iOS 26.2', limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), sourceRoot, cacheRoot, }), @@ -172,7 +181,7 @@ test('an aborted cache waiter does not cancel an independent preparation', async host, runtime: 'iOS 26.2', limits: { ...DEFAULT_SNAPSHOT_SOURCE_LIMITS, maxDurationMs: 300 }, - signal: controller.signal, + deadline: testDeadline(300, controller.signal), sourceRoot, cacheRoot, }); @@ -182,6 +191,7 @@ test('an aborted cache waiter does not cancel an independent preparation', async host, runtime: 'iOS 26.2', limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), sourceRoot, cacheRoot, }); @@ -205,6 +215,13 @@ async function expectRejectedCancellation(value: Promise): Promise string | Promise), getXcode: () => string = () => 'Xcode 16.4\nBuild version 16F6', diff --git a/packages/platform-apple/src/snapshot-source/cache.ts b/packages/platform-apple/src/snapshot-source/cache.ts index d5f73efaf..16b89885a 100644 --- a/packages/platform-apple/src/snapshot-source/cache.ts +++ b/packages/platform-apple/src/snapshot-source/cache.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { SnapshotSourceError, snapshotSourceError } from './errors.ts'; -import { createSnapshotSourceDeadline, remainingSnapshotSourceMs } from './deadline.ts'; +import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts'; import { fingerprintSnapshotBridgeSource, readSnapshotSourceToolchain, @@ -36,14 +36,12 @@ export async function ensureSnapshotBridgeBinary( host: SnapshotSourceHost; runtime: string; limits: SnapshotSourceLimits; - signal?: AbortSignal; - deadline?: import('./deadline.ts').SnapshotSourceDeadline; + deadline: SnapshotSourceDeadline; sourceRoot?: string; cacheRoot?: string; }>, ): Promise { - const deadline = - input.deadline ?? createSnapshotSourceDeadline(input.limits.maxDurationMs, input.signal); + 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); @@ -186,7 +184,7 @@ async function readValidCache( cacheKey: string; toolchain: SnapshotSourceToolchainIdentity; }>, - deadline: import('./deadline.ts').SnapshotSourceDeadline, + deadline: SnapshotSourceDeadline, ): Promise { const binaryPath = path.join(entryPath, BRIDGE_FILENAME); if (!host.exists(binaryPath) || !host.exists(path.join(entryPath, MANIFEST_FILENAME))) { @@ -229,9 +227,9 @@ async function readValidCache( async function sha256File( host: SnapshotSourceHost, filePath: string, - deadline?: import('./deadline.ts').SnapshotSourceDeadline, + deadline: SnapshotSourceDeadline, ): Promise { - if (deadline) remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline'); + remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline'); return createHash('sha256') .update(await host.readBinary(filePath)) .digest('hex'); diff --git a/packages/platform-apple/src/snapshot-source/deadline.ts b/packages/platform-apple/src/snapshot-source/deadline.ts index c77ccd98e..ca03e563e 100644 --- a/packages/platform-apple/src/snapshot-source/deadline.ts +++ b/packages/platform-apple/src/snapshot-source/deadline.ts @@ -3,7 +3,7 @@ import { snapshotSourceError } from './errors.ts'; export type SnapshotSourceDeadline = Readonly<{ clock: Deadline; - signal?: AbortSignal; + signal: AbortSignal | undefined; }>; export function createSnapshotSourceDeadline( @@ -41,5 +41,6 @@ export async function waitForSnapshotSourceDelay( action(); }; deadline.signal?.addEventListener('abort', onAbort, { once: true }); + if (deadline.signal?.aborted) onAbort(); }); } diff --git a/packages/platform-apple/src/snapshot-source/fixtures/native-wire-golden.json b/packages/platform-apple/src/snapshot-source/fixtures/native-wire-golden.json deleted file mode 100644 index 004494867..000000000 --- a/packages/platform-apple/src/snapshot-source/fixtures/native-wire-golden.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "producer": "SnapshotBridge.m + SnapshotBridgeRuntime.m", - "request": { - "verb": "describe", - "requestId": "golden-request", - "pid": 321, - "generation": "generation-current", - "snapshotTree": true, - "automationMode": true, - "maxDepth": 8, - "maxNodes": 1500, - "maxDurationMs": 5000, - "maxResponseBytes": 4194304 - }, - "success": { - "protocolVersion": 1, - "sourceVersion": "agent-device-simulator-ax-v1.5.3", - "requestId": "golden-request", - "generation": "generation-current", - "ok": true, - "pid": 321, - "truncated": false, - "automationEnabled": true - }, - "malformed": { - "protocolVersion": 1, - "sourceVersion": "agent-device-simulator-ax-v1.5.3", - "requestId": "golden-request", - "generation": "generation-current", - "ok": false, - "pid": 321, - "error_kind": "malformed_tree", - "error_code": "snapshot-tree-malformed" - }, - "versionMismatch": { - "protocolVersion": 0, - "sourceVersion": "agent-device-simulator-ax-v1.5.3", - "requestId": "golden-request", - "generation": "generation-current" - }, - "staleGeneration": { - "protocolVersion": 1, - "sourceVersion": "agent-device-simulator-ax-v1.5.3", - "requestId": "golden-request", - "generation": "generation-previous", - "ok": true, - "pid": 321, - "truncated": false, - "automationEnabled": true - } -} diff --git a/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json b/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json new file mode 100644 index 000000000..b7282ec1d --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json @@ -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" + ] +} diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index a5d74d95a..fe62909a7 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -9,11 +9,9 @@ import { hostFileExistsSync, hostHomeDirectory, readHostBinaryFile, - readHostDirectory, readHostTextFile, removeHostPath, renameHostPath, - hostTemporaryDirectory, writeHostTextFile, } from '@agent-device/host-kit/host-file'; import { @@ -22,7 +20,6 @@ import { signalProcessGroupBestEffort, } from '@agent-device/host-kit/process'; import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; -import { withKeyedLock } from '@agent-device/kernel/keyed-lock'; import { findProjectRoot } from '@agent-device/host-kit/version'; import { SnapshotSourceError, snapshotSourceError } from './errors.ts'; import { remainingSnapshotSourceMs } from './deadline.ts'; @@ -31,7 +28,6 @@ import type { SnapshotSourceHost, SnapshotSourceProcess, SnapshotSourceSocket } const BRIDGE_IDLE_TIMEOUT_SECONDS = 60; const MAX_PROCESS_LOG_BYTES = 64 * 1024; const SNAPSHOT_SOCKET_ROOT = '/tmp'; -const snapshotSourceLocks = new Map>(); export function createSnapshotSourceHost(): SnapshotSourceHost { return { @@ -43,20 +39,15 @@ export function createSnapshotSourceHost(): SnapshotSourceHost { readText: readHostTextFile, readBinary: readHostBinaryFile, writeText: writeHostTextFile, - listDirectory: async (directoryPath) => - await readHostDirectory(directoryPath, { withFileTypes: true }), ensureDirectory: ensureHostDirectory, chmod: chmodHostFile, exists: hostFileExistsSync, rename: renameHostPath, remove: removeHostPath, acquireLock: acquireSnapshotSourceLock, - withKeyedLock: async (key, action) => await withKeyedLock(snapshotSourceLocks, key, action), emitDiagnostic, withDiagnosticTimer, processId: hostProcessId, - readProcessStartTime, - temporaryDirectory: hostTemporaryDirectory, }; } @@ -164,12 +155,13 @@ async function connectSnapshotBridge( 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[1] = {}, + options: Parameters[1], ): Promise<() => Promise> { const pid = hostProcessId(); const deadline = options.deadline; @@ -180,12 +172,13 @@ async function acquireSnapshotSourceLock( startTime: readProcessStartTime(pid), acquiredAtMs: Date.now(), }, - timeoutMs: deadline ? remainingSnapshotSourceMs(deadline, 'cache-lock-deadline') : 180_000, + timeoutMs: remainingSnapshotSourceMs(deadline, 'cache-lock-deadline'), pollMs: 100, ownerGraceMs: 5_000, description: 'iOS Simulator snapshot bridge cache', }); - if (!deadline?.signal) return await pending; + const signal = deadline.signal; + if (!signal) return await pending; let canceled = false; let onAbort!: () => void; @@ -194,7 +187,8 @@ async function acquireSnapshotSourceLock( canceled = true; reject(snapshotSourceError('cancelled', 'abort-signal')); }; - deadline.signal!.addEventListener('abort', onAbort, { once: true }); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); }); try { return await Promise.race([pending, aborted]); @@ -212,7 +206,7 @@ async function acquireSnapshotSourceLock( } throw error; } finally { - deadline.signal.removeEventListener('abort', onAbort); + signal.removeEventListener('abort', onAbort); } } diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index 8762906da..6aa1a507c 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -3,6 +3,7 @@ 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, @@ -43,8 +44,8 @@ test('the bridge manager reuses a healthy per-device helper and stops it exactly const fixture = createLifecycleFixture(); const manager = new SnapshotBridgeManager(fixture.host); - await manager.request({ target, bridge, limits, maxDepth: 10 }); - await manager.request({ target, bridge, limits, maxDepth: 10 }); + 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); @@ -52,22 +53,57 @@ test('the bridge manager reuses a healthy per-device helper and stops it exactly assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']); }); -test('a new target generation does not reuse the previous helper', async () => { +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 }); + 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, 2); + 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(); - assert.deepEqual(fixture.processes[1]!.signals, ['SIGTERM']); }); test('request cancellation after dispatch reaps the exact helper before recovery', async () => { @@ -79,7 +115,7 @@ test('request cancellation after dispatch reaps the exact helper before recovery bridge, limits, maxDepth: 10, - signal: controller.signal, + deadline: deadline(controller.signal), }); setTimeout(() => controller.abort(), 10); @@ -89,7 +125,7 @@ test('request cancellation after dispatch reaps the exact helper before recovery ); assert.deepEqual(fixture.processes[0]?.signals, ['SIGTERM']); assert.equal(fixture.processes[0]?.alive, false); - await manager.request({ target, bridge, limits, maxDepth: 10 }); + 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']); @@ -103,6 +139,7 @@ test('pre-dispatch cancellation preserves a healthy helper', async () => { bridge, limits: { ...limits, maxDurationMs: 500 }, maxDepth: 10, + deadline: deadline(undefined, 500), }); fixture.sockets[0]!.destroy(); @@ -112,7 +149,7 @@ test('pre-dispatch cancellation preserves a healthy helper', async () => { bridge, limits: { ...limits, maxDurationMs: 500 }, maxDepth: 10, - signal: controller.signal, + deadline: deadline(controller.signal, 500), }); setTimeout(() => controller.abort(), 10); @@ -129,6 +166,7 @@ test('pre-dispatch cancellation preserves a healthy helper', async () => { bridge, limits: { ...limits, maxDurationMs: 500 }, maxDepth: 10, + deadline: deadline(undefined, 500), }); assert.equal(fixture.processes.length, 1); await manager.close(); @@ -145,6 +183,7 @@ test('one absolute deadline covers helper connect and response read', async () = bridge, limits: { ...limits, maxDurationMs: 100 }, maxDepth: 10, + deadline: deadline(undefined, 100), }), (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout', ); @@ -155,7 +194,7 @@ test('one absolute deadline covers helper connect and response read', async () = 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 }); + const request = manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }); setTimeout(() => fixture.processes[0]?.crash(), 10); await assert.rejects( @@ -165,17 +204,40 @@ test('a crashed helper is removed and the next request starts a fresh helper', a ); assert.equal(fixture.processes[0]?.signals.length, 0); - await manager.request({ target, bridge, limits, maxDepth: 10 }); + 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 }), + manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }), (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'stale-target', ); @@ -187,7 +249,7 @@ test('the manager rejects a response carrying a previous target generation as st const manager = new SnapshotBridgeManager(fixture.host); await assert.rejects( - manager.request({ target, bridge, limits, maxDepth: 10 }), + manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }), (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'stale-target' && @@ -201,7 +263,7 @@ test('typed guest failures retain their kind after target validation', async () const manager = new SnapshotBridgeManager(fixture.host); await assert.rejects( - manager.request({ target, bridge, limits, maxDepth: 10 }), + manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }), (error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout', ); await manager.close(); @@ -211,8 +273,21 @@ type LifecycleFixture = { host: SnapshotSourceHost; processes: FakeProcess[]; sockets: FakeSocket[]; + diagnostics: Array[0]>; }; +function deadline(signal?: AbortSignal, timeoutMs = limits.maxDurationMs) { + return createSnapshotSourceDeadline(timeoutMs, signal); +} + +async function waitForDispatch(fixture: LifecycleFixture): Promise { + 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; @@ -224,9 +299,11 @@ function createLifecycleFixture( ): LifecycleFixture { const processes: FakeProcess[] = []; const sockets: FakeSocket[] = []; + const diagnostics: LifecycleFixture['diagnostics'] = []; const realHost = createSnapshotSourceHost(); const host: SnapshotSourceHost = { ...realHost, + emitDiagnostic: (event) => diagnostics.push(event), start: () => { const process = new FakeProcess(700 + processes.length); processes.push(process); @@ -259,7 +336,7 @@ function createLifecycleFixture( return socket; }, }; - return { host, processes, sockets }; + return { host, processes, sockets, diagnostics }; } class FakeProcess implements SnapshotSourceProcess { @@ -302,6 +379,7 @@ class FakeProcess implements SnapshotSourceProcess { class FakeSocket extends EventEmitter implements SnapshotSourceSocket { destroyed = false; + writes = 0; private readonly responseDelayMs: number; private readonly responsePid: number; private readonly responseGeneration: string | undefined; @@ -321,6 +399,7 @@ class FakeSocket extends EventEmitter implements SnapshotSourceSocket { } 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; diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 40ca699c6..948ee66ec 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -2,12 +2,12 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { asSnapshotSourceError, snapshotSourceError, SnapshotSourceError } from './errors.ts'; import { - createSnapshotSourceDeadline, 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 { @@ -27,7 +27,6 @@ const SHUTDOWN_KILL_TIMEOUT_MS = 500; type BridgeSession = { readonly udid: string; - readonly generation: string; readonly bridgePath: string; readonly socketPath: string; readonly process: SnapshotSourceProcess; @@ -39,12 +38,12 @@ type SnapshotBridgeRequest = Readonly<{ bridge: SnapshotSourceBridgeBinary; limits: SnapshotSourceLimits; maxDepth: number; - signal?: AbortSignal; - deadline?: SnapshotSourceDeadline; + deadline: SnapshotSourceDeadline; }>; export class SnapshotBridgeManager { private readonly sessions = new Map(); + private readonly requestQueues = new Map>(); private closed = false; private readonly host: SnapshotSourceHost; @@ -54,30 +53,61 @@ export class SnapshotBridgeManager { async request(input: SnapshotBridgeRequest): Promise { if (this.closed) throw snapshotSourceError('unsupported', 'source-closed'); - const deadline = - input.deadline ?? createSnapshotSourceDeadline(input.limits.maxDurationMs, input.signal); - return await this.host.withKeyedLock(`simulator:${input.target.udid}`, async () => { - remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'); - const previousSession = this.sessions.get(input.target.udid); - const session = await this.ensureSession(input, deadline); - try { - return await this.exchange(session, input, deadline); - } catch (error) { - const normalized = asSnapshotSourceError(error); - if ( - (normalized.failureKind === 'cancelled' || normalized.failureKind === 'timeout') && - (normalized.details?.dispatched === true || previousSession !== session) - ) { - 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; - } - throw normalized; - } + return await this.withSimulatorLock(input.target.udid, input.deadline, () => + this.requestInSimulator(input), + ); + } + + private async requestInSimulator(input: SnapshotBridgeRequest): Promise { + 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 { + return await this.exchange(session, input, deadline); + } catch (error) { + const normalized = await this.handleRequestFailure(error, session, previousSession); + throw normalized; + } + } + + private async handleRequestFailure( + error: unknown, + session: BridgeSession, + previousSession: BridgeSession | undefined, + ): Promise { + 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( + udid: string, + deadline: SnapshotSourceDeadline, + action: () => Promise, + ): Promise { + const previous = this.requestQueues.get(udid); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; }); + this.requestQueues.set(udid, current); + try { + if (previous) await waitForSimulatorTurn(previous, deadline); + else remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'); + return await action(); + } finally { + release(); + if (this.requestQueues.get(udid) === current) this.requestQueues.delete(udid); + } } async close(): Promise { @@ -94,12 +124,7 @@ export class SnapshotBridgeManager { ): Promise { const key = input.target.udid; const existing = this.sessions.get(key); - if ( - existing && - existing.generation === input.target.generation && - existing.bridgePath === input.bridge.path && - existing.process.isAlive() - ) { + if (existing && existing.bridgePath === input.bridge.path && existing.process.isAlive()) { if (!existing.socket || existing.socket.destroyed) { existing.socket = await this.connectUntilReady(existing, deadline); } @@ -115,7 +140,6 @@ export class SnapshotBridgeManager { }); const session: BridgeSession = { udid: input.target.udid, - generation: input.target.generation, bridgePath: input.bridge.path, socketPath, process: bridgeProcess, @@ -137,7 +161,9 @@ export class SnapshotBridgeManager { let lastError: unknown; while (true) { const remainingMs = remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline'); - if (!session.process.isAlive()) throw bridgeProcessExited(session.process); + if (!session.process.isAlive()) { + throw await bridgeProcessExited(this.host, session.process); + } try { return await this.host.connect(session.socketPath, { signal: deadline.signal, @@ -192,6 +218,7 @@ export class SnapshotBridgeManager { limits: input.limits, expectedPid: input.target.pid, expectedGeneration: input.target.generation, + host: this.host, }); } @@ -215,11 +242,28 @@ export class SnapshotBridgeManager { } } -function bridgeProcessExited(bridgeProcess: SnapshotSourceProcess): SnapshotSourceError { - return snapshotSourceError('process-crash', 'bridge-exited', { - pid: bridgeProcess.pid, - log: bridgeProcess.readLog().slice(-64 * 1024), - }); +async function waitForSimulatorTurn( + previous: Promise, + deadline: SnapshotSourceDeadline, +): Promise { + 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( diff --git a/packages/platform-apple/src/snapshot-source/process.ts b/packages/platform-apple/src/snapshot-source/process.ts new file mode 100644 index 000000000..1ad491e49 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/process.ts @@ -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(); + +export async function bridgeProcessExited( + host: SnapshotSourceHost, + bridgeProcess: SnapshotSourceProcess, +): Promise { + 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'); +} diff --git a/packages/platform-apple/src/snapshot-source/protocol.test.ts b/packages/platform-apple/src/snapshot-source/protocol.test.ts index a30d40a71..1f96a6748 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.test.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.test.ts @@ -4,7 +4,6 @@ import path from 'node:path'; import { test } from 'vitest'; import { assertSnapshotBridgeEnvelope, - assertSnapshotBridgeTargetIdentity, bridgeFailureFromEnvelope, createSnapshotBridgeDescribeRequest, encodeSnapshotBridgeFrame, @@ -26,20 +25,25 @@ const limits: SnapshotSourceLimits = { maxDurationMs: 1000, }; -const nativeWireGolden = JSON.parse( - await readFile(path.join(import.meta.dirname, 'fixtures', 'native-wire-golden.json'), 'utf8'), -); +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 across split and coalesced socket chunks', () => { +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.deepEqual(decoder.push(first.subarray(0, 3)), []); - assert.deepEqual(decoder.push(Buffer.concat([first.subarray(3), second])), [ - Buffer.from('{"requestId":"one","value":1}'), - Buffer.from('{"requestId":"two","value":2}'), - ]); + 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', () => { @@ -116,9 +120,9 @@ test('snapshot bridge failures stay typed at the guest boundary', () => { } }); -test('native wire golden keeps TS and Objective-C protocol vocabularies in parity', async () => { +test('wire vocabulary guard keeps TS and Objective-C literals aligned', async () => { const native = await Promise.all( - ['SnapshotBridge.m', 'SnapshotBridgeRuntime.m'].map((fileName) => + ['SnapshotBridge.m', 'SnapshotBridgeRuntime.m', 'SnapshotBridgeRuntime.h'].map((fileName) => readFile( path.join(import.meta.dirname, '../../../../apple/snapshot-bridge', fileName), 'utf8', @@ -126,47 +130,21 @@ test('native wire golden keeps TS and Objective-C protocol vocabularies in parit ), ); 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"/); - assert.match(nativeSource, /snapshot-tree-malformed/); - assert.match(nativeSource, /if \(\*malformed\) return nil/); for (const key of [ - ...SNAPSHOT_SOURCE_WIRE_KEYS, - ...SNAPSHOT_SOURCE_RESPONSE_KEYS, - ...SNAPSHOT_SOURCE_ATTRIBUTE_KEYS, + ...wireVocabulary.requestKeys, + ...wireVocabulary.responseKeys, + ...wireVocabulary.attributeKeys, ]) { assert.match( nativeSource, new RegExp(`@"${key.replaceAll(/[.*+?^${}()|[\\]\\\\]/g, String.raw`\$&`)}"`), ); } - - const request = createSnapshotBridgeDescribeRequest(nativeWireGolden.request); - assert.deepEqual(request, nativeWireGolden.request); - const success = parseSnapshotBridgeEnvelope( - Buffer.from(JSON.stringify(nativeWireGolden.success)), - ); - assert.doesNotThrow(() => assertSnapshotBridgeEnvelope(success, 'golden-request')); - assert.doesNotThrow(() => - assertSnapshotBridgeTargetIdentity(success, { pid: 321, generation: 'generation-current' }), - ); - assert.throws( - () => assertSnapshotBridgeEnvelope(nativeWireGolden.versionMismatch, 'golden-request'), - /protocol-version-mismatch/, - ); - assert.throws( - () => - assertSnapshotBridgeTargetIdentity(nativeWireGolden.staleGeneration, { - pid: 321, - generation: 'generation-current', - }), - /bridge-generation-mismatch/, - ); - assert.throws( - () => bridgeFailureFromEnvelope(nativeWireGolden.malformed), - (error: unknown) => - error instanceof Error && - 'failureKind' in error && - (error as { failureKind: string }).failureKind === 'malformed-tree', - ); }); diff --git a/packages/platform-apple/src/snapshot-source/protocol.ts b/packages/platform-apple/src/snapshot-source/protocol.ts index 3d06cfae3..e4f0f4774 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.ts @@ -70,7 +70,12 @@ export function encodeSnapshotBridgeFrame( } export class SnapshotBridgeFrameDecoder { - private buffer = Buffer.alloc(0); + 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) { @@ -80,23 +85,64 @@ export class SnapshotBridgeFrameDecoder { this.maxFrameBytes = maxFrameBytes; } - push(chunk: Buffer): Buffer[] { - this.buffer = Buffer.concat([this.buffer, chunk]); - const frames: Buffer[] = []; - while (this.buffer.length >= FRAME_HEADER_BYTES) { - const bodyBytes = this.buffer.readUInt32BE(0); - if (bodyBytes === 0 || bodyBytes > this.maxFrameBytes) { - throw snapshotSourceError('malformed-tree', 'frame-limit-exceeded', { - bodyBytes, - maxFrameBytes: this.maxFrameBytes, - }); - } - const frameBytes = FRAME_HEADER_BYTES + bodyBytes; - if (this.buffer.length < frameBytes) break; - frames.push(this.buffer.subarray(FRAME_HEADER_BYTES, frameBytes)); - this.buffer = this.buffer.subarray(frameBytes); + 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, + }); } - return frames; + 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, + }); } } diff --git a/packages/platform-apple/src/snapshot-source/transport.ts b/packages/platform-apple/src/snapshot-source/transport.ts index 7d5c4260d..18f82e4ec 100644 --- a/packages/platform-apple/src/snapshot-source/transport.ts +++ b/packages/platform-apple/src/snapshot-source/transport.ts @@ -1,5 +1,6 @@ import { asSnapshotSourceError, snapshotSourceError } from './errors.ts'; import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts'; +import { bridgeProcessExited } from './process.ts'; import { assertSnapshotBridgeEnvelope, assertSnapshotBridgeTargetIdentity, @@ -8,7 +9,12 @@ import { SnapshotBridgeFrameDecoder, type SnapshotBridgeEnvelope, } from './protocol.ts'; -import type { SnapshotSourceLimits, SnapshotSourceProcess, SnapshotSourceSocket } from './types.ts'; +import type { + SnapshotSourceHost, + SnapshotSourceLimits, + SnapshotSourceProcess, + SnapshotSourceSocket, +} from './types.ts'; export async function roundTripSnapshotBridge( input: Readonly<{ @@ -20,6 +26,7 @@ export async function roundTripSnapshotBridge( limits: SnapshotSourceLimits; expectedPid: number; expectedGeneration: string; + host: SnapshotSourceHost; }>, ): Promise { const decoder = new SnapshotBridgeFrameDecoder(input.limits.maxResponseBytes - 4); @@ -39,8 +46,8 @@ export async function roundTripSnapshotBridge( try { if (!Buffer.isBuffer(chunk)) throw snapshotSourceError('transport-failure', 'bridge-data-invalid'); - const frames = decoder.push(chunk); - for (const body of frames) { + const body = decoder.push(chunk); + if (body) { const envelope = parseSnapshotBridgeEnvelope(body); assertSnapshotBridgeEnvelope(envelope, input.requestId); assertSnapshotBridgeTargetIdentity(envelope, { @@ -62,16 +69,16 @@ export async function roundTripSnapshotBridge( const onError = (error: unknown) => finishReject(asSnapshotSourceError(error)); const onClose = () => { if (!settled) { - finishReject( - input.process.isAlive() - ? snapshotSourceError('transport-failure', 'bridge-connection-closed') - : bridgeProcessExited(input.process), - ); + 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) finishReject(bridgeProcessExited(input.process)); + if (!settled) void bridgeProcessExited(input.host, input.process).then(finishReject); }, (error: unknown) => { if (!settled) finishReject(asSnapshotSourceError(error)); @@ -104,10 +111,3 @@ export async function roundTripSnapshotBridge( } }); } - -function bridgeProcessExited(bridgeProcess: SnapshotSourceProcess) { - return snapshotSourceError('process-crash', 'bridge-exited', { - pid: bridgeProcess.pid, - log: bridgeProcess.readLog().slice(-64 * 1024), - }); -} diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts index a194ea069..00c5896af 100644 --- a/packages/platform-apple/src/snapshot-source/types.ts +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -77,7 +77,6 @@ export type SnapshotSourceSocket = Readonly<{ export type SnapshotSourceHost = Readonly<{ projectRoot(): string; homeDirectory(): string; - temporaryDirectory(): string; run(command: string, args: string[], options?: ExecOptions): Promise; start( udid: string, @@ -92,9 +91,6 @@ export type SnapshotSourceHost = Readonly<{ readText(path: string): Promise; readBinary(path: string): Promise; writeText(path: string, contents: string): Promise; - listDirectory( - path: string, - ): Promise>; ensureDirectory(path: string): Promise; chmod(path: string, mode: number): Promise; exists(path: string): boolean; @@ -102,9 +98,8 @@ export type SnapshotSourceHost = Readonly<{ remove(path: string): Promise; acquireLock( path: string, - options?: { deadline?: SnapshotSourceDeadline }, + options: { deadline: SnapshotSourceDeadline }, ): Promise<() => Promise>; - withKeyedLock(key: string, action: () => Promise): Promise; emitDiagnostic(event: { level?: 'debug' | 'info' | 'warn' | 'error'; phase: string; @@ -117,7 +112,6 @@ export type SnapshotSourceHost = Readonly<{ data?: Record, ): Promise; processId(): number; - readProcessStartTime(pid: number): string | null; }>; export type SnapshotSourceBridgeBinary = Readonly<{ diff --git a/scripts/__tests__/fixtures/size-report-npm-pack.json b/scripts/__tests__/fixtures/size-report-npm-pack.json index a7c6ae86c..df8c38abe 100644 --- a/scripts/__tests__/fixtures/size-report-npm-pack.json +++ b/scripts/__tests__/fixtures/size-report-npm-pack.json @@ -7,6 +7,7 @@ { "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 }, diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index 32c5020a0..0a44e8ca5 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -25,6 +25,7 @@ test('classifies every shipped entry into one named component', () => { ['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'], diff --git a/scripts/ios-snapshot-benchmark/size-install.test.ts b/scripts/ios-snapshot-benchmark/size-install.test.ts index d36abfc97..09ac5798d 100644 --- a/scripts/ios-snapshot-benchmark/size-install.test.ts +++ b/scripts/ios-snapshot-benchmark/size-install.test.ts @@ -13,16 +13,18 @@ test('measures the clean-installed package tree without counting the consumer', assert.deepEqual(measureDirectory(root), { packageBytes: 6, files: 2 }); }); -test('clean-installed snapshot bridge keeps both native prepare/acquire sources', async () => { +test('clean-installed snapshot bridge requires all native assets', async () => { const root = await mkdtempForTest('agent-device-size-bridge-'); const bridge = join(root, 'apple', 'snapshot-bridge'); - await mkdir(bridge, { recursive: true }); - await writeFile(join(bridge, 'SnapshotBridge.m'), 'serve'); - await writeFile(join(bridge, 'SnapshotBridgeRuntime.m'), 'snapshotForProcess'); try { + assert.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridge\.m/); + 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.m')); - assert.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridgeRuntime\.m/); + await rm(join(bridge, 'SnapshotBridgeRuntime.h')); + assert.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridgeRuntime\.h/); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/scripts/lib/snapshot-bridge-assets.mjs b/scripts/lib/snapshot-bridge-assets.mjs new file mode 100644 index 000000000..146ba3bbc --- /dev/null +++ b/scripts/lib/snapshot-bridge-assets.mjs @@ -0,0 +1,13 @@ +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(', ')}`); + } +} diff --git a/scripts/size-report-install.mjs b/scripts/size-report-install.mjs index f1b8fee0a..404c8f6c4 100644 --- a/scripts/size-report-install.mjs +++ b/scripts/size-report-install.mjs @@ -2,6 +2,10 @@ 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 './lib/snapshot-bridge-assets.mjs'; export function measureCleanInstalledPackage(tarballPath, packageName) { const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-size-install-')); @@ -41,21 +45,10 @@ export function measureCleanInstalledPackage(tarballPath, packageName) { } export function assertInstalledSnapshotBridge(packageDir) { - const bridgeRoot = path.join(packageDir, 'apple', 'snapshot-bridge'); - if (!fs.existsSync(bridgeRoot)) return; - const requiredSources = ['SnapshotBridge.m', 'SnapshotBridgeRuntime.m']; - const missing = requiredSources.filter((source) => !fs.existsSync(path.join(bridgeRoot, source))); - if (missing.length > 0) { - throw new Error(`Clean-installed snapshot bridge is missing: ${missing.join(', ')}`); - } - const source = requiredSources - .map((fileName) => fs.readFileSync(path.join(bridgeRoot, fileName), 'utf8')) - .join('\n'); - if (!source.includes('serve') || !source.includes('snapshotForProcess')) { - throw new Error( - 'Clean-installed snapshot bridge does not contain its serve/acquire implementation.', - ); - } + const present = SNAPSHOT_BRIDGE_ASSET_PATHS.filter((assetPath) => + fs.existsSync(path.join(packageDir, assetPath)), + ); + assertSnapshotBridgeAssets(present, 'Clean-installed snapshot bridge'); } export function measureDirectory(root) { diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index fbd9e22c1..1408ab8c6 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -7,6 +7,10 @@ import { formatMaybeBytes, formatSignedBytes, } from './size-report-format.mjs'; +import { + assertSnapshotBridgeAssets, + SNAPSHOT_BRIDGE_ASSET_PATHS, +} from './lib/snapshot-bridge-assets.mjs'; const PACKAGE_COMPONENTS = [ { @@ -84,9 +88,9 @@ export function assertPublishPackageContents(entries, options = {}) { options.requireSnapshotBridge ?? paths.some((entryPath) => entryPath.startsWith('apple/snapshot-bridge/')) ) { - requiredAssets.unshift( - { path: 'apple/snapshot-bridge/SnapshotBridge.m' }, - { path: 'apple/snapshot-bridge/SnapshotBridgeRuntime.m' }, + assertSnapshotBridgeAssets( + paths.filter((entryPath) => SNAPSHOT_BRIDGE_ASSET_PATHS.includes(entryPath)), + 'npm pack snapshot bridge', ); } const missingAssets = requiredAssets.filter((asset) => From 6e55aba5d6cb83d2a3a64b75156b63a739c600af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 12:26:41 +0200 Subject: [PATCH 5/9] docs: record public snapshot source timings --- ...os-simulator-snapshot-source-2026-09-04.md | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md index 22df3515e..379fd87be 100644 --- a/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md +++ b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md @@ -1,18 +1,22 @@ # iOS Simulator snapshot-source live evidence - Issue: #2196 -- Observed: 2026-09-04T09:03:35Z -- Revision: `6d39faaa6a36a010b346d36bd576194d936115ac` +- 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`), PID `60439` -- Workflow: `agent-device open` established the session; the private facet was then called directly with an injected host and raw projection. No production routing or proxy path was used. +- 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 | |---|---:| -| Acquisition latency | 2560 ms | +| 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.1–14.1 ms | +| Relaunch acquire range | 38.3–97.1 ms | | Raw nodes | 77 | | Truncated | false | | Viewport | 402 x 874 | @@ -20,7 +24,7 @@ | Intent | `full` | | Residue | `hittability` unavailable | -The returned lineage carried the supplied target id and opaque generation. The source returned raw nodes with the observed target PID and did not claim hittability or interaction-query facts. +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 @@ -28,7 +32,8 @@ The returned lineage carried the supplied target id and opaque generation. The s - Source version: `agent-device-simulator-ax-v1.5.3` - Source hash: `44e0c10dd5f0bf236c35293999e05d6bfaa740b492a98206da6dc1dec6f7d879` - Cache key: `0c73362db09451e54089e40d42c8f263` -- The live acquisition used the prepared cache entry; deterministic tests cover cold publish, atomic concurrent publish, corrupt-entry rejection, source invalidation, and toolchain invalidation. +- 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 @@ -46,7 +51,8 @@ The returned lineage carried the supplied target id and opaque generation. The s 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 a native-source wire parity fixture. The change is intentionally not split into +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. From b4cb787eca4e644b514874b8276ee752be8ec31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 12:30:16 +0200 Subject: [PATCH 6/9] fix: preserve size report helper on base checkout --- scripts/lib/snapshot-bridge-assets.mjs | 13 ------------- scripts/size-report-install.mjs | 5 +---- scripts/size-report-package.mjs | 18 ++++++++++++++---- 3 files changed, 15 insertions(+), 21 deletions(-) delete mode 100644 scripts/lib/snapshot-bridge-assets.mjs diff --git a/scripts/lib/snapshot-bridge-assets.mjs b/scripts/lib/snapshot-bridge-assets.mjs deleted file mode 100644 index 146ba3bbc..000000000 --- a/scripts/lib/snapshot-bridge-assets.mjs +++ /dev/null @@ -1,13 +0,0 @@ -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(', ')}`); - } -} diff --git a/scripts/size-report-install.mjs b/scripts/size-report-install.mjs index 404c8f6c4..06d2bd199 100644 --- a/scripts/size-report-install.mjs +++ b/scripts/size-report-install.mjs @@ -2,10 +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 './lib/snapshot-bridge-assets.mjs'; +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-')); diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 1408ab8c6..0896fdfdd 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -7,10 +7,20 @@ import { formatMaybeBytes, formatSignedBytes, } from './size-report-format.mjs'; -import { - assertSnapshotBridgeAssets, - SNAPSHOT_BRIDGE_ASSET_PATHS, -} from './lib/snapshot-bridge-assets.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 = [ { From 5b3544864b8b42bc161257ea6caf4f8c3c1dbe21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 12:33:08 +0200 Subject: [PATCH 7/9] fix: allow base packages without snapshot bridge --- scripts/ios-snapshot-benchmark/size-install.test.ts | 4 ++-- scripts/size-report-install.mjs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ios-snapshot-benchmark/size-install.test.ts b/scripts/ios-snapshot-benchmark/size-install.test.ts index 09ac5798d..93bc331a2 100644 --- a/scripts/ios-snapshot-benchmark/size-install.test.ts +++ b/scripts/ios-snapshot-benchmark/size-install.test.ts @@ -13,11 +13,11 @@ test('measures the clean-installed package tree without counting the consumer', assert.deepEqual(measureDirectory(root), { packageBytes: 6, files: 2 }); }); -test('clean-installed snapshot bridge requires all native assets', async () => { +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.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridge\.m/); + 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'); diff --git a/scripts/size-report-install.mjs b/scripts/size-report-install.mjs index 06d2bd199..19894518d 100644 --- a/scripts/size-report-install.mjs +++ b/scripts/size-report-install.mjs @@ -42,6 +42,8 @@ export function measureCleanInstalledPackage(tarballPath, packageName) { } 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)), ); From 7ec3327bf8cfc69d9401811748aea2444d59682f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 13:28:16 +0200 Subject: [PATCH 8/9] fix: close simulator snapshot source ownership gaps --- .github/workflows/ios.yml | 18 +++++ ...os-simulator-snapshot-source-2026-09-04.md | 2 +- packages/platform-apple/package.json | 3 + .../verify-installed-snapshot-bridge.ts | 26 ++++++ .../src/snapshot-source/adapter.test.ts | 1 + .../src/snapshot-source/host.test.ts | 7 +- .../src/snapshot-source/host.ts | 23 +++++- .../src/snapshot-source/lifecycle.test.ts | 79 ++++++++++++++++++- .../src/snapshot-source/lifecycle.ts | 52 ++++++++++-- .../src/snapshot-source/types.ts | 4 + scripts/check-package.ts | 21 +++++ 11 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 packages/platform-apple/scripts/verify-installed-snapshot-bridge.ts diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 88593c2bb..f4deb5579 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -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)" diff --git a/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md index 379fd87be..e9c7a568c 100644 --- a/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md +++ b/docs/evidence/ios-simulator-snapshot-source-2026-09-04.md @@ -59,7 +59,7 @@ 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 validation | +| 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 | diff --git a/packages/platform-apple/package.json b/packages/platform-apple/package.json index 49a35fe88..1e4c3c7c8 100644 --- a/packages/platform-apple/package.json +++ b/packages/platform-apple/package.json @@ -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:*", diff --git a/packages/platform-apple/scripts/verify-installed-snapshot-bridge.ts b/packages/platform-apple/scripts/verify-installed-snapshot-bridge.ts new file mode 100644 index 000000000..6b06cf78b --- /dev/null +++ b/packages/platform-apple/scripts/verify-installed-snapshot-bridge.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 '); +} + +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.'); +} diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index 457b198ec..a46c05a90 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -161,6 +161,7 @@ function createAdapterHost(buildDelayMs = 0): AdapterFixture { }, start: () => new AdapterProcess(), connect: async () => new AdapterSocket(() => fixture.responsePid), + readTargetProcessStartTime: async () => 'target-start', }; fixture.host = host; return fixture; diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index 9d9f4a524..f0e35b44f 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -4,10 +4,13 @@ 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'); - const second = snapshotSourceSocketPath(host, 'simulator-2'); + 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); }); diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index fe62909a7..63f69b391 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -48,9 +48,23 @@ export function createSnapshotSourceHost(): SnapshotSourceHost { emitDiagnostic, withDiagnosticTimer, processId: hostProcessId, + readTargetProcessStartTime, }; } +async function readTargetProcessStartTime( + pid: number, + options: { signal?: AbortSignal; timeoutMs: number }, +): Promise { + 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, @@ -217,11 +231,16 @@ function appendBoundedLog(current: string, addition: string): string { : combined.slice(combined.length - MAX_PROCESS_LOG_BYTES); } -export function snapshotSourceSocketPath(host: SnapshotSourceHost, udid: string): string { +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()}`, + `agent-device-ax-${targetKey}-${host.processId()}-${ownerKey}`, 'snapshot.sock', ); } diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index 6aa1a507c..e273a603f 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -106,6 +106,62 @@ test('cancellation while queued prevents a later dispatch', async () => { 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); @@ -258,6 +314,20 @@ test('the manager rejects a response carrying a previous target generation as st 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); @@ -274,6 +344,7 @@ type LifecycleFixture = { processes: FakeProcess[]; sockets: FakeSocket[]; diagnostics: Array[0]>; + socketPaths: string[]; }; function deadline(signal?: AbortSignal, timeoutMs = limits.maxDurationMs) { @@ -295,16 +366,20 @@ function createLifecycleFixture( responsePid?: number; responseGeneration?: string; responseErrorKind?: string; + targetStartTimes?: Array; } = {}, ): 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), - start: () => { + 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; @@ -336,7 +411,7 @@ function createLifecycleFixture( return socket; }, }; - return { host, processes, sockets, diagnostics }; + return { host, processes, sockets, diagnostics, socketPaths }; } class FakeProcess implements SnapshotSourceProcess { diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 948ee66ec..da293d2ce 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -44,6 +44,7 @@ type SnapshotBridgeRequest = Readonly<{ export class SnapshotBridgeManager { private readonly sessions = new Map(); private readonly requestQueues = new Map>(); + private readonly ownerId = randomUUID(); private closed = false; private readonly host: SnapshotSourceHost; @@ -65,7 +66,10 @@ export class SnapshotBridgeManager { const previousSession = this.sessions.get(input.target.udid); const session = await this.ensureSession(input, deadline); try { - return await this.exchange(session, input, deadline); + 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; @@ -94,15 +98,15 @@ export class SnapshotBridgeManager { deadline: SnapshotSourceDeadline, action: () => Promise, ): Promise { - const previous = this.requestQueues.get(udid); + const previous = this.requestQueues.get(udid) ?? Promise.resolve(); let release!: () => void; - const current = new Promise((resolve) => { + const turnFinished = new Promise((resolve) => { release = resolve; }); + const current = previous.then(() => turnFinished); this.requestQueues.set(udid, current); try { - if (previous) await waitForSimulatorTurn(previous, deadline); - else remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'); + await waitForSimulatorTurn(previous, deadline); return await action(); } finally { release(); @@ -132,7 +136,7 @@ export class SnapshotBridgeManager { } if (existing) await this.removeSession(existing, true); - const socketPath = snapshotSourceSocketPath(this.host, input.target.udid); + 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, { @@ -222,6 +226,42 @@ export class SnapshotBridgeManager { }); } + private async readTargetStartTime( + target: SnapshotSourceTarget, + deadline: SnapshotSourceDeadline, + ): Promise { + 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 { + 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 { if (this.sessions.get(session.udid) === session) this.sessions.delete(session.udid); await this.dispose(session, stopProcess); diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts index 00c5896af..c56711bb5 100644 --- a/packages/platform-apple/src/snapshot-source/types.ts +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -112,6 +112,10 @@ export type SnapshotSourceHost = Readonly<{ data?: Record, ): Promise; processId(): number; + readTargetProcessStartTime( + pid: number, + options: { signal?: AbortSignal; timeoutMs: number }, + ): Promise; }>; export type SnapshotSourceBridgeBinary = Readonly<{ diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 9f929761e..febbf509c 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -35,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)); @@ -179,6 +182,24 @@ try { 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; From e7b32ca5b80b5d68699f7dd8decd06d64589c20a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 13:52:40 +0200 Subject: [PATCH 9/9] docs: explain simulator bridge language choice --- apple/snapshot-bridge/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apple/snapshot-bridge/README.md b/apple/snapshot-bridge/README.md index 5d73929b7..557652c01 100644 --- a/apple/snapshot-bridge/README.md +++ b/apple/snapshot-bridge/README.md @@ -22,3 +22,18 @@ 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.