Skip to content

Commit 7ec3327

Browse files
committed
fix: close simulator snapshot source ownership gaps
1 parent 5b35448 commit 7ec3327

11 files changed

Lines changed: 223 additions & 13 deletions

File tree

.github/workflows/ios.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ jobs:
126126
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
127127
preferred-device-name: iPhone 17 Pro
128128

129+
- name: Verify clean-installed Simulator snapshot bridge preparation
130+
if: github.event_name == 'pull_request'
131+
env:
132+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
133+
run: |
134+
git fetch origin "$BASE_SHA" --depth=1
135+
if git diff --quiet "$BASE_SHA"...HEAD -- \
136+
apple/snapshot-bridge \
137+
packages/platform-apple/src/snapshot-source \
138+
scripts/check-package.ts \
139+
scripts/size-report-install.mjs \
140+
scripts/size-report-package.mjs; then
141+
echo "Snapshot bridge packaging is unchanged; skipping preparation proof."
142+
exit 0
143+
fi
144+
pnpm build
145+
pnpm check:package -- --verify-snapshot-bridge-preparation
146+
129147
- name: Run targeted iOS runner XCTest regressions
130148
run: |
131149
XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)"

docs/evidence/ios-simulator-snapshot-source-2026-09-04.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ lifecycle contract.
5959
| Retained growth | Scope kept in the facet |
6060
|---|---|
6161
| Native runtime | Private AX binding, strict tree materialization, watchdog, and bounded response framing |
62-
| Host/cache | Toolchain-aware atomic build cache and clean-installed native source validation |
62+
| Host/cache | Toolchain-aware atomic build cache and clean-installed native source preparation |
6363
| Lifecycle/wire | Per-simulator generation routing, persistent helper reuse, typed failures, and reap recovery |
6464
| Proof | Vitest coverage topology, native/TypeScript vocabulary parity, size base/head handling, and live evidence |
6565

packages/platform-apple/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
"private": true,
55
"type": "module",
66
"description": "Apple-family platform runtime metadata and implementations for agent-device.",
7+
"scripts": {
8+
"verify-installed-snapshot-bridge": "node --experimental-strip-types scripts/verify-installed-snapshot-bridge.ts"
9+
},
710
"dependencies": {
811
"@agent-device/capture-kit": "workspace:*",
912
"@agent-device/contracts": "workspace:*",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import path from 'node:path';
2+
import { ensureSnapshotBridgeBinary } from '../src/snapshot-source/cache.ts';
3+
import { createSnapshotSourceDeadline } from '../src/snapshot-source/deadline.ts';
4+
import { createSnapshotSourceHost } from '../src/snapshot-source/host.ts';
5+
import { resolveSnapshotSourceLimits } from '../src/snapshot-source/limits.ts';
6+
7+
const [installedRoot, cacheRoot] = process.argv.slice(2);
8+
if (!installedRoot || !cacheRoot) {
9+
throw new Error('Usage: verify-installed-snapshot-bridge <installed-root> <cache-root>');
10+
}
11+
12+
const host = {
13+
...createSnapshotSourceHost(),
14+
projectRoot: () => installedRoot,
15+
};
16+
const limits = resolveSnapshotSourceLimits({ maxDurationMs: 120_000 });
17+
const prepared = await ensureSnapshotBridgeBinary({
18+
host,
19+
runtime: 'installed-package-verification',
20+
limits,
21+
deadline: createSnapshotSourceDeadline(limits.maxDurationMs, undefined),
22+
cacheRoot,
23+
});
24+
if (!prepared.path.startsWith(`${cacheRoot}${path.sep}`) || !host.exists(prepared.path)) {
25+
throw new Error('Installed snapshot bridge preparation did not publish its compiled binary.');
26+
}

packages/platform-apple/src/snapshot-source/adapter.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ function createAdapterHost(buildDelayMs = 0): AdapterFixture {
161161
},
162162
start: () => new AdapterProcess(),
163163
connect: async () => new AdapterSocket(() => fixture.responsePid),
164+
readTargetProcessStartTime: async () => 'target-start',
164165
};
165166
fixture.host = host;
166167
return fixture;

packages/platform-apple/src/snapshot-source/host.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts';
44

55
test('snapshot bridge socket paths stay within the AF_UNIX limit and are target-specific', () => {
66
const host = createSnapshotSourceHost();
7-
const first = snapshotSourceSocketPath(host, 'simulator-1');
8-
const second = snapshotSourceSocketPath(host, 'simulator-2');
7+
const first = snapshotSourceSocketPath(host, 'simulator-1', 'owner-1');
8+
const second = snapshotSourceSocketPath(host, 'simulator-2', 'owner-1');
9+
const otherOwner = snapshotSourceSocketPath(host, 'simulator-1', 'owner-2');
910

1011
assert.equal(first.length < 104, true);
1112
assert.equal(second.length < 104, true);
13+
assert.equal(otherOwner.length < 104, true);
1214
assert.notEqual(first, second);
15+
assert.notEqual(first, otherOwner);
1316
});

packages/platform-apple/src/snapshot-source/host.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,23 @@ export function createSnapshotSourceHost(): SnapshotSourceHost {
4848
emitDiagnostic,
4949
withDiagnosticTimer,
5050
processId: hostProcessId,
51+
readTargetProcessStartTime,
5152
};
5253
}
5354

55+
async function readTargetProcessStartTime(
56+
pid: number,
57+
options: { signal?: AbortSignal; timeoutMs: number },
58+
): Promise<string | null> {
59+
const result = await runCmd('ps', ['-p', String(pid), '-o', 'lstart='], {
60+
allowFailure: true,
61+
signal: options.signal,
62+
timeoutMs: options.timeoutMs,
63+
});
64+
if (result.exitCode !== 0) return null;
65+
return result.stdout.trim() || null;
66+
}
67+
5468
function startSnapshotBridge(
5569
udid: string,
5670
bridgePath: string,
@@ -217,11 +231,16 @@ function appendBoundedLog(current: string, addition: string): string {
217231
: combined.slice(combined.length - MAX_PROCESS_LOG_BYTES);
218232
}
219233

220-
export function snapshotSourceSocketPath(host: SnapshotSourceHost, udid: string): string {
234+
export function snapshotSourceSocketPath(
235+
host: SnapshotSourceHost,
236+
udid: string,
237+
ownerId: string,
238+
): string {
221239
const targetKey = createHash('sha256').update(udid).digest('hex').slice(0, 12);
240+
const ownerKey = createHash('sha256').update(ownerId).digest('hex').slice(0, 12);
222241
return path.join(
223242
SNAPSHOT_SOCKET_ROOT,
224-
`agent-device-ax-${targetKey}-${host.processId()}`,
243+
`agent-device-ax-${targetKey}-${host.processId()}-${ownerKey}`,
225244
'snapshot.sock',
226245
);
227246
}

packages/platform-apple/src/snapshot-source/lifecycle.test.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,62 @@ test('cancellation while queued prevents a later dispatch', async () => {
106106
await manager.close();
107107
});
108108

109+
test('cancelling a middle waiter does not release the following request early', async () => {
110+
const fixture = createLifecycleFixture({ responseDelayMs: 120 });
111+
const manager = new SnapshotBridgeManager(fixture.host);
112+
const first = manager.request({
113+
target,
114+
bridge,
115+
limits,
116+
maxDepth: 10,
117+
deadline: deadline(undefined, 1000),
118+
});
119+
await waitForDispatch(fixture);
120+
121+
const controller = new AbortController();
122+
const middle = manager.request({
123+
target,
124+
bridge,
125+
limits,
126+
maxDepth: 10,
127+
deadline: deadline(controller.signal, 1000),
128+
});
129+
const last = manager.request({
130+
target,
131+
bridge,
132+
limits,
133+
maxDepth: 10,
134+
deadline: deadline(undefined, 1000),
135+
});
136+
controller.abort();
137+
138+
await assert.rejects(
139+
middle,
140+
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled',
141+
);
142+
assert.equal(fixture.sockets[0]?.writes, 1);
143+
await first;
144+
await last;
145+
assert.equal(fixture.sockets[0]?.writes, 2);
146+
await manager.close();
147+
});
148+
149+
test('independent managers own distinct sockets for the same Simulator', async () => {
150+
const fixture = createLifecycleFixture();
151+
const first = new SnapshotBridgeManager(fixture.host);
152+
const second = new SnapshotBridgeManager(fixture.host);
153+
154+
await first.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
155+
await second.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
156+
157+
assert.equal(fixture.socketPaths.length, 2);
158+
assert.notEqual(fixture.socketPaths[0], fixture.socketPaths[1]);
159+
await first.close();
160+
assert.equal(fixture.processes[1]?.isAlive(), true);
161+
await second.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
162+
await second.close();
163+
});
164+
109165
test('request cancellation after dispatch reaps the exact helper before recovery', async () => {
110166
const fixture = createLifecycleFixture({ responseDelayMs: 80 });
111167
const manager = new SnapshotBridgeManager(fixture.host);
@@ -258,6 +314,20 @@ test('the manager rejects a response carrying a previous target generation as st
258314
await manager.close();
259315
});
260316

317+
test('the manager rejects a tree when the target process changes during acquisition', async () => {
318+
const fixture = createLifecycleFixture({ targetStartTimes: ['start-1', 'start-2'] });
319+
const manager = new SnapshotBridgeManager(fixture.host);
320+
321+
await assert.rejects(
322+
manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }),
323+
(error: unknown) =>
324+
error instanceof SnapshotSourceError &&
325+
error.failureKind === 'stale-target' &&
326+
error.failureCode === 'target-process-changed',
327+
);
328+
await manager.close();
329+
});
330+
261331
test('typed guest failures retain their kind after target validation', async () => {
262332
const fixture = createLifecycleFixture({ responseErrorKind: 'application_not_responding' });
263333
const manager = new SnapshotBridgeManager(fixture.host);
@@ -274,6 +344,7 @@ type LifecycleFixture = {
274344
processes: FakeProcess[];
275345
sockets: FakeSocket[];
276346
diagnostics: Array<Parameters<SnapshotSourceHost['emitDiagnostic']>[0]>;
347+
socketPaths: string[];
277348
};
278349

279350
function deadline(signal?: AbortSignal, timeoutMs = limits.maxDurationMs) {
@@ -295,16 +366,20 @@ function createLifecycleFixture(
295366
responsePid?: number;
296367
responseGeneration?: string;
297368
responseErrorKind?: string;
369+
targetStartTimes?: Array<string | null>;
298370
} = {},
299371
): LifecycleFixture {
300372
const processes: FakeProcess[] = [];
301373
const sockets: FakeSocket[] = [];
302374
const diagnostics: LifecycleFixture['diagnostics'] = [];
375+
const socketPaths: string[] = [];
303376
const realHost = createSnapshotSourceHost();
304377
const host: SnapshotSourceHost = {
305378
...realHost,
306379
emitDiagnostic: (event) => diagnostics.push(event),
307-
start: () => {
380+
readTargetProcessStartTime: async () => options.targetStartTimes?.shift() ?? 'target-start',
381+
start: (_udid, _bridgePath, socketPath) => {
382+
socketPaths.push(socketPath);
308383
const process = new FakeProcess(700 + processes.length);
309384
processes.push(process);
310385
return process;
@@ -336,7 +411,7 @@ function createLifecycleFixture(
336411
return socket;
337412
},
338413
};
339-
return { host, processes, sockets, diagnostics };
414+
return { host, processes, sockets, diagnostics, socketPaths };
340415
}
341416

342417
class FakeProcess implements SnapshotSourceProcess {

packages/platform-apple/src/snapshot-source/lifecycle.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type SnapshotBridgeRequest = Readonly<{
4444
export class SnapshotBridgeManager {
4545
private readonly sessions = new Map<string, BridgeSession>();
4646
private readonly requestQueues = new Map<string, Promise<void>>();
47+
private readonly ownerId = randomUUID();
4748
private closed = false;
4849
private readonly host: SnapshotSourceHost;
4950

@@ -65,7 +66,10 @@ export class SnapshotBridgeManager {
6566
const previousSession = this.sessions.get(input.target.udid);
6667
const session = await this.ensureSession(input, deadline);
6768
try {
68-
return await this.exchange(session, input, deadline);
69+
const targetStartTime = await this.readTargetStartTime(input.target, deadline);
70+
const envelope = await this.exchange(session, input, deadline);
71+
await this.assertTargetStillCurrent(input.target, targetStartTime, deadline);
72+
return envelope;
6973
} catch (error) {
7074
const normalized = await this.handleRequestFailure(error, session, previousSession);
7175
throw normalized;
@@ -94,15 +98,15 @@ export class SnapshotBridgeManager {
9498
deadline: SnapshotSourceDeadline,
9599
action: () => Promise<T>,
96100
): Promise<T> {
97-
const previous = this.requestQueues.get(udid);
101+
const previous = this.requestQueues.get(udid) ?? Promise.resolve();
98102
let release!: () => void;
99-
const current = new Promise<void>((resolve) => {
103+
const turnFinished = new Promise<void>((resolve) => {
100104
release = resolve;
101105
});
106+
const current = previous.then(() => turnFinished);
102107
this.requestQueues.set(udid, current);
103108
try {
104-
if (previous) await waitForSimulatorTurn(previous, deadline);
105-
else remainingSnapshotSourceMs(deadline, 'bridge-request-deadline');
109+
await waitForSimulatorTurn(previous, deadline);
106110
return await action();
107111
} finally {
108112
release();
@@ -132,7 +136,7 @@ export class SnapshotBridgeManager {
132136
}
133137
if (existing) await this.removeSession(existing, true);
134138

135-
const socketPath = snapshotSourceSocketPath(this.host, input.target.udid);
139+
const socketPath = snapshotSourceSocketPath(this.host, input.target.udid, this.ownerId);
136140
await this.host.ensureDirectory(path.dirname(socketPath));
137141
await this.host.remove(socketPath);
138142
const bridgeProcess = this.host.start(input.target.udid, input.bridge.path, socketPath, {
@@ -222,6 +226,42 @@ export class SnapshotBridgeManager {
222226
});
223227
}
224228

229+
private async readTargetStartTime(
230+
target: SnapshotSourceTarget,
231+
deadline: SnapshotSourceDeadline,
232+
): Promise<string> {
233+
const startTime = await this.host.readTargetProcessStartTime(target.pid, {
234+
signal: deadline.signal,
235+
timeoutMs: remainingSnapshotSourceMs(deadline, 'target-identity-deadline'),
236+
});
237+
if (!startTime) {
238+
throw snapshotSourceError('stale-target', 'target-process-unavailable', {
239+
pid: target.pid,
240+
generation: target.generation,
241+
});
242+
}
243+
return startTime;
244+
}
245+
246+
private async assertTargetStillCurrent(
247+
target: SnapshotSourceTarget,
248+
expectedStartTime: string,
249+
deadline: SnapshotSourceDeadline,
250+
): Promise<void> {
251+
const observedStartTime = await this.host.readTargetProcessStartTime(target.pid, {
252+
signal: deadline.signal,
253+
timeoutMs: remainingSnapshotSourceMs(deadline, 'target-identity-deadline'),
254+
});
255+
if (observedStartTime !== expectedStartTime) {
256+
throw snapshotSourceError('stale-target', 'target-process-changed', {
257+
pid: target.pid,
258+
generation: target.generation,
259+
expectedStartTime,
260+
observedStartTime,
261+
});
262+
}
263+
}
264+
225265
private async removeSession(session: BridgeSession, stopProcess: boolean): Promise<void> {
226266
if (this.sessions.get(session.udid) === session) this.sessions.delete(session.udid);
227267
await this.dispose(session, stopProcess);

packages/platform-apple/src/snapshot-source/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ export type SnapshotSourceHost = Readonly<{
112112
data?: Record<string, unknown>,
113113
): Promise<T>;
114114
processId(): number;
115+
readTargetProcessStartTime(
116+
pid: number,
117+
options: { signal?: AbortSignal; timeoutMs: number },
118+
): Promise<string | null>;
115119
}>;
116120

117121
export type SnapshotSourceBridgeBinary = Readonly<{

0 commit comments

Comments
 (0)