diff --git a/apple/snapshot-bridge/README.md b/apple/snapshot-bridge/README.md index 589d4973b3..cc29217155 100644 --- a/apple/snapshot-bridge/README.md +++ b/apple/snapshot-bridge/README.md @@ -64,6 +64,23 @@ At each native fragment boundary, an absent or invalid child count means unknown completeness and fails closed. Natural leaves above that boundary need no continuation evidence. Unchanged native dictionaries and child arrays are reused. +## Accepted-depth hints + +The host source (`packages/platform-apple/src/snapshot-source/depth-hints.ts`) +remembers the native levels a finished recovery accepted, keyed by the resolved +target id, its app generation, and this producer. The next capture of that +generation sends `nativeLevelsHint`, so the guest's first request asks for the +accepted levels instead of re-paying the known rejection. A hint changes the +request strategy only: the delivered depth, node bounds, and completeness rules +are unchanged. Hints are learned only from a recovery that observed a rejection +and then finished (a tree bounded by the requested depth or node budget still +teaches), never cross apps, generations, or producers, and expire after eight +hinted captures so the next capture probes the full depth again; a capture that +merely succeeds at the hinted depth does not renew it. Explicit raw-depth +requests neither use nor teach hints. Every response carries `recovery` +(`requests`, `rejected`, `continuations`, `acceptedLevels`), which the host +emits as the `ios_snapshot_source_recovery` diagnostic. + ## Recovery conformance `contracts/fixtures/ios-ax-recovery-conformance.json` is the shared, executable diff --git a/apple/snapshot-bridge/SnapshotBridge.m b/apple/snapshot-bridge/SnapshotBridge.m index 681ae93c80..05a218c0ee 100644 --- a/apple/snapshot-bridge/SnapshotBridge.m +++ b/apple/snapshot-bridge/SnapshotBridge.m @@ -85,6 +85,11 @@ static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, !validBoundInteger(request[@"maxResponseBytes"], 1024, kMaximumFrameBytes, &maxResponseBytes)) { return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"snapshot bridge request bounds are outside the bridge limits"); } + NSUInteger nativeLevelsHint = 0; + if (request[@"nativeLevelsHint"] != nil && + !validBoundInteger(request[@"nativeLevelsHint"], 1, kMaximumDepth + 1, &nativeLevelsHint)) { + return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"nativeLevelsHint is outside the bridge depth limits"); + } NSString *setupError = nil; BridgeRuntime *runtime = sharedRuntime(&setupError); @@ -98,6 +103,7 @@ static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, NSDictionary *response = [runtime snapshotForProcess:pidValue.intValue maxDepth:maxDepth maxNodes:maxNodes + nativeLevelsHint:nativeLevelsHint requestId:requestId generation:generation maxDurationMs:maxDurationMs diff --git a/apple/snapshot-bridge/SnapshotBridgeCapture.h b/apple/snapshot-bridge/SnapshotBridgeCapture.h index 0afaa22f31..6f1cf4a6e5 100644 --- a/apple/snapshot-bridge/SnapshotBridgeCapture.h +++ b/apple/snapshot-bridge/SnapshotBridgeCapture.h @@ -4,8 +4,22 @@ NS_ASSUME_NONNULL_BEGIN typedef id _Nullable (^SnapshotElementReader)(id element, NSUInteger depth, NSUInteger nodes, NSError **error); -/// Materializes one bounded tree; retries bounded native acquisition failures and re-roots withheld children. +/// Native request accounting for one acquisition: requests issued, requests the accessibility +/// server rejected at their depth, continuations (requests that reached the reader beyond the +/// root's accepted one), and the native levels of the last accepted request. +typedef struct { + NSUInteger requests; + NSUInteger rejected; + NSUInteger continuations; + NSUInteger acceptedLevels; +} SnapshotCaptureRecovery; + +/// Materializes one bounded tree; retries bounded native acquisition failures and re-roots withheld +/// children. `nativeLevelsHint` (0 for none) caps only the first request's native levels; the +/// delivered depth, node bounds, and completeness rules are unchanged. NSDictionary *_Nullable captureSnapshotTree(id element, NSUInteger maxDepth, NSUInteger maxNodes, - SnapshotElementReader reader, BOOL *truncated, NSError **error); + NSUInteger nativeLevelsHint, SnapshotElementReader reader, + BOOL *truncated, SnapshotCaptureRecovery *_Nullable recovery, + NSError **error); NS_ASSUME_NONNULL_END diff --git a/apple/snapshot-bridge/SnapshotBridgeCapture.m b/apple/snapshot-bridge/SnapshotBridgeCapture.m index 857b8bc453..650f58d183 100644 --- a/apple/snapshot-bridge/SnapshotBridgeCapture.m +++ b/apple/snapshot-bridge/SnapshotBridgeCapture.m @@ -12,6 +12,7 @@ @interface SnapshotTreeCapture : NSObject @property(nonatomic) NSUInteger remainingNodes; @property(nonatomic) NSUInteger maximumNodes; @property(nonatomic) NSUInteger requests; +@property(nonatomic) NSUInteger rejected; @property(nonatomic) BOOL truncated; - (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSError **)error; - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth nativeLevels:(NSUInteger)nativeLevels error:(NSError **)error; @@ -34,6 +35,7 @@ - (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSErr NSNumber *nativeCode = failure.userInfo[@"accessibility-error"]; BOOL rejected = ([nativeCode isKindOfClass:NSNumber.class] && nativeCode.integerValue == -25201) || ([failure.domain isEqualToString:@"com.apple.dt.xctest.automation-support.error"] && failure.code == 5); + if (rejected) self.rejected++; if (!rejected || attemptDepth <= 1 || retries >= 2) { if (error) *error = failure; return nil; @@ -115,15 +117,22 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de @end NSDictionary *captureSnapshotTree(id element, NSUInteger maxDepth, NSUInteger maxNodes, - SnapshotElementReader reader, BOOL *truncated, NSError **error) + NSUInteger nativeLevelsHint, SnapshotElementReader reader, + BOOL *truncated, SnapshotCaptureRecovery *recovery, NSError **error) { SnapshotTreeCapture *capture = [SnapshotTreeCapture new]; capture.reader = reader; - capture.acceptedDepth = maxDepth + 1; + capture.acceptedDepth = nativeLevelsHint > 0 ? MIN(maxDepth + 1, nativeLevelsHint) : maxDepth + 1; capture.remainingNodes = maxNodes; capture.maximumNodes = maxNodes; NSDictionary *tree = [capture read:element depth:maxDepth + 1 error:error]; NSDictionary *result = tree ? [capture materialize:tree depth:maxDepth + 1 nativeLevels:capture.acceptedDepth error:error] : nil; *truncated = capture.truncated; + if (recovery) { + recovery->requests = capture.requests; + recovery->rejected = capture.rejected; + recovery->continuations = capture.requests - capture.rejected - (tree ? 1 : 0); + recovery->acceptedLevels = tree ? capture.acceptedDepth : 0; + } return result; } diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.h b/apple/snapshot-bridge/SnapshotBridgeRuntime.h index 9a2bebf864..d5085513c9 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.h +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.h @@ -22,6 +22,7 @@ NSDictionary *failureResponse(NSString *requestId, - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid maxDepth:(NSUInteger)maxDepth maxNodes:(NSUInteger)maxNodes + nativeLevelsHint:(NSUInteger)nativeLevelsHint requestId:(NSString *)requestId generation:(NSString *)generation maxDurationMs:(NSUInteger)maxDurationMs diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index 2d27591d55..f384eeb31c 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -18,7 +18,7 @@ NSString *const kProtocolVersionKey = @"protocolVersion"; NSString *const kSourceVersionKey = @"sourceVersion"; NSString *const kRequestIdKey = @"requestId"; -NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.4"; +NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.5"; const NSUInteger kProtocolVersion = 1; const uint32_t kMaximumFrameBytes = 16 * 1024 * 1024; const NSUInteger kMaximumDepth = 128; @@ -262,6 +262,7 @@ - (nullable NSDictionary *)nodeFromSnapshot:(id)snapshot - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid maxDepth:(NSUInteger)maxDepth maxNodes:(NSUInteger)maxNodes + nativeLevelsHint:(NSUInteger)nativeLevelsHint requestId:(NSString *)requestId generation:(NSString *)generation maxDurationMs:(NSUInteger)maxDurationMs @@ -313,13 +314,14 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid NSError *runtimeError = nil; id snapshot = nil; BOOL acquisitionTruncated = NO; + SnapshotCaptureRecovery recovery = {0, 0, 0, 0}; @try { if (![self isPrimaryForegroundProcess:pid]) { if (error) *error = failureResponse(requestId, @"unsupported", @"foreground-owner-unverified", @"target app is not the primary foreground accessibility owner"); finishRequestWatchdog(watchdog, watchdogState); return nil; } - snapshot = captureSnapshotTree((__bridge id)raw, maxDepth, maxNodes, + snapshot = captureSnapshotTree((__bridge id)raw, maxDepth, maxNodes, nativeLevelsHint, ^id(id element, NSUInteger depth, NSUInteger nodes, NSError **captureError) { if (![self isPrimaryForegroundProcess:pid]) { if (captureError) *captureError = [NSError errorWithDomain:@"agent-device.snapshot" code:5 @@ -331,7 +333,7 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid bounded[@"maxChildren"] = @(nodes); bounded[@"maxArrayCount"] = @(nodes); return [_framework userTestingSnapshotForElement:element options:bounded error:captureError]; - }, &acquisitionTruncated, &runtimeError); + }, &acquisitionTruncated, &recovery, &runtimeError); if (![self isPrimaryForegroundProcess:pid]) { if (error) *error = failureResponse(requestId, @"unsupported", @"foreground-owner-changed", @"foreground accessibility ownership changed during acquisition"); finishRequestWatchdog(watchdog, watchdogState); @@ -385,6 +387,12 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid @"tree" : tree, @"truncated" : @((BOOL)(truncated || acquisitionTruncated)), @"automationEnabled" : @(automationEnabled), + @"recovery" : @{ + @"requests" : @(recovery.requests), + @"rejected" : @(recovery.rejected), + @"continuations" : @(recovery.continuations), + @"acceptedLevels" : @(recovery.acceptedLevels), + }, }; } @end diff --git a/contracts/fixtures/ios-ax-recovery-conformance.json b/contracts/fixtures/ios-ax-recovery-conformance.json index 212c2c28f5..d761419b2c 100644 --- a/contracts/fixtures/ios-ax-recovery-conformance.json +++ b/contracts/fixtures/ios-ax-recovery-conformance.json @@ -14,13 +14,16 @@ "producers": { "host-bridge": { "recoveryOwner": "apple/snapshot-bridge/SnapshotBridgeCapture.m", - "hintOwner": "not yet: the host learns no accepted-depth hint (follow-up to #2424 step 1)", - "adapters": ["packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m"], + "hintOwner": "packages/platform-apple/src/snapshot-source/depth-hints.ts", + "adapters": [ + "packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m", + "packages/platform-apple/src/snapshot-source/adapter.test.ts (hint cases through the source adapter)" + ], "nativeLevelsForTraversalDepth": "depth + 1 (delivers exactly `depth` edges below the root)", "frontierEvidence": "native child count at every fragment boundary; an absent or invalid count fails closed", - "hintIdentity": "none yet; every capture starts at the requested depth", - "hintExpiry": "n/a", - "hintLearning": "n/a" + "hintIdentity": "targetId + generation + producer, owned by the host source", + "hintExpiry": "count-based probe-back after 8 hinted captures; hinted successes never renew", + "hintLearning": "any recovery that observed a rejection and then finished; a depth- or node-bounded tree still teaches" }, "runner": { "recoveryOwner": "apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m and RunnerTests+AXSnapshotFallback.swift", @@ -42,7 +45,7 @@ "Delivered depth: host continuations keep the requested traversal depth and disclose deeper content as truncated; runner extension re-roots with the same native depth, so the delivered tree can exceed the requested depth and an explicit depth is not disclosed as truncated (deeper-than-requested, explicit-depth-honored).", "Budgets: the host spends one continuation per withheld parent inside a 32-request budget; the runner spends one extension call per frontier node inside an 8-call budget (host-request-budget, runner-extension-budget).", "Ownership and deadlines: only the host checks the foreground owner on every native request; only the runner consults the capture deadline between rungs and extension calls (owner-change-during-continuation, deadline-before-retry).", - "Hints: the runner remembers any lower rung per bundle id + pid with a wall-clock expiry that hinted successes never renew; the host bridge has no accepted-depth hint yet, so the hinted recovery cases and the host column of the hint cases are filled in by the follow-up that adds one." + "Hints: both producers learn from any finished lower-depth recovery, bounded trees included (bounded-recovery-still-learned); the host keys on the resolved target generation and expires by hinted-capture count, while the runner keys on bundle id + pid and expires by wall clock (probe-back-after-expiry)." ], "recoveryCases": [ { @@ -505,7 +508,8 @@ "explicitDepth": false, "nodeBudget": 1500, "hint": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } }, "native": { @@ -517,8 +521,13 @@ }, "expected": { "host-bridge": { - "outcome": "not-applicable", - "reason": "the host bridge has no accepted-depth hint yet" + "outcome": "complete", + "requests": 2, + "rejected": 0, + "continuations": 1, + "deepestLevel": 44, + "tree": "0-44", + "nodes": 45 }, "runner": { "outcome": "complete", @@ -538,7 +547,8 @@ "explicitDepth": false, "nodeBudget": 1500, "hint": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } }, "native": { @@ -550,8 +560,13 @@ }, "expected": { "host-bridge": { - "outcome": "not-applicable", - "reason": "the host bridge has no accepted-depth hint yet" + "outcome": "complete", + "requests": 4, + "rejected": 1, + "continuations": 2, + "deepestLevel": 44, + "tree": "0-44", + "nodes": 45 }, "runner": { "outcome": "complete", @@ -576,19 +591,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 2 + "runner": 2, + "host-bridge": 1 }, "acceptedLevels": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "complete": true }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } }, { @@ -598,19 +617,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "outcome": { "rejected": { - "runner": 0 + "runner": 0, + "host-bridge": 0 }, "acceptedLevels": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "complete": true }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "expectRenewed": false } @@ -626,19 +649,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 2 + "runner": 2, + "host-bridge": 1 }, "acceptedLevels": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "complete": true }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } }, { @@ -648,19 +675,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 0 + "runner": 0, + "host-bridge": 0 }, "acceptedLevels": { - "runner": 64 + "runner": 64, + "host-bridge": 65 }, "complete": true }, "expectHintAfter": { - "runner": null + "runner": null, + "host-bridge": null } } ] @@ -675,19 +706,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 2 + "runner": 2, + "host-bridge": 1 }, "acceptedLevels": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "complete": true }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } }, { @@ -697,19 +732,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 0 + "runner": 0, + "host-bridge": 0 }, "acceptedLevels": { - "runner": 64 + "runner": 64, + "host-bridge": 65 }, "complete": true }, "expectHintAfter": { - "runner": null + "runner": null, + "host-bridge": null } } ] @@ -724,19 +763,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 2 + "runner": 2, + "host-bridge": 1 }, "acceptedLevels": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "complete": true }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } }, { @@ -749,19 +792,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 0 + "runner": 0, + "host-bridge": 0 }, "acceptedLevels": { - "runner": 64 + "runner": 64, + "host-bridge": 65 }, "complete": true }, "expectHintAfter": { - "runner": null + "runner": null, + "host-bridge": null } } ] @@ -776,19 +823,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 2 + "runner": 2, + "host-bridge": 1 }, "acceptedLevels": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "complete": true }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } }, { @@ -801,19 +852,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 3 + "runner": 3, + "host-bridge": 2 }, "acceptedLevels": { - "runner": 24 + "runner": 24, + "host-bridge": 16 }, "complete": true }, "expectHintAfter": { - "runner": 24 + "runner": 24, + "host-bridge": 16 } } ] @@ -828,19 +883,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 2 + "runner": 2, + "host-bridge": 1 }, "acceptedLevels": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "complete": false }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } } ] @@ -855,19 +914,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 2 + "runner": 2, + "host-bridge": 1 }, "acceptedLevels": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "complete": true }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } }, { @@ -877,19 +940,23 @@ }, "explicitDepth": true, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 3 + "runner": 3, + "host-bridge": 2 }, "acceptedLevels": { - "runner": 24 + "runner": 24, + "host-bridge": 16 }, "complete": true }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } } ] @@ -904,19 +971,23 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": null + "runner": null, + "host-bridge": null }, "outcome": { "rejected": { - "runner": 2 + "runner": 2, + "host-bridge": 1 }, "acceptedLevels": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "complete": true }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } }, { @@ -926,16 +997,19 @@ }, "explicitDepth": false, "expectHintBefore": { - "runner": 40 + "runner": 40, + "host-bridge": 32 }, "outcome": { "failure": "rejected", "rejected": { - "runner": 3 + "runner": 3, + "host-bridge": 3 } }, "expectHintAfter": { - "runner": 40 + "runner": 40, + "host-bridge": 32 } } ] diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 6a9c695c43..65a976138f 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -181,6 +181,12 @@ records per-producer expectations plus the intentional differences, so a change path is measured against the same synthetic native world. Common executable policy is extracted only where the fixture proves equivalence; a shared engine is not a goal. +The host source additionally keeps a bounded accepted-depth hint per resolved target generation +and producer. It changes only the native levels the first request asks for, is learned only from a +finished recovery that observed a rejection, expires by hinted-capture count so ordinary screens +probe back to the full depth, and is never shared across apps, generations, or producers. The +route's generation circuit remains the only lifecycle owner. + ## Consequences Regular snapshots remain the right tool for agents and Maestro compatibility because they describe diff --git a/examples/test-app/app/_layout.tsx b/examples/test-app/app/_layout.tsx index d49b817552..81369b6e9e 100644 --- a/examples/test-app/app/_layout.tsx +++ b/examples/test-app/app/_layout.tsx @@ -27,6 +27,7 @@ function RootLayoutContent() { + diff --git a/examples/test-app/app/deep-tree.tsx b/examples/test-app/app/deep-tree.tsx new file mode 100644 index 0000000000..436c5e9603 --- /dev/null +++ b/examples/test-app/app/deep-tree.tsx @@ -0,0 +1,5 @@ +import { DeepTreeScreen } from '../src/screens/DeepTreeScreen'; + +export default function DeepTreeRoute() { + return ; +} diff --git a/examples/test-app/src/screens/DeepTreeScreen.tsx b/examples/test-app/src/screens/DeepTreeScreen.tsx new file mode 100644 index 0000000000..c892c08c7d --- /dev/null +++ b/examples/test-app/src/screens/DeepTreeScreen.tsx @@ -0,0 +1,97 @@ +import { useLocalSearchParams } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +import { useAppColors, type AppColors } from '../theme'; + +/** + * A synthetic deep accessibility tree for host AX bridge recovery benchmarks: `chain` nested + * views deep, with `width` labelled siblings at every level. Deep React Native screens make the + * accessibility server reject bulk snapshot requests above a size-dependent depth, and this screen + * reproduces that shape without a third-party app. Every level opts out of view flattening, so the + * native hierarchy is as deep as the React tree. + */ +export function DeepTreeScreen() { + const colors = useAppColors(); + const styles = createStyles(colors); + const params = useLocalSearchParams<{ chain?: string; width?: string }>(); + const chain = clampInteger(params.chain, 80, 1, 200); + const width = clampInteger(params.width, 1, 0, 8); + + return ( + + + Deep tree {chain}x{width} + + + + ); +} + +function DeepLevel({ + level, + chain, + width, + styles, +}: { + level: number; + chain: number; + width: number; + styles: ReturnType; +}) { + if (level >= chain) { + return ( + + Leaf at level {level} + + ); + } + return ( + + {Array.from({ length: width }, (_, index) => ( + + Row {level}.{index} + + ))} + + + ); +} + +function clampInteger(value: string | undefined, fallback: number, min: number, max: number) { + const parsed = Number.parseInt(value ?? '', 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + frame: { + flex: 1, + paddingHorizontal: 12, + paddingTop: 24, + }, + title: { + color: colors.text, + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + level: { + paddingLeft: 1, + }, + sibling: { + color: colors.textSoft, + fontSize: 9, + lineHeight: 10, + }, + leaf: { + color: colors.accent, + fontSize: 12, + }, + }); +} diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index 7e5de38c62..7c8d2f66fd 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import { readFileSync } from 'node:fs'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -10,6 +11,7 @@ import { } from '@agent-device/capture-kit/ios-snapshot-planning'; import { createSnapshotSourceHost } from './host.ts'; import { createSimulatorSnapshotSource } from './adapter.ts'; +import { DEPTH_HINT_PROBE_BACK_AFTER_USES } from './depth-hints.ts'; import { encodeSnapshotBridgeFrame, SNAPSHOT_SOURCE_PROTOCOL_VERSION, @@ -126,12 +128,253 @@ test('preparation consumes the same acquisition deadline as bridge I/O', async ( } }); +test('the Simulator AX source learns a hint only from a validated acquisition', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-adapter-hints-')); + const sourceRoot = path.join(root, 'source'); + await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot); + for (const name of [ + 'SnapshotBridge.m', + 'SnapshotBridgeRuntime.m', + 'SnapshotBridgeRuntime.h', + 'SnapshotBridgeCapture.h', + 'SnapshotBridgeCapture.m', + ]) { + await writeFile(path.join(sourceRoot, name), 'native source'); + } + const fixture = createAdapterHost(); + fixture.rejectLevelsAbove = 4; + const source = createSimulatorSnapshotSource({ + host: fixture.host, + sourceRoot, + cacheRoot: path.join(root, 'cache'), + limits: { maxNodes: 20, maxTraversalDepth: 10, maxDurationMs: 1000 }, + }); + const hint = deriveIosCaptureHint(createIosSnapshotRequest()); + const target = { ...targetForTest(), targetId: 'target-1', generation: 'generation-1' }; + + try { + // A response with sound counters but an unusable tree fails acquisition and teaches nothing: + // the next request of the same generation still asks for the full depth. + fixture.malformedTree = true; + const unusable = await source.acquire({ target, hint }); + assert.equal(unusable.stage, 'failed'); + if (unusable.stage === 'failed') assert.equal(unusable.failure.kind, 'malformed-tree'); + assert.equal(fixture.diagnostics.length, 0); + fixture.malformedTree = false; + + // The first validated capture pays the rejection and learns; the next one sends the levels. + assert.equal((await source.acquire({ target, hint })).stage, 'acquired'); + assert.equal((await source.acquire({ target, hint })).stage, 'acquired'); + assert.deepEqual(fixture.requestedHints, [undefined, undefined, 2]); + assert.deepEqual( + fixture.diagnostics.map((event) => [event.hint, event.rejected, event.learning]), + [ + ['no-hint', 2, 'learned'], + ['hinted', 0, 'kept'], + ], + ); + + // A guest that stops reporting its request accounting is a malformed producer. + fixture.omitRecovery = true; + const unaccounted = await source.acquire({ target, hint }); + assert.equal(unaccounted.stage, 'failed'); + if (unaccounted.stage === 'failed') { + assert.deepEqual( + [unaccounted.failure.kind, unaccounted.failure.code], + ['malformed-tree', 'recovery-invalid'], + ); + } + } finally { + await source.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +type HintStep = Readonly<{ + expire?: boolean; + target?: Readonly<{ id: string; generation: string }>; + explicitDepth?: boolean; + expectHintBefore?: Readonly>; + outcome?: Readonly<{ + failure?: string; + rejected: Readonly>; + acceptedLevels?: Readonly>; + complete?: boolean; + }>; + expectHintAfter?: Readonly>; + expectRenewed?: boolean; +}>; + +const recoveryFixture = JSON.parse( + readFileSync( + path.resolve( + import.meta.dirname, + '../../../../contracts/fixtures/ios-ax-recovery-conformance.json', + ), + 'utf8', + ), +) as { version: number; hintCases: readonly { name: string; steps: readonly HintStep[] }[] }; + +type HintTarget = Readonly<{ targetId: string; generation: string }>; + +/** + * One hint case replayed through the source adapter: each step is one acquisition whose guest + * rejects above the step's accepted levels and reports boundedness, and the hint the next request + * carries is the observation. `expire` spends the count-based hint lifetime. + */ +class HintCaseReplay { + private hintedUses = 0; + private lastTarget: HintTarget | undefined; + private readonly fixture: AdapterFixture; + private readonly source: ReturnType; + private readonly name: string; + + constructor( + name: string, + fixture: AdapterFixture, + source: ReturnType, + ) { + this.name = name; + this.fixture = fixture; + this.source = source; + } + + async step(step: HintStep): Promise { + if (step.expire) { + await this.exhaust(this.lastTarget); + return; + } + const target = { targetId: step.target!.id, generation: step.target!.generation }; + this.lastTarget = target; + const outcome = step.outcome!; + // A failing step is rejected at every depth; the guest gives up after its halving budget. + this.fixture.rejectLevelsAbove = outcome.failure ? 0 : outcome.acceptedLevels!['host-bridge']; + this.fixture.truncated = outcome.complete === false; + const observed = await this.capture( + target, + step.explicitDepth ? explicitRawHint : regularHint, + outcome.failure ? 'failed' : 'acquired', + ); + assert.equal(observed.hint, expectedHint(step.expectHintBefore), `${this.name}: before`); + this.assertObservedOutcome(observed.diagnostic, outcome); + // The next capture of the same generation observes what the step taught. + this.fixture.rejectLevelsAbove = undefined; + const after = await this.capture(target); + assert.equal(after.hint, expectedHint(step.expectHintAfter), `${this.name}: after`); + if (step.expectRenewed === false) await this.assertNotRenewed(target); + } + + /** A failed capture reports nothing; a validated one reports what the guest observed. */ + private assertObservedOutcome( + diagnostic: Record | undefined, + outcome: NonNullable, + ): void { + if (outcome.failure) { + assert.equal(diagnostic, undefined, `${this.name}: a failed capture reports nothing`); + return; + } + assert.equal(diagnostic?.rejected, outcome.rejected['host-bridge'], `${this.name}: rejected`); + assert.equal( + diagnostic?.acceptedLevels, + outcome.acceptedLevels!['host-bridge'], + `${this.name}: accepted`, + ); + assert.equal(diagnostic?.truncated, outcome.complete === false, `${this.name}: bounded`); + } + + private async assertNotRenewed(target: HintTarget): Promise { + const remaining = DEPTH_HINT_PROBE_BACK_AFTER_USES - this.hintedUses; + assert.equal( + await this.exhaust(target), + remaining, + `${this.name}: a hinted success must not renew the hint`, + ); + } + + private async capture( + target: HintTarget | undefined, + hint = regularHint, + expectedStage: 'acquired' | 'failed' = 'acquired', + ) { + assert.ok(target, `${this.name}: a capture needs a target`); + const diagnosticsBefore = this.fixture.diagnostics.length; + const outcome = await this.source.acquire({ target: { ...targetForTest(), ...target }, hint }); + assert.equal(outcome.stage, expectedStage, this.name); + const sent = this.fixture.requestedHints.at(-1); + if (sent !== undefined) this.hintedUses += 1; + const diagnostic = + this.fixture.diagnostics.length > diagnosticsBefore + ? this.fixture.diagnostics.at(-1) + : undefined; + return { hint: sent, diagnostic }; + } + + /** Hinted captures until the owner probes back; returns how many hinted uses that spent. */ + private async exhaust(target: HintTarget | undefined): Promise { + const before = this.hintedUses; + while ((await this.capture(target)).hint !== undefined) { + assert.ok( + this.hintedUses <= DEPTH_HINT_PROBE_BACK_AFTER_USES, + `${this.name}: hints must expire`, + ); + } + return this.hintedUses - before; + } +} + +const regularHint = deriveIosCaptureHint(createIosSnapshotRequest()); +const explicitRawHint = deriveIosCaptureHint(createIosSnapshotRequest({ raw: true, depth: 64 })); + +function expectedHint(hints: Readonly> | undefined) { + return hints?.['host-bridge'] ?? undefined; +} + +test('the Simulator AX source follows the shared hint contract', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-adapter-contract-')); + const sourceRoot = path.join(root, 'source'); + await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot); + for (const name of [ + 'SnapshotBridge.m', + 'SnapshotBridgeRuntime.m', + 'SnapshotBridgeRuntime.h', + 'SnapshotBridgeCapture.h', + 'SnapshotBridgeCapture.m', + ]) { + await writeFile(path.join(sourceRoot, name), 'native source'); + } + assert.equal(recoveryFixture.version, 1); + try { + for (const hintCase of recoveryFixture.hintCases) { + const fixture = createAdapterHost(); + const source = createSimulatorSnapshotSource({ + host: fixture.host, + sourceRoot, + cacheRoot: path.join(root, 'cache', hintCase.name), + }); + const replay = new HintCaseReplay(hintCase.name, fixture, source); + for (const step of hintCase.steps) await replay.step(step); + await source.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + type AdapterFixture = { host: SnapshotSourceHost; builds: number; runs: number; responsePid: number; requestedDepths: number[]; + requestedHints: (number | undefined)[]; + /** Native levels above which the fake guest reports a rejection-then-recovery. */ + rejectLevelsAbove: number | undefined; + /** Whether the fake guest reports a depth- or node-bounded tree. */ + truncated: boolean; + /** Whether the fake guest answers with sound counters but an unusable tree. */ + malformedTree: boolean; + omitRecovery: boolean; + diagnostics: Record[]; }; function targetForTest() { @@ -150,9 +393,19 @@ function createAdapterHost(buildDelayMs = 0): AdapterFixture { runs: 0, responsePid: 321, requestedDepths: [], + requestedHints: [], + rejectLevelsAbove: undefined, + truncated: false, + malformedTree: false, + omitRecovery: false, + diagnostics: [], }; const host: SnapshotSourceHost = { ...realHost, + emitDiagnostic: (event) => { + if (event.phase === 'ios_snapshot_source_recovery') + fixture.diagnostics.push(event.data ?? {}); + }, run: async (command, args) => { fixture.runs += 1; if (command === 'xcrun' && args.includes('clang')) { @@ -175,11 +428,7 @@ function createAdapterHost(buildDelayMs = 0): AdapterFixture { }; }, start: () => new AdapterProcess(), - connect: async () => - new AdapterSocket( - () => fixture.responsePid, - (depth) => fixture.requestedDepths.push(depth), - ), + connect: async () => new AdapterSocket(fixture), readTargetProcessStartTime: async () => 'target-start', }; fixture.host = host; @@ -214,13 +463,11 @@ class AdapterProcess implements SnapshotSourceProcess { class AdapterSocket extends EventEmitter implements SnapshotSourceSocket { destroyed = false; - private readonly readResponsePid: () => number; - private readonly recordMaxDepth: (depth: number) => void; + private readonly fixture: AdapterFixture; - constructor(responsePid: () => number, recordMaxDepth: (depth: number) => void) { + constructor(fixture: AdapterFixture) { super(); - this.readResponsePid = responsePid; - this.recordMaxDepth = recordMaxDepth; + this.fixture = fixture; } write(frame: Buffer): boolean { @@ -230,10 +477,41 @@ class AdapterSocket extends EventEmitter implements SnapshotSourceSocket { pid: number; generation: string; maxDepth: number; + nativeLevelsHint?: number; }; - this.recordMaxDepth(request.maxDepth); + this.fixture.requestedDepths.push(request.maxDepth); + this.fixture.requestedHints.push(request.nativeLevelsHint); + // The guest's own recovery: halve a rejected depth at most twice, like SnapshotBridgeCapture. + const requestedLevels = request.nativeLevelsHint ?? request.maxDepth + 1; + const rejectAbove = this.fixture.rejectLevelsAbove; + let acceptedLevels = requestedLevels; + let rejected = 0; + while (rejectAbove !== undefined && acceptedLevels > rejectAbove && rejected < 3) { + rejected += 1; + acceptedLevels = Math.max(1, Math.floor(acceptedLevels / 2)); + } + const rejectedEverywhere = rejectAbove !== undefined && acceptedLevels > rejectAbove; queueMicrotask(() => { if (this.destroyed) return; + if (rejectedEverywhere) { + this.emit( + 'data', + encodeSnapshotBridgeFrame( + { + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + requestId: request.requestId, + ok: false, + pid: this.fixture.responsePid, + generation: request.generation, + error_kind: 'application_unavailable', + error_code: 'application-server-unavailable', + }, + { maxRequestBytes: 64 * 1024 }, + ), + ); + return; + } this.emit( 'data', encodeSnapshotBridgeFrame( @@ -242,18 +520,30 @@ class AdapterSocket extends EventEmitter implements SnapshotSourceSocket { sourceVersion: SNAPSHOT_SOURCE_VERSION, requestId: request.requestId, ok: true, - pid: this.readResponsePid(), + pid: this.fixture.responsePid, generation: request.generation, - truncated: request.maxDepth === 1, + truncated: request.maxDepth === 1 || this.fixture.truncated, automationEnabled: true, - tree: { - XC_kAXXCAttributeElementType: 'Application', - XC_kAXXCAttributeFrame: { X: 0, Y: 0, Width: 390, Height: 844 }, - XC_kAXXCAttributeChildren: - request.maxDepth === 1 - ? [{ XC_kAXXCAttributeElementType: 'Button', XC_kAXXCAttributeChildren: [] }] - : [], - }, + ...(this.fixture.omitRecovery + ? {} + : { + recovery: { + requests: 1 + rejected, + rejected, + continuations: 0, + acceptedLevels, + }, + }), + tree: this.fixture.malformedTree + ? null + : { + XC_kAXXCAttributeElementType: 'Application', + XC_kAXXCAttributeFrame: { X: 0, Y: 0, Width: 390, Height: 844 }, + XC_kAXXCAttributeChildren: + request.maxDepth === 1 + ? [{ XC_kAXXCAttributeElementType: 'Button', XC_kAXXCAttributeChildren: [] }] + : [], + }, }, { maxRequestBytes: 64 * 1024, diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts index 4ebec49b8b..297e5441b8 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -2,10 +2,11 @@ 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 { AcceptedDepthHints, type DepthHintDecision } from './depth-hints.ts'; import { asSnapshotSourceError, snapshotSourceError } from './errors.ts'; import { SnapshotBridgeManager } from './lifecycle.ts'; import { resolveSnapshotSourceLimits } from './limits.ts'; -import type { SnapshotBridgeEnvelope } from './protocol.ts'; +import { readSnapshotBridgeRecovery, type SnapshotBridgeEnvelope } from './protocol.ts'; import { decodeSnapshotBridgeTree } from './tree.ts'; import { createSnapshotSourceHost } from './host.ts'; import type { @@ -35,6 +36,7 @@ export function createSimulatorSnapshotSource( ): SimulatorSnapshotSource { const host = options.host ?? createSnapshotSourceHost(); const manager = new SnapshotBridgeManager(host); + const depthHints = new AcceptedDepthHints(); const preparedBinaries = new Map(); let closed = false; @@ -72,6 +74,8 @@ export function createSimulatorSnapshotSource( const limits = resolveSnapshotSourceLimits({ ...options.limits, ...request.limits }); const deadline = createSnapshotSourceDeadline(limits.maxDurationMs, request.signal); const maxDepth = resolveRequestedDepth(request.hint, limits.maxTraversalDepth); + const requestedLevels = maxDepth + 1; + const explicitDepth = request.hint.rawTraversalDepth !== null; return await host.withDiagnosticTimer( 'ios.snapshot-source.acquire', async () => { @@ -80,24 +84,33 @@ export function createSimulatorSnapshotSource( limits, deadline, }); + const decision = depthHints.consume(request.target, requestedLevels, explicitDepth); const envelope = await manager.request({ target: request.target, bridge, limits, maxDepth, + nativeLevelsHint: decision.nativeLevels, deadline, }); remainingSnapshotSourceMs(deadline, 'snapshot-decode-deadline'); - return { - stage: 'acquired', - acquisition: createAcquisition( - request.hint, - request.target, - envelope, - limits, - maxDepth, - ), - }; + const acquisition = createAcquisition( + request.hint, + request.target, + envelope, + limits, + maxDepth, + ); + recordRecovery( + host, + depthHints, + request.target, + requestedLevels, + explicitDepth, + decision, + envelope, + ); + return { stage: 'acquired', acquisition }; }, { producer: SNAPSHOT_SOURCE_PRODUCER }, ); @@ -148,6 +161,41 @@ function validateRequest(request: SnapshotSourceRequest): void { } } +/** + * Learns the accepted native depth from the guest's request accounting and reports the whole + * acquisition's native work, so a benchmark can pair native calls, rejections, and continuations + * with capture latency without re-deriving them from the tree. Runs only after the delivered tree + * validated: a response whose counters look sound but whose tree fails acquisition teaches nothing. + */ +function recordRecovery( + host: SnapshotSourceHost, + depthHints: AcceptedDepthHints, + target: SnapshotSourceRequest['target'], + requestedLevels: number, + explicitDepth: boolean, + decision: DepthHintDecision, + envelope: SnapshotBridgeEnvelope, +): void { + const recovery = readSnapshotBridgeRecovery(envelope); + const truncated = envelope.truncated === true; + const learning = depthHints.learn(target, requestedLevels, explicitDepth, recovery); + host.emitDiagnostic({ + level: 'debug', + phase: 'ios_snapshot_source_recovery', + data: { + producer: SNAPSHOT_SOURCE_PRODUCER, + ...(target.targetId ? { targetId: target.targetId } : {}), + generation: target.generation, + requestedLevels, + hint: decision.reason, + ...(decision.nativeLevels !== undefined ? { hintedLevels: decision.nativeLevels } : {}), + ...recovery, + truncated, + learning, + }, + }); +} + function resolveRequestedDepth(hint: CaptureHint, maximum: number): number { const requested = hint.rawTraversalDepth ?? maximum; if (requested > maximum) { diff --git a/packages/platform-apple/src/snapshot-source/depth-hints.test.ts b/packages/platform-apple/src/snapshot-source/depth-hints.test.ts new file mode 100644 index 0000000000..1feb0745cc --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/depth-hints.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AcceptedDepthHints } from './depth-hints.ts'; + +test('hints are bounded per target and a probe that recovers relearns fresh uses', () => { + const hints = new AcceptedDepthHints(2); + const target = { targetId: 'app', generation: 'g1' }; + const recovered = { requests: 2, rejected: 1, continuations: 1, acceptedLevels: 32 }; + assert.equal(hints.learn(target, 65, false, recovered), 'learned'); + assert.equal(hints.consume(target, 65, false).reason, 'hinted'); + assert.equal(hints.consume(target, 65, false).reason, 'hinted'); + assert.equal(hints.consume(target, 65, false).reason, 'probe-back'); + assert.equal(hints.learn(target, 65, false, recovered), 'learned'); + assert.equal(hints.consume(target, 65, false).reason, 'hinted'); + + // A hint never applies at or above the requested levels, and unidentified targets get none. + assert.equal(hints.consume(target, 32, false).reason, 'no-hint'); + assert.equal(hints.consume({ generation: 'g1' }, 65, false).reason, 'unidentified-target'); + assert.equal(hints.learn({ generation: 'g1' }, 65, false, recovered), 'ignored'); + + // Only the latest 32 targets are tracked; the oldest entry is evicted first. + for (let index = 0; index < 32; index += 1) { + hints.learn({ targetId: `other-${index}`, generation: 'g1' }, 65, false, recovered); + } + assert.equal(hints.consume(target, 65, false).reason, 'no-hint'); + assert.equal( + hints.consume({ targetId: 'other-31', generation: 'g1' }, 65, false).reason, + 'hinted', + ); +}); diff --git a/packages/platform-apple/src/snapshot-source/depth-hints.ts b/packages/platform-apple/src/snapshot-source/depth-hints.ts new file mode 100644 index 0000000000..23b48016fd --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/depth-hints.ts @@ -0,0 +1,103 @@ +import type { SnapshotBridgeRecovery } from './protocol.ts'; +import type { SnapshotSourceTarget } from './types.ts'; + +/** + * Hinted captures allowed before the owner probes the full requested depth again. A hint is not + * renewed by captures that merely succeed at the hinted depth, so a screen that regains deep + * capture ability is rediscovered by the probe rather than capped forever. + */ +export const DEPTH_HINT_PROBE_BACK_AFTER_USES = 8; +const MAX_TRACKED_TARGETS = 32; + +export type DepthHintReason = + | 'hinted' + | 'probe-back' + | 'no-hint' + | 'explicit-depth' + | 'unidentified-target'; + +export type DepthHintDecision = Readonly<{ + nativeLevels: number | undefined; + reason: DepthHintReason; +}>; + +export type DepthHintLearning = 'learned' | 'forgotten' | 'kept' | 'ignored'; + +type HintEntry = { + generation: string; + nativeLevels: number; + remainingUses: number; +}; + +type HintTarget = Pick; + +/** + * Accepted native depth per resolved app generation for one producer. A hint only changes which + * native levels the first request asks for; the guest still delivers the requested traversal depth + * through continuations. Hints never cross apps, generations, or producers, and are learned only + * from a recovery that observed a rejection and then finished: the delivered tree may still be + * bounded by the requested depth or node budget, which is not a recovery failure. + */ +export class AcceptedDepthHints { + private readonly entries = new Map(); + private readonly probeBackAfterUses: number; + + constructor(probeBackAfterUses = DEPTH_HINT_PROBE_BACK_AFTER_USES) { + this.probeBackAfterUses = probeBackAfterUses; + } + + consume(target: HintTarget, requestedLevels: number, explicitDepth: boolean): DepthHintDecision { + if (explicitDepth) return { nativeLevels: undefined, reason: 'explicit-depth' }; + const entry = this.currentEntry(target); + if (entry === 'unidentified') return { nativeLevels: undefined, reason: 'unidentified-target' }; + if (!entry || entry.nativeLevels >= requestedLevels) { + return { nativeLevels: undefined, reason: 'no-hint' }; + } + if (entry.remainingUses <= 0) return { nativeLevels: undefined, reason: 'probe-back' }; + entry.remainingUses -= 1; + return { nativeLevels: entry.nativeLevels, reason: 'hinted' }; + } + + learn( + target: HintTarget, + requestedLevels: number, + explicitDepth: boolean, + recovery: SnapshotBridgeRecovery, + ): DepthHintLearning { + if (explicitDepth || !target.targetId) return 'ignored'; + const recoveredLower = recovery.rejected > 0 && recovery.acceptedLevels < requestedLevels; + if (recoveredLower) { + this.remember(target.targetId, { + generation: target.generation, + nativeLevels: recovery.acceptedLevels, + remainingUses: this.probeBackAfterUses, + }); + return 'learned'; + } + const acceptedFullDepth = recovery.rejected === 0 && recovery.acceptedLevels >= requestedLevels; + if (acceptedFullDepth) { + return this.entries.delete(target.targetId) ? 'forgotten' : 'ignored'; + } + return this.currentEntry(target) ? 'kept' : 'ignored'; + } + + private currentEntry(target: HintTarget): HintEntry | undefined | 'unidentified' { + if (!target.targetId) return 'unidentified'; + const entry = this.entries.get(target.targetId); + if (!entry) return undefined; + if (entry.generation !== target.generation) { + this.entries.delete(target.targetId); + return undefined; + } + return entry; + } + + private remember(targetId: string, entry: HintEntry): void { + this.entries.delete(targetId); + if (this.entries.size >= MAX_TRACKED_TARGETS) { + const oldest = this.entries.keys().next().value; + if (oldest !== undefined) this.entries.delete(oldest); + } + this.entries.set(targetId, entry); + } +} diff --git a/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m b/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m index 098a60f2ff..43299ac76e 100644 --- a/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m +++ b/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m @@ -157,7 +157,7 @@ int main(int argc, const char *argv[]) __block NSUInteger requests = 0; BOOL truncated = NO; NSError *failure = nil; - NSDictionary *result = captureSnapshotTree(@"root", depth, 1000, ^id(id element, NSUInteger levels, NSUInteger nodes, NSError **error) { + NSDictionary *result = captureSnapshotTree(@"root", depth, 1000, 0, ^id(id element, NSUInteger levels, NSUInteger nodes, NSError **error) { requests++; if ([scenario hasPrefix:@"api-depth-"]) { require(levels == depth + 1, @"native levels must include the root exactly once"); @@ -167,7 +167,7 @@ int main(int argc, const char *argv[]) } if ([element isEqual:@"root"]) return root; return @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[@{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[]}]}; - }, &truncated, &failure); + }, &truncated, NULL, &failure); if (budget) require(!result && failure.code == 1 && requests == 32, @"request budget must fail without publishing partial content"); else if ([scenario isEqual:@"identity"]) require(result == root && requests == 1, @"healthy capture must reuse the native tree"); else { @@ -222,7 +222,7 @@ int main(int argc, const char *argv[]) require(runtime != nil, setupError ?: @"fixture initialization failed"); NSDictionary *error = nil; NSDictionary *result = [runtime snapshotForProcess:42 maxDepth:([scenario isEqual:@"zero-depth"] ? 0 : [scenario isEqual:@"depth-bound"] ? 4 : 8) maxNodes:(([scenario isEqual:@"depth-nodes"] || [scenario hasPrefix:@"wide-"]) ? 3 : [scenario isEqual:@"runtime-budget"] ? 1000 : 10) - requestId:@"capture-1" generation:@"generation-1" maxDurationMs:4000 error:&error]; + nativeLevelsHint:0 requestId:@"capture-1" generation:@"generation-1" maxDurationMs:4000 error:&error]; if (expectedCode) { require(result == nil, @"refused capture must not publish the app tree"); require([error[@"error_kind"] isEqual:([expectedCode isEqual:@"application-server-unavailable"] ? @"application_unavailable" : [expectedCode isEqual:@"snapshot-tree-malformed"] ? @"malformed_tree" : [expectedCode isEqual:@"continuation-budget-exhausted"] ? @"reader_unavailable" : @"unsupported")], @"refusal must preserve the typed failure kind"); diff --git a/packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m b/packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m index 246e8c0ec7..258ab4140a 100644 --- a/packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m +++ b/packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m @@ -203,13 +203,15 @@ int main(int argc, const char *argv[]) NSNumber *ownerChangesAfter = [native[@"ownerChangesAfterRequests"] isKindOfClass:NSNumber.class] ? native[@"ownerChangesAfterRequests"] : nil; BOOL unknownAtBoundary = [native[@"frontierEvidence"] isEqual:@"unknown"]; BOOL vanishAtBoundary = [native[@"vanishAtFrontier"] boolValue]; + NSUInteger hint = [request[@"hint"][@"host-bridge"] unsignedIntegerValue]; __block NSUInteger requests = 0; __block NSUInteger rejected = 0; BOOL truncated = NO; + SnapshotCaptureRecovery recovery = {0, 0, 0, 0}; NSError *error = nil; NSDictionary *result = captureSnapshotTree(root.identity, [request[@"traversalDepth"] unsignedIntegerValue], - [request[@"nodeBudget"] unsignedIntegerValue], + [request[@"nodeBudget"] unsignedIntegerValue], hint, ^id(id element, NSUInteger levels, NSUInteger nodes, NSError **readError) { requests++; if (ownerChangesAfter && requests > ownerChangesAfter.unsignedIntegerValue) { @@ -227,14 +229,18 @@ int main(int argc, const char *argv[]) return nil; } return fragment(node, levels, unknownAtBoundary, vanishAtBoundary); - }, &truncated, &error); + }, &truncated, &recovery, &error); - // Continuations are the native requests beyond the root's accepted request: every request - // that reached the reader, minus the rejected ones, minus the root read that produced a tree. + // The guest's accounting is what the host learns from and reports, so it must agree with what + // the reader observed: every request, every rejection, and the continuations beyond the root. + if (recovery.requests != requests || recovery.rejected != rejected || + recovery.continuations != requests - rejected - (requests > rejected ? 1 : 0)) { + return fail(caseName, @"recovery accounting must count every native request, rejection, and continuation"); + } NSMutableDictionary *actual = [NSMutableDictionary dictionary]; - actual[@"requests"] = @(requests); - actual[@"rejected"] = @(rejected); - actual[@"continuations"] = @(requests - rejected - (requests > rejected ? 1 : 0)); + actual[@"requests"] = @(recovery.requests); + actual[@"rejected"] = @(recovery.rejected); + actual[@"continuations"] = @(recovery.continuations); if (result) { NSUInteger nodes = 0; actual[@"outcome"] = truncated ? @"incomplete" : @"complete"; diff --git a/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json b/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json index fb88c09d80..ef70facf56 100644 --- a/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json +++ b/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json @@ -1,6 +1,6 @@ { "protocolVersion": 1, - "sourceVersion": "agent-device-simulator-ax-v1.5.4", + "sourceVersion": "agent-device-simulator-ax-v1.5.5", "requestKeys": [ "verb", "requestId", @@ -11,7 +11,8 @@ "maxDepth", "maxNodes", "maxDurationMs", - "maxResponseBytes" + "maxResponseBytes", + "nativeLevelsHint" ], "responseKeys": [ "protocolVersion", @@ -23,6 +24,7 @@ "tree", "truncated", "automationEnabled", + "recovery", "error_kind", "error_code", "error" diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 0267ea00dd..12a02c43ed 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -38,6 +38,7 @@ type SnapshotBridgeRequest = Readonly<{ bridge: SnapshotSourceBridgeBinary; limits: SnapshotSourceLimits; maxDepth: number; + nativeLevelsHint?: number; deadline: SnapshotSourceDeadline; }>; @@ -211,6 +212,7 @@ export class SnapshotBridgeManager { maxNodes: input.limits.maxNodes, maxDurationMs: remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'), maxResponseBytes: input.limits.maxResponseBytes, + ...(input.nativeLevelsHint !== undefined ? { nativeLevelsHint: input.nativeLevelsHint } : {}), }); const frame = encodeSnapshotBridgeFrame(request, input.limits); return await roundTripSnapshotBridge({ diff --git a/packages/platform-apple/src/snapshot-source/protocol.test.ts b/packages/platform-apple/src/snapshot-source/protocol.test.ts index 4c8f7f7c90..443935b9c3 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.test.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.test.ts @@ -142,7 +142,7 @@ test('wire vocabulary guard keeps TS and Objective-C literals aligned', async () 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\.4"/); + assert.match(nativeSource, /kSourceVersion = @"agent-device-simulator-ax-v1\.5\.5"/); for (const key of [ ...wireVocabulary.requestKeys, ...wireVocabulary.responseKeys, diff --git a/packages/platform-apple/src/snapshot-source/protocol.ts b/packages/platform-apple/src/snapshot-source/protocol.ts index e6140257e7..234fee3742 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.ts @@ -3,7 +3,7 @@ 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.4'; +export const SNAPSHOT_SOURCE_VERSION = 'agent-device-simulator-ax-v1.5.5'; const FRAME_HEADER_BYTES = 4; export const SNAPSHOT_SOURCE_WIRE_KEYS = Object.freeze([ @@ -17,6 +17,7 @@ export const SNAPSHOT_SOURCE_WIRE_KEYS = Object.freeze([ 'maxNodes', 'maxDurationMs', 'maxResponseBytes', + 'nativeLevelsHint', ] as const); export const SNAPSHOT_SOURCE_RESPONSE_KEYS = Object.freeze([ @@ -29,6 +30,7 @@ export const SNAPSHOT_SOURCE_RESPONSE_KEYS = Object.freeze([ 'tree', 'truncated', 'automationEnabled', + 'recovery', 'error_kind', 'error_code', 'error', @@ -47,6 +49,34 @@ export const SNAPSHOT_SOURCE_ATTRIBUTE_KEYS = Object.freeze([ export type SnapshotBridgeEnvelope = Readonly>; +/** + * The guest's native request accounting for one acquisition: how many native requests it + * issued, how many the accessibility server rejected at their depth, how many re-rooted withheld + * children, and the native levels of the last accepted request. + */ +export type SnapshotBridgeRecovery = Readonly<{ + requests: number; + rejected: number; + continuations: number; + acceptedLevels: number; +}>; + +const RECOVERY_FIELDS = ['requests', 'rejected', 'continuations', 'acceptedLevels'] as const; + +export function readSnapshotBridgeRecovery( + envelope: SnapshotBridgeEnvelope, +): SnapshotBridgeRecovery { + const recovery = envelope.recovery; + if (!isRecord(recovery)) throw snapshotSourceError('malformed-tree', 'recovery-invalid'); + const fields = RECOVERY_FIELDS.map((field) => [field, recovery[field]] as const); + if (fields.some(([, value]) => !Number.isSafeInteger(value) || (value as number) < 0)) { + throw snapshotSourceError('malformed-tree', 'recovery-invalid'); + } + return Object.freeze( + Object.fromEntries(fields) as Record<(typeof RECOVERY_FIELDS)[number], number>, + ); +} + export function encodeSnapshotBridgeFrame( value: unknown, limits: Pick, @@ -168,6 +198,7 @@ export function createSnapshotBridgeDescribeRequest( maxNodes: number; maxDurationMs: number; maxResponseBytes: number; + nativeLevelsHint?: number; }>, ): Readonly> { return Object.freeze({ @@ -181,6 +212,7 @@ export function createSnapshotBridgeDescribeRequest( maxNodes: input.maxNodes, maxDurationMs: input.maxDurationMs, maxResponseBytes: input.maxResponseBytes, + ...(input.nativeLevelsHint !== undefined ? { nativeLevelsHint: input.nativeLevelsHint } : {}), }); }