diff --git a/packages/platform-apple/src/detached-attempt.test.ts b/packages/platform-apple/src/detached-attempt.test.ts new file mode 100644 index 000000000..fdb4fe12f --- /dev/null +++ b/packages/platform-apple/src/detached-attempt.test.ts @@ -0,0 +1,326 @@ +import assert from 'node:assert/strict'; +import { getEventListeners } from 'node:events'; +import { test, vi } from 'vitest'; +import { createDetachedAttempts, waitForDetachedAttempt } from './detached-attempt.ts'; + +const PENDING = new Error('still-running'); + +/** A wait whose slice is instantly spent, which is how a caller reports "still running". */ +const spentWait = (calls: { count: number }) => async () => { + calls.count += 1; +}; + +test('one attempt answers every caller that arrives while it runs', async () => { + const attempts = createDetachedAttempts({ waitMs: 20 }); + let release = () => {}; + const running = new Promise((resolve) => { + release = resolve; + }); + let starts = 0; + const waitCalls = { count: 0 }; + const params = { + start: async () => { + starts += 1; + await running; + return 7; + }, + wait: spentWait(waitCalls), + pending: () => PENDING, + }; + + const first = await attempts.value('key', params).then(() => undefined, identity); + const second = await attempts.value('key', params).then(() => undefined, identity); + + assert.equal(first, PENDING); + assert.equal(second, PENDING); + assert.equal(starts, 1); + + release(); + await settle(); + assert.equal(await attempts.value('key', params), 7); +}); + +test('the first-caller grant spends the wait budget once per attempt', async () => { + const attempts = createDetachedAttempts({ waitMs: 20, waitGrant: 'first-caller' }); + let release = () => {}; + const running = new Promise((resolve) => { + release = resolve; + }); + const waitCalls = { count: 0 }; + const params = { + start: async () => { + await running; + return 7; + }, + wait: spentWait(waitCalls), + pending: () => PENDING, + }; + + await assert.rejects(attempts.value('key', params), (error) => error === PENDING); + await assert.rejects(attempts.value('key', params), (error) => error === PENDING); + assert.equal(waitCalls.count, 1); + + release(); + await settle(); + assert.equal(await attempts.value('key', params), 7); +}); + +test('the every-caller grant lets each capture wait for the same attempt', async () => { + const attempts = createDetachedAttempts({ waitMs: 20 }); + let release = () => {}; + const running = new Promise((resolve) => { + release = resolve; + }); + const waitCalls = { count: 0 }; + const params = { + start: async () => { + await running; + return 7; + }, + wait: spentWait(waitCalls), + pending: () => PENDING, + }; + + await assert.rejects(attempts.value('key', params), (error) => error === PENDING); + await assert.rejects(attempts.value('key', params), (error) => error === PENDING); + assert.equal(waitCalls.count, 2); + release(); +}); + +test('a caller that waits while the attempt settles is served the value', async () => { + const attempts = createDetachedAttempts({ waitMs: 20 }); + let release = () => {}; + const running = new Promise((resolve) => { + release = resolve; + }); + const started = attempts.value('key', { + start: async () => { + await running; + return 7; + }, + wait: async () => { + await running; + }, + pending: () => PENDING, + }); + + release(); + assert.equal(await started, 7); +}); + +test('the retry window is measured from the failure, not from the start of the attempt', async () => { + let clockMs = 0; + const attempts = createDetachedAttempts({ + waitMs: 20, + retryAfterMs: 60_000, + now: () => clockMs, + }); + let starts = 0; + const params = { + start: async () => { + starts += 1; + // A cold host can spend the whole window building before it fails at all. + clockMs += 90_000; + throw new Error('build-failed'); + }, + wait: async () => {}, + pending: () => PENDING, + }; + + await assert.rejects(attempts.value('key', params), /build-failed/); + assert.equal(starts, 1); + await assert.rejects(attempts.value('key', params), /build-failed/); + assert.equal(starts, 1); + + clockMs += 60_001; + await assert.rejects(attempts.value('key', params), /build-failed/); + assert.equal(starts, 2); +}); + +test('without a retry window the next caller starts a fresh attempt', async () => { + const attempts = createDetachedAttempts({ waitMs: 20 }); + let starts = 0; + const params = { + start: async () => { + starts += 1; + throw new Error('probe-failed'); + }, + wait: async () => {}, + pending: () => PENDING, + }; + + await assert.rejects(attempts.value('key', params), /probe-failed/); + await assert.rejects(attempts.value('key', params), /probe-failed/); + assert.equal(starts, 2); +}); + +test('close aborts a running attempt and the next caller starts its own', async () => { + const attempts = createDetachedAttempts({ waitMs: 20 }); + let release = () => {}; + const running = new Promise((resolve) => { + release = resolve; + }); + const seen: AbortSignal[] = []; + const params = { + start: async (signal: AbortSignal) => { + seen.push(signal); + await running; + return 7; + }, + wait: async () => {}, + pending: () => PENDING, + }; + + await assert.rejects(attempts.value('key', params), (error) => error === PENDING); + assert.equal(seen[0]?.aborted, false); + + attempts.close(); + assert.equal(seen[0]?.aborted, true); + + await assert.rejects(attempts.value('key', params), (error) => error === PENDING); + assert.equal(seen.length, 2); + release(); +}); + +test('a wait that rejects with the caller own cancellation stays that cancellation', async () => { + const attempts = createDetachedAttempts({ waitMs: 20 }); + const cancelled = new Error('cancelled-by-request'); + + await assert.rejects( + attempts.value('key', { + start: async () => 7, + wait: () => Promise.reject(cancelled), + pending: () => PENDING, + }), + (error) => error === cancelled, + ); +}); + +test('an attempt that throws before awaiting is reported as its own failure', async () => { + const attempts = createDetachedAttempts({ waitMs: 20 }); + + await assert.rejects( + attempts.value('key', { + start: () => { + throw new Error('source-missing'); + }, + wait: async () => {}, + pending: () => PENDING, + }), + /source-missing/, + ); +}); + +test('a caller answered by the attempt stops the wait it left running', async () => { + const attempts = createDetachedAttempts({ waitMs: 60_000 }); + let releaseBuild: (value: string) => void = () => {}; + const building = new Promise((resolve) => { + releaseBuild = resolve; + }); + let stopSignal: AbortSignal | undefined; + let waitState: 'pending' | 'resolved' = 'pending'; + + const caller = attempts.value('bridge', { + start: () => building, + // Deliberately has no timer of its own: only the stop can end it, so a wait that is never + // stopped stays observable as `pending` instead of quietly expiring. + wait: (_waitMs, stop) => { + stopSignal = stop; + return new Promise((resolve) => { + stop.addEventListener( + 'abort', + () => { + waitState = 'resolved'; + resolve(); + }, + { once: true }, + ); + }); + }, + pending: () => PENDING, + }); + + await settle(); + assert.equal(waitState, 'pending'); + releaseBuild('binary'); + assert.equal(await caller, 'binary'); + await settle(); + + assert.ok(stopSignal?.aborted, 'the losing wait is stopped once the attempt settles'); + assert.equal(waitState, 'resolved'); +}); + +test('a wait that settles by its own timeout releases the stop listener it added', async () => { + vi.useFakeTimers(); + try { + const stop = new AbortController(); + const waiting = waitForDetachedAttempt({ + waitMs: 20, + signal: undefined, + stop: stop.signal, + cancelled: () => new Error('unreachable'), + }); + + await vi.advanceTimersByTimeAsync(20); + await waiting; + + assert.equal(getEventListeners(stop.signal, 'abort').length, 0); + } finally { + vi.useRealTimers(); + } +}); + +test('a caller signal already aborted rejects at once, without spending waitMs', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const stop = new AbortController(); + const reason = new Error('already-cancelled'); + controller.abort(reason); + + const waiting = waitForDetachedAttempt({ + waitMs: 60_000, + signal: controller.signal, + stop: stop.signal, + cancelled: () => reason, + }); + + await assert.rejects(waiting, (error) => error === reason); + assert.equal(vi.getTimerCount(), 0); + assert.equal(getEventListeners(stop.signal, 'abort').length, 0); + } finally { + vi.useRealTimers(); + } +}); + +test('a stop already aborted resolves at once, without spending waitMs', async () => { + vi.useFakeTimers(); + try { + const stop = new AbortController(); + stop.abort(); + + const waiting = waitForDetachedAttempt({ + waitMs: 60_000, + signal: undefined, + stop: stop.signal, + cancelled: () => new Error('unreachable'), + }); + + await waiting; + assert.equal(vi.getTimerCount(), 0); + assert.equal(getEventListeners(stop.signal, 'abort').length, 0); + } finally { + vi.useRealTimers(); + } +}); + +function identity(error: unknown): unknown { + return error; +} + +/** Lets a released attempt reach the settle handler before the next read. */ +function settle(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} diff --git a/packages/platform-apple/src/detached-attempt.ts b/packages/platform-apple/src/detached-attempt.ts new file mode 100644 index 000000000..659c249c7 --- /dev/null +++ b/packages/platform-apple/src/detached-attempt.ts @@ -0,0 +1,200 @@ +/** + * Work that has to outlive the request that started it. + * + * A capture that gives up on a slow probe or build must not take that work down with it: the next + * capture wants the same result, and on a cold host the cost is host setup rather than request work + * (#2491). So one attempt runs per key, detached from whoever started it, and every caller that + * arrives while it runs is answered from that attempt instead of launching a second one. + * + * What each owner declares — rather than re-implements — is how long a caller may wait for an + * attempt (`waitMs`), whether every waiting caller may spend that budget or only the first one to + * arrive (`waitGrant`), how long a failed attempt keeps being answered as-is before another attempt + * starts (`retryAfterMs`, omitted for work worth retrying at once), and what an attempt that is + * still running costs the caller (`pending`). A settled success leaves the table, because only the + * owner knows when its value stops being valid; that owner caches it and invalidates it on its own + * terms. `close()` is for owners with a lifecycle: it aborts the running attempts so a build or + * probe cannot outlive the source that started it. + */ + +export type DetachedAttempts = Readonly<{ + /** + * The value for `key`, starting a detached attempt when none is running or the last failure is + * old enough to retry. Resolves when the attempt is ready, throws its failure, or throws + * `pending()` when it is still running once this caller's wait is spent. + */ + value( + key: string, + params: Readonly<{ + /** Called only when no attempt is running; must honour `signal` for `close()` to reach it. */ + start: (signal: AbortSignal) => Promise; + /** + * Sleeps inside the caller's own deadline, so a client abort rejects this call with the + * caller's typed cancellation instead of reporting a fresh `pending`. + * + * `stop` is aborted as soon as this caller has its answer from somewhere else; the wait has to + * release its timer and listeners then and resolve, since nobody is racing it any more. + */ + wait: (waitMs: number, stop: AbortSignal) => Promise; + pending: () => Error; + }>, + ): Promise; + /** Aborts every running attempt and forgets all of them. */ + close(): void; +}>; + +/** Whether a caller that arrives while an attempt runs may wait for it. */ +export type DetachedAttemptWaitGrant = + /** Each caller spends the wait budget, for work measured in seconds that every caller would + * rather wait out than give up on. */ + | 'every-caller' + /** Only the first caller to arrive spends it, for work that outlives any one capture: a poll + * loop that has already paid the budget once should not pay it again per poll (#2491). */ + | 'first-caller'; + +type Attempt = { + status: 'pending' | 'ready' | 'failed'; + value: Value; + error: unknown; + /** When the attempt stopped being pending. A retry window is measured from here, so an attempt + * that failed late is not already eligible for a retry the moment it fails. */ + settledAtMs: number; + waitSpent: boolean; + controller: AbortController; + settled: Promise; +}; + +/** + * The wait an owner hands to `value()`, built once so the promise every owner needs is the promise + * this module describes: sleeps `waitMs`, resolves when that sleep is spent or when `stop` says the + * answer arrived elsewhere, and rejects only on the caller's own abort so that stays typed. + * + * Every listener this adds to a signal it did not create is removed once the wait settles, on every + * path (timeout, `stop`, or the caller's own abort) — `stop` is optional for a caller with no signal + * to end the wait early. + */ +export function waitForDetachedAttempt( + params: Readonly<{ + waitMs: number; + /** The caller's own deadline signal; a wait inside it keeps a client abort a client abort. */ + signal: AbortSignal | undefined; + stop: AbortSignal | undefined; + /** The rejection for the caller aborting, so each owner keeps its own error type. */ + cancelled: () => unknown; + }>, +): Promise { + const { waitMs, signal, stop, cancelled } = params; + return new Promise((resolve, reject) => { + const onAbort = () => finish(() => reject(cancelled())); + const onStop = () => finish(resolve); + const timer = setTimeout(() => finish(resolve), waitMs); + function finish(settle: () => void): void { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + stop?.removeEventListener('abort', onStop); + settle(); + } + signal?.addEventListener('abort', onAbort, { once: true }); + stop?.addEventListener('abort', onStop, { once: true }); + if (signal?.aborted) onAbort(); + else if (stop?.aborted) onStop(); + }); +} + +export function createDetachedAttempts( + deps: Readonly<{ + waitMs: number; + waitGrant?: DetachedAttemptWaitGrant; + retryAfterMs?: number; + /** The clock the retry window is measured against; injected so a test can move time. */ + now?: () => number; + }>, +): DetachedAttempts { + const now = deps.now ?? Date.now; + const waitGrant = deps.waitGrant ?? 'every-caller'; + const attempts = new Map>(); + + const startAttempt = ( + key: string, + start: (signal: AbortSignal) => Promise, + ): Attempt => { + const attempt: Attempt = { + status: 'pending', + value: undefined as Value, + error: undefined, + settledAtMs: now(), + waitSpent: false, + controller: new AbortController(), + settled: undefined as unknown as Promise, + }; + let settle = () => {}; + attempt.settled = new Promise((resolve) => { + settle = resolve; + }); + attempts.set(key, attempt); + const finish = (status: 'ready' | 'failed', outcome: Value | unknown) => { + attempt.status = status; + if (status === 'ready') attempt.value = outcome as Value; + else attempt.error = outcome; + attempt.settledAtMs = now(); + // A success is the owner's to cache, because only the owner knows when the value stops being + // valid. A failure stays only as long as it should keep being answered as-is. + if (status === 'ready' || deps.retryAfterMs === undefined) { + if (attempts.get(key) === attempt) attempts.delete(key); + } + settle(); + }; + // The attempt outlives every caller that asked for it, so a rejection nobody is awaiting yet + // must never surface as an unhandled rejection; later callers read it from the record. + try { + void start(attempt.controller.signal).then( + (value) => finish('ready', value), + (error: unknown) => finish('failed', error), + ); + } catch (error) { + finish('failed', error); + } + return attempt; + }; + + const currentAttempt = ( + key: string, + start: (signal: AbortSignal) => Promise, + ): Attempt => { + const attempt = attempts.get(key); + if (!attempt) return startAttempt(key, start); + if (attempt.status !== 'failed') return attempt; + const failedAgoMs = now() - attempt.settledAtMs; + if (deps.retryAfterMs === undefined || failedAgoMs >= deps.retryAfterMs) { + return startAttempt(key, start); + } + return attempt; + }; + + return { + value: async (key, params) => { + const attempt = currentAttempt(key, params.start); + if (attempt.status === 'pending' && (waitGrant === 'every-caller' || !attempt.waitSpent)) { + // Marked before waiting: two captures arriving together must not each spend the budget. + attempt.waitSpent = true; + const stopWaiting = new AbortController(); + const waiting = params.wait(deps.waitMs, stopWaiting.signal); + // A wait can still reject after it lost the race, e.g. a client aborting in the moment + // between the attempt settling and this call returning; nobody awaits it by then. + waiting.catch(() => {}); + try { + await Promise.race([attempt.settled, waiting]); + } finally { + stopWaiting.abort(); + } + } + if (attempt.status === 'ready') return attempt.value; + if (attempt.status === 'failed') throw attempt.error; + throw params.pending(); + }, + close: () => { + const running = [...attempts.values()]; + attempts.clear(); + for (const attempt of running) attempt.controller.abort(); + }, + }; +} diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index fd06f4109..cff163e2b 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -302,6 +302,33 @@ test('typed bridge failure falls back once and disables retries for that app gen }); }); +test('a bridge still being prepared sends only that capture to the runner', async () => { + const source = sourceReturning({ + stage: 'failed', + failure: { kind: 'preparing', code: 'bridge-preparation-pending' }, + }); + const fallback = vi.fn(async () => runnerResult()); + const route = createAppleSnapshotRoute(platformRuntimeHostFixture(), { + source, + resolveTarget: vi.fn(async () => target), + }); + + const first = await route.capture(ios, input, signal(), fallback); + const second = await route.capture(ios, input, signal(), fallback); + + // An attempt in flight says nothing about this app generation, so unlike a failed bridge it must + // not close the circuit: a stable screen would never use the finished preparation. + expect(source.acquire).toHaveBeenCalledTimes(2); + expect(fallback).toHaveBeenCalledTimes(2); + const pending = [ + 'Simulator AX snapshot unavailable (bridge-preparation-pending); used XCTest for this capture while the bridge is still being prepared.', + ]; + expect(first.warnings).toEqual(pending); + // The circuit-disabled sentence would mean the route gave up on the bridge for this generation, + // and the app-generation sentence would mean the same; only this capture moved to the runner. + expect(second.warnings).toEqual(pending); +}); + test('a new app generation re-enables the bridge', async () => { const source = sourceReturning({ stage: 'failed', diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index 190aff51c..84250e883 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -49,6 +49,13 @@ const SYSTEM_SURFACE_PRESENTED = 'system-surface-presented'; */ const SYSTEM_SURFACE_HOST_LINGERING = 'system-surface-host-lingering'; +/** + * Why this capture left the bridge: the bridge binary is still being built. The generation stays on + * the bridge path, so the warning has to say the runner served this capture rather than the + * generation (#2491). + */ +const BRIDGE_PREPARATION_PENDING = 'bridge-preparation-pending'; + export type AppleSnapshotRoute = LaunchObservationPort & Readonly<{ capture( @@ -258,7 +265,11 @@ async function fallbackAfterFailure( disabledGenerations: Set, cause?: unknown, ): Promise { - disabledGenerations.add(generationKey(failedTarget)); + // A failed bridge is evidence about this app generation, so its captures take the runner until the + // generation is rebaselined. A bridge that is merely still being prepared is evidence about the + // daemon's build queue instead: the same generation has to be able to use it as soon as it exists, + // which is what let one cold host cost every later capture of a stable screen (#2491). + if (failure.kind !== 'preparing') disabledGenerations.add(generationKey(failedTarget)); emitRouteDiagnostic( failure.code, { id: failedTarget.udid }, @@ -420,6 +431,12 @@ function fallbackWarning(reason: string, lineage: IosSnapshotLineage, served: bo if (served) { return `Simulator AX snapshot unavailable (${reason}); used XCTest, which read the system surface presented over the app.`; } + // A bridge that is still being built says nothing about this generation, and the route keeps the + // generation on the bridge path because of it. The generation sentence would claim a retirement + // that did not happen (#2491). + if (reason === BRIDGE_PREPARATION_PENDING) { + return `Simulator AX snapshot unavailable (${reason}); used XCTest for this capture while the bridge is still being prepared.`; + } const generation = lineage.generation ? 'this app generation' : 'an unverified app generation'; return `Simulator AX snapshot unavailable (${reason}); used XCTest for ${generation}.`; } diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index bdb6e1970..c4910022a 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -16,7 +16,12 @@ import { SNAPSHOT_SOURCE_PROTOCOL_VERSION, SNAPSHOT_SOURCE_VERSION, } from './protocol.ts'; -import type { SnapshotSourceHost, SnapshotSourceProcess, SnapshotSourceSocket } from './types.ts'; +import type { + SnapshotSourceHost, + SnapshotSourceOutcome, + SnapshotSourceProcess, + SnapshotSourceSocket, +} from './types.ts'; import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; test('the Simulator AX source returns raw acquisition facts and discloses unsupported facets', async () => { @@ -144,8 +149,8 @@ test('the Simulator AX source refuses a tree that ends at content another proces } }); -test('preparation consumes the same acquisition deadline as bridge I/O', async () => { - const root = await mkdtempForTest('agent-device-snapshot-adapter-deadline-'); +test('a capture reports a cold preparation instead of spending its deadline on it', async () => { + const root = await mkdtempForTest('agent-device-snapshot-adapter-preparing-'); const sourceRoot = path.join(root, 'source'); const cacheRoot = path.join(root, 'cache'); await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot); @@ -154,19 +159,33 @@ test('preparation consumes the same acquisition deadline as bridge I/O', async ( await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); - const fixture = createAdapterHost(150); + const fixture = createAdapterHost(300); const source = createSimulatorSnapshotSource({ host: fixture.host, sourceRoot, cacheRoot }); - const request = createIosSnapshotRequest(); - const hint = deriveIosCaptureHint(request); + const hint = deriveIosCaptureHint(createIosSnapshotRequest()); + const target = { ...targetForTest(), generation: 'generation-1' }; try { - const outcome = await source.acquire({ - target: { ...targetForTest(), generation: 'generation-1' }, + const startedAt = performance.now(); + let outcome: SnapshotSourceOutcome = await source.acquire({ + target, hint, limits: { maxDurationMs: 100 }, }); + const waitedMs = performance.now() - startedAt; assert.equal(outcome.stage, 'failed'); - if (outcome.stage === 'failed') assert.equal(outcome.failure.kind, 'timeout'); + if (outcome.stage === 'failed') { + assert.equal(outcome.failure.kind, 'preparing'); + assert.equal(outcome.failure.code, 'bridge-preparation-pending'); + } + // The capture ends on its own budget while the attempt it started keeps compiling detached; + // waiting for that build is what let a cold host cancel captures that had a working runner. + assert.ok(waitedMs < 300, `capture waited ${waitedMs}ms for a 300ms build`); + + for (let attempt = 0; attempt < 20 && outcome.stage !== 'acquired'; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 50)); + outcome = await source.acquire({ target, hint }); + } + assert.equal(outcome.stage, 'acquired'); assert.equal(fixture.builds, 1); } finally { await source.close(); diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts index a45cdab7a..61e469273 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -4,18 +4,17 @@ import type { IosSnapshotAcquisition, IosViewportEvidence, } 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 { readSnapshotBridgeRecovery, type SnapshotBridgeEnvelope } from './protocol.ts'; +import { createSnapshotBridgePreparation } from './preparation.ts'; import { decodeSnapshotBridgeTree } from './tree.ts'; import { createSnapshotSourceHost } from './host.ts'; import type { SnapshotSourceHost, - SnapshotSourceBridgeBinary, SnapshotSourceLimits, SnapshotSourceOutcome, SnapshotSourceRequest, @@ -41,36 +40,17 @@ export function createSimulatorSnapshotSource( const host = options.host ?? createSnapshotSourceHost(); const manager = new SnapshotBridgeManager(host); const depthHints = new AcceptedDepthHints(); - const preparedBinaries = new Map(); + // Preparation is daemon-scoped work: it reads the toolchain identity, fingerprints the bridge + // source and may compile it. It runs detached so no capture's deadline pays for it (#2491). + const preparation = createSnapshotBridgePreparation({ + host, + limits: resolveSnapshotSourceLimits(options.limits), + producer: SNAPSHOT_SOURCE_PRODUCER, + sourceRoot: options.sourceRoot, + cacheRoot: options.cacheRoot, + }); let closed = false; - const prepare = async ( - input: Readonly<{ - runtime: string; - limits: SnapshotSourceLimits; - deadline: import('./deadline.ts').SnapshotSourceDeadline; - }>, - ) => { - if (closed) throw snapshotSourceError('unsupported', 'source-closed'); - const prepared = preparedBinaries.get(input.runtime); - if (prepared) return prepared; - const completed = await host.withDiagnosticTimer( - 'ios.snapshot-source.prepare', - async () => - await ensureSnapshotBridgeBinary({ - host, - runtime: input.runtime, - limits: input.limits, - deadline: input.deadline, - sourceRoot: options.sourceRoot, - cacheRoot: options.cacheRoot, - }), - { producer: SNAPSHOT_SOURCE_PRODUCER }, - ); - preparedBinaries.set(input.runtime, completed); - return completed; - }; - const acquire = async (request: SnapshotSourceRequest): Promise => { try { if (closed) throw snapshotSourceError('unsupported', 'source-closed'); @@ -83,11 +63,11 @@ export function createSimulatorSnapshotSource( return await host.withDiagnosticTimer( 'ios.snapshot-source.acquire', async () => { - const bridge = await prepare({ - runtime: request.target.runtime, - limits, + const bridge = await preparation.readyBinary( + request.target.runtime, deadline, - }); + 'bridge-preparation-deadline', + ); const decision = depthHints.consume(request.target, requestedLevels, explicitDepth); const envelope = await manager.request({ target: request.target, @@ -130,6 +110,9 @@ export function createSimulatorSnapshotSource( close: async () => { if (closed) return; closed = true; + // A bridge build that outlives this source answers to nobody: no request is waiting on it and + // its cache write would land after the source is gone (#2491). + preparation.close(); await manager.close(); }, }; diff --git a/packages/platform-apple/src/snapshot-source/deadline.test.ts b/packages/platform-apple/src/snapshot-source/deadline.test.ts new file mode 100644 index 000000000..2d9341f59 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/deadline.test.ts @@ -0,0 +1,49 @@ +import { expect, test, vi } from 'vitest'; +import { createSnapshotSourceDeadline, waitForSnapshotSourceDelay } from './deadline.ts'; +import { SnapshotSourceError } from './errors.ts'; + +const WAIT_CODE = 'bridge-preparation-pending'; + +test('a stopped delay returns without spending the rest of the deadline', async () => { + vi.useFakeTimers(); + try { + const stop = new AbortController(); + const deadline = createSnapshotSourceDeadline(60_000, undefined); + let settled = false; + const waiting = waitForSnapshotSourceDelay(deadline, 60_000, WAIT_CODE, stop.signal).then( + () => { + settled = true; + }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(false); + stop.abort(); + await waiting; + + expect(settled).toBe(true); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + +test('an aborted caller signal stays typed cancellation next to a stop', async () => { + vi.useFakeTimers(); + try { + const caller = new AbortController(); + const stop = new AbortController(); + const deadline = createSnapshotSourceDeadline(60_000, caller.signal); + const waiting = waitForSnapshotSourceDelay(deadline, 60_000, WAIT_CODE, stop.signal); + + caller.abort(); + await expect(waiting).rejects.toBeInstanceOf(SnapshotSourceError); + await expect(waiting).rejects.toMatchObject({ + failureKind: 'cancelled', + failureCode: 'abort-signal', + }); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/platform-apple/src/snapshot-source/deadline.ts b/packages/platform-apple/src/snapshot-source/deadline.ts index 0e95de0ef..219545d69 100644 --- a/packages/platform-apple/src/snapshot-source/deadline.ts +++ b/packages/platform-apple/src/snapshot-source/deadline.ts @@ -1,4 +1,5 @@ import { Deadline } from '@agent-device/host-kit/retry'; +import { waitForDetachedAttempt } from '../detached-attempt.ts'; import { snapshotSourceError } from './errors.ts'; export type SnapshotSourceDeadline = Readonly<{ @@ -24,26 +25,22 @@ export function remainingSnapshotSourceMs(deadline: SnapshotSourceDeadline, code return Math.max(1, Math.floor(remainingMs)); } +/** + * Sleeps inside the caller's own deadline, so a client abort stays a typed `cancelled` instead of + * arriving as a fresh timeout. `stop` is for a caller that no longer needs the sleep because the work + * it was waiting on answered elsewhere: the delay ends without burning the rest of its budget. + */ export async function waitForSnapshotSourceDelay( deadline: SnapshotSourceDeadline, requestedMs: number, code: string, + stop?: AbortSignal, ): 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 }); - if (deadline.signal?.aborted) onAbort(); + await waitForDetachedAttempt({ + waitMs: delayMs, + signal: deadline.signal, + stop, + cancelled: () => snapshotSourceError('cancelled', 'abort-signal'), }); } diff --git a/packages/platform-apple/src/snapshot-source/errors.ts b/packages/platform-apple/src/snapshot-source/errors.ts index 07a2b64b7..bd7671c00 100644 --- a/packages/platform-apple/src/snapshot-source/errors.ts +++ b/packages/platform-apple/src/snapshot-source/errors.ts @@ -9,6 +9,7 @@ const APP_ERROR_CODE_BY_KIND: Readonly cancelled: 'COMMAND_FAILED', 'process-crash': 'COMMAND_FAILED', 'transport-failure': 'COMMAND_FAILED', + preparing: 'COMMAND_FAILED', }; export class SnapshotSourceError extends AppError { diff --git a/packages/platform-apple/src/snapshot-source/preparation.test.ts b/packages/platform-apple/src/snapshot-source/preparation.test.ts new file mode 100644 index 000000000..9b1e9a0b4 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/preparation.test.ts @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict'; +import { rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; +import { createSnapshotSourceDeadline, type SnapshotSourceDeadline } from './deadline.ts'; +import { createSnapshotSourceHost } from './host.ts'; +import { resolveSnapshotSourceLimits } from './limits.ts'; +import { + createSnapshotBridgePreparation, + type SnapshotBridgePreparation, + SNAPSHOT_BRIDGE_PREPARATION_RETRY_AFTER_MS, +} from './preparation.ts'; +import type { SnapshotSourceBridgeBinary, SnapshotSourceHost } from './types.ts'; + +const BRIDGE_SOURCES = [ + 'SnapshotBridge.m', + 'SnapshotBridgeRuntime.m', + 'SnapshotBridgeRuntime.h', + 'SnapshotBridgeCapture.h', + 'SnapshotBridgeCapture.m', +]; + +test('one detached preparation answers every capture that arrives while it runs', async () => { + const root = await writeBridgeSource('preparation-shared-'); + let releaseBuild = () => {}; + const gate = new Promise((resolve) => { + releaseBuild = resolve; + }); + const fixture = createPreparationHost(async () => { + await gate; + return 'built'; + }); + const preparation = createSnapshotBridgePreparation({ + host: fixture.host, + limits: resolveSnapshotSourceLimits({}), + producer: 'test', + sourceRoot: path.join(root, 'source'), + cacheRoot: path.join(root, 'cache'), + }); + + try { + const pending = await Promise.all([ + preparation.readyBinary('ios-simulator', deadline(300), 'test-deadline').then( + () => undefined, + (error: unknown) => failureOf(error), + ), + preparation.readyBinary('ios-simulator', deadline(300), 'test-deadline').then( + () => undefined, + (error: unknown) => failureOf(error), + ), + ]); + for (const failure of pending) { + assert.equal(failure?.kind, 'preparing'); + assert.equal(failure?.code, 'bridge-preparation-pending'); + } + assert.equal(fixture.builds, 1); + + releaseBuild(); + // A capture that arrives after the budget is spent is pointed at the runner rather than made to + // wait again, so this is a poll loop finding the finished bridge, not one waiting it out. + const binary = await readyWithin(preparation, 2_000); + assert.ok(binary.path.startsWith(path.join(root, 'cache'))); + assert.ok(binary.cacheKey.length > 0); + assert.equal(fixture.builds, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a failed preparation is reported to each capture without rebuilding per capture', async () => { + const root = await writeBridgeSource('preparation-failed-'); + let clockMs = 0; + const fixture = createPreparationHost(async () => { + // A cold host spends the retry window building before it fails at all, which is what the + // window has to be measured against (#2491). + clockMs += SNAPSHOT_BRIDGE_PREPARATION_RETRY_AFTER_MS + 1_000; + return 'failed'; + }); + const preparation = createSnapshotBridgePreparation({ + host: fixture.host, + limits: resolveSnapshotSourceLimits({}), + producer: 'test', + sourceRoot: path.join(root, 'source'), + cacheRoot: path.join(root, 'cache'), + now: () => clockMs, + }); + + try { + const first = await prepare( + preparation, + deadline(5_000, () => clockMs), + ); + assert.equal(first?.kind, 'unsupported'); + assert.equal(first?.code, 'native-build-failed'); + assert.equal(fixture.builds, 1); + + // The attempt above is long past the window measured from its start; measured from the failure + // it is brand new, and a tight wait-poll loop must not launch a compile every 200 ms. + const again = await prepare( + preparation, + deadline(5_000, () => clockMs), + ); + assert.deepEqual(again, first); + assert.equal(fixture.builds, 1); + + clockMs += SNAPSHOT_BRIDGE_PREPARATION_RETRY_AFTER_MS + 1; + const retried = await prepare( + preparation, + deadline(5_000, () => clockMs), + ); + assert.equal(retried?.code, 'native-build-failed'); + assert.equal(fixture.builds, 2); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('only the first capture waits out the budget for a cold preparation', async () => { + const root = await writeBridgeSource('preparation-wait-'); + let releaseBuild = () => {}; + const gate = new Promise((resolve) => { + releaseBuild = resolve; + }); + const fixture = createPreparationHost(async () => { + await gate; + return 'built'; + }); + const preparation = createSnapshotBridgePreparation({ + host: fixture.host, + limits: resolveSnapshotSourceLimits({}), + producer: 'test', + sourceRoot: path.join(root, 'source'), + cacheRoot: path.join(root, 'cache'), + }); + + try { + const paying = prepare(preparation, deadline(30_000)); + // The capture that arrives while the first is still waiting is the `wait` poll of a caller that + // has already spent the budget: it is told to use the runner at once, in seconds it can spare. + await delay(150); + const joined = Date.now(); + const second = await prepare(preparation, deadline(30_000)); + assert.equal(second?.code, 'bridge-preparation-pending'); + assert.ok( + Date.now() - joined < 500, + `a capture that arrived while the first was waiting took ${Date.now() - joined}ms`, + ); + assert.equal(fixture.builds, 1); + + releaseBuild(); + // The capture that paid the budget either caught the finished build or went to the runner with + // `preparing`; either answer is fine, what the test pinned is that only one capture paid. + await paying; + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('closing the preparation stops a build that is still running', async () => { + const root = await writeBridgeSource('preparation-close-'); + const fixture = createPreparationHost(({ signal, attempt }) => { + if (attempt > 1) return Promise.resolve('built'); + // `run` rejects on abort the way the real host does, so the build's own cleanup runs. + return new Promise<'built'>((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + }); + }); + const preparation = createSnapshotBridgePreparation({ + host: fixture.host, + limits: resolveSnapshotSourceLimits({}), + producer: 'test', + sourceRoot: path.join(root, 'source'), + cacheRoot: path.join(root, 'cache'), + }); + + try { + const pending = await prepare(preparation, deadline(300)); + assert.equal(pending?.code, 'bridge-preparation-pending'); + assert.equal(fixture.signals.at(-1)?.aborted, false); + + preparation.close(); + // Reaching the build is the point: no request owns this attempt any more, and a bridge write + // landing after the source closed would belong to nobody. + assert.equal(fixture.signals.at(-1)?.aborted, true); + + const next = await prepare(preparation, deadline(5_000)); + assert.equal(next, undefined); + assert.equal(fixture.builds, 2); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function writeBridgeSource(prefix: string): Promise { + const root = await mkdtempForTest(`agent-device-bridge-${prefix}`); + const sourceRoot = path.join(root, 'source'); + await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot); + for (const name of BRIDGE_SOURCES) { + await writeFile(path.join(sourceRoot, name), 'native source'); + } + return root; +} + +function deadline(timeoutMs: number, now: () => number = Date.now): SnapshotSourceDeadline { + return createSnapshotSourceDeadline(timeoutMs, undefined, now); +} + +function prepare( + preparation: SnapshotBridgePreparation, + captureDeadline: SnapshotSourceDeadline, +): Promise | undefined> { + return preparation.readyBinary('ios-simulator', captureDeadline, 'test-deadline').then( + () => undefined, + (error: unknown) => failureOf(error), + ); +} + +/** + * Reads the preparation the way a `wait` poll does: a capture that was pointed at the runner comes + * back later, when the bridge may exist. + */ +async function readyWithin( + preparation: SnapshotBridgePreparation, + timeoutMs: number, +): Promise { + const until = Date.now() + timeoutMs; + for (;;) { + try { + return await preparation.readyBinary('ios-simulator', deadline(300), 'test-deadline'); + } catch (error) { + if (Date.now() >= until || failureOf(error)?.code !== 'bridge-preparation-pending') { + throw error; + } + await delay(20); + } + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function failureOf(error: unknown): Readonly<{ kind: string; code: string }> | undefined { + const failure = (error as Readonly<{ failureKind?: unknown; failureCode?: unknown }>) ?? {}; + return typeof failure.failureKind === 'string' && typeof failure.failureCode === 'string' + ? { kind: failure.failureKind, code: failure.failureCode } + : undefined; +} + +function createPreparationHost( + build: ( + context: Readonly<{ signal: AbortSignal | undefined; attempt: number }>, + ) => Promise<'built' | 'failed'>, +): Readonly<{ + host: SnapshotSourceHost; + builds: number; + signals: (AbortSignal | undefined)[]; +}> { + const fixture: { + host: SnapshotSourceHost; + builds: number; + signals: (AbortSignal | undefined)[]; + } = { host: undefined as never, builds: 0, signals: [] }; + const host: SnapshotSourceHost = { + ...createSnapshotSourceHost(), + run: async (command, args, options) => { + if (command === 'xcrun' && args.includes('clang')) { + fixture.builds += 1; + fixture.signals.push(options?.signal); + const outcome = await build({ signal: options?.signal, attempt: fixture.builds }); + if (outcome === 'failed') return { stdout: '', stderr: 'boom', exitCode: 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, + }; + }, + }; + fixture.host = host; + return fixture; +} diff --git a/packages/platform-apple/src/snapshot-source/preparation.ts b/packages/platform-apple/src/snapshot-source/preparation.ts new file mode 100644 index 000000000..1126c951f --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/preparation.ts @@ -0,0 +1,131 @@ +import { BUILD_TIMEOUT_MS, ensureSnapshotBridgeBinary } from './cache.ts'; +import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts'; +import { createDetachedAttempts } from '../detached-attempt.ts'; +import { + createSnapshotSourceDeadline, + waitForSnapshotSourceDelay, + type SnapshotSourceDeadline, +} from './deadline.ts'; +import { asSnapshotSourceError, snapshotSourceError } from './errors.ts'; +import type { + SnapshotSourceBridgeBinary, + SnapshotSourceHost, + SnapshotSourceLimits, +} from './types.ts'; + +/** + * Ceiling on one detached preparation, sized from the two costs it can honestly pay: a cold Apple + * toolchain probe stall on a fresh host (`COLD_TOOLCHAIN_PROBE_TIMEOUT_MS`, #2422) and one clang + * build (`BUILD_TIMEOUT_MS`). No capture waits this long; it serves from the XCTest runner instead. + */ +const SNAPSHOT_BRIDGE_PREPARATION_DEADLINE_MS = COLD_TOOLCHAIN_PROBE_TIMEOUT_MS + BUILD_TIMEOUT_MS; + +/** + * How long one capture may wait for a preparation before the XCTest runner serves it. Measured on a + * warm cache, the first preparation in a fresh daemon process costs 1.5 s here — mostly the first + * in-process Apple toolchain probe — and 0.1 s after that, so this budget keeps a cached bridge on + * the bridge path. What it cannot cover is a cold host: the probe stall of #2422 or a clang build is + * host-setup cost, and that belongs to the daemon rather than to one capture (#2491). + * + * Granted to the first capture that finds an attempt running, not to each of them: a `wait` poll + * cycles about every 200 ms, and a budget paid per poll would cost a cold build ten times its own + * cost in captured polls that all end up on the runner anyway. + */ +const SNAPSHOT_BRIDGE_PREPARATION_WAIT_BUDGET_MS = 2_000; + +/** + * A failed attempt is reported as-is for this long instead of starting another build per capture. + * The retry is what recovers a one-off probe timeout; the window is what stops a tight wait-poll + * loop from launching a compile every 200 ms. Measured from the failure, because a cold host can + * spend the whole window building before it fails at all. + */ +export const SNAPSHOT_BRIDGE_PREPARATION_RETRY_AFTER_MS = 60_000; + +export type SnapshotBridgePreparation = Readonly<{ + /** + * The bridge binary for `runtime`, starting a detached preparation when none is running. An + * attempt still running when `deadline` has spent its wait budget is reported as `preparing` and + * keeps running: the caller serves that capture another way instead of paying for it. + */ + readyBinary( + runtime: string, + deadline: SnapshotSourceDeadline, + code: string, + ): Promise; + /** Stops a build that is still running. Called when the snapshot source closes. */ + close(): void; +}>; + +export function createSnapshotBridgePreparation( + deps: Readonly<{ + host: SnapshotSourceHost; + limits: SnapshotSourceLimits; + producer: string; + sourceRoot?: string; + cacheRoot?: string; + now?: () => number; + }>, +): SnapshotBridgePreparation { + const now = deps.now ?? Date.now; + // The binary a finished preparation produced. Content-addressed and verified at rest, so it is + // good for the life of this source; re-deriving it per capture would re-hash the bridge source + // every time. This is the cache the attempt table deliberately does not keep. + const binaries = new Map(); + const attempts = createDetachedAttempts({ + waitMs: SNAPSHOT_BRIDGE_PREPARATION_WAIT_BUDGET_MS, + waitGrant: 'first-caller', + retryAfterMs: SNAPSHOT_BRIDGE_PREPARATION_RETRY_AFTER_MS, + now, + }); + + const build = async ( + runtime: string, + signal: AbortSignal, + ): Promise => { + // The preparation carries the signal `close()` aborts: once a capture has answered there is no + // request deadline left to stop this build, and a bridge write landing after the source closed + // belongs to nobody (#2491). + const deadline = createSnapshotSourceDeadline( + SNAPSHOT_BRIDGE_PREPARATION_DEADLINE_MS, + signal, + now, + ); + try { + const binary = await deps.host.withDiagnosticTimer( + 'ios.snapshot-source.prepare', + async () => + await ensureSnapshotBridgeBinary({ + host: deps.host, + runtime, + limits: deps.limits, + deadline, + sourceRoot: deps.sourceRoot, + cacheRoot: deps.cacheRoot, + }), + { producer: deps.producer }, + ); + binaries.set(runtime, binary); + return binary; + } catch (error) { + throw asSnapshotSourceError(error); + } + }; + + return { + readyBinary: async (runtime, deadline, code) => { + const binary = binaries.get(runtime); + if (binary) return binary; + return await attempts.value(runtime, { + start: (signal) => build(runtime, signal), + // Waiting inside the request's own deadline keeps a genuine client abort typed + // `cancelled`: only a preparation that is simply still running is reported as `preparing`. + wait: (waitMs, stop) => waitForSnapshotSourceDelay(deadline, waitMs, code, stop), + pending: () => snapshotSourceError('preparing', 'bridge-preparation-pending'), + }); + }, + close: () => { + binaries.clear(); + attempts.close(); + }, + }; +} diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts index ef62a3a02..9380fca88 100644 --- a/packages/platform-apple/src/snapshot-source/types.ts +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -43,7 +43,9 @@ export type SnapshotSourceFailureKind = | 'timeout' | 'cancelled' | 'process-crash' - | 'transport-failure'; + | 'transport-failure' + /** The bridge binary is still being prepared by a detached attempt; nothing failed. */ + | 'preparing'; export type SnapshotSourceFailure = Readonly<{ kind: SnapshotSourceFailureKind; diff --git a/packages/platform-apple/src/snapshot-target.test.ts b/packages/platform-apple/src/snapshot-target.test.ts index 061684f87..5d50ac9ff 100644 --- a/packages/platform-apple/src/snapshot-target.test.ts +++ b/packages/platform-apple/src/snapshot-target.test.ts @@ -1,3 +1,4 @@ +import { getEventListeners } from 'node:events'; import { expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createLocalAppleToolProvider, withAppleToolProvider } from './core/tool-provider.ts'; @@ -140,6 +141,11 @@ test('an aborted request cannot reuse a cached target', async () => { }); }); +/** The abort listeners still attached to a caller's signal, once its call has returned. */ +function abortListeners(signal: AbortSignal): number { + return getEventListeners(signal, 'abort').length; +} + function deferredSpawn(fixture: ReturnType) { let release!: () => void; const released = new Promise((resolve) => { @@ -263,3 +269,28 @@ test('a failed runtime probe does not release the slot while the launch-job prob vi.useRealTimers(); } }); + +test('a discovery that settles leaves no timer or abort listener behind for its waiters', async () => { + const fixture = targetFixture(); + const release = deferredSpawn(fixture); + vi.useFakeTimers(); + try { + await withAppleToolProvider(fixture.provider, async () => { + const starting = new AbortController(); + const joining = new AbortController(); + const first = fixture.resolve(ios, app, starting.signal); + const second = fixture.resolve(ios, app, joining.signal); + await vi.advanceTimersByTimeAsync(0); + + release(); + await vi.advanceTimersByTimeAsync(0); + await Promise.all([first, second]); + + expect(abortListeners(starting.signal)).toBe(0); + expect(abortListeners(joining.signal)).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/platform-apple/src/snapshot-target.ts b/packages/platform-apple/src/snapshot-target.ts index fdd145ffd..544dad5e2 100644 --- a/packages/platform-apple/src/snapshot-target.ts +++ b/packages/platform-apple/src/snapshot-target.ts @@ -1,5 +1,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { createDetachedAttempts, waitForDetachedAttempt } from './detached-attempt.ts'; import { runSimctl } from './core/apps-simctl.ts'; import { readSnapshotTargetProcessStartTime } from './snapshot-process.ts'; @@ -33,8 +34,13 @@ export type SimulatorSnapshotTargetResolver = ( export function createSimulatorSnapshotTargetResolver(): SimulatorSnapshotTargetResolver { const targets = new Map(); - const discoveries = new Map>(); const runtimeByDevice = new Map>(); + // One discovery per target at a time, detached from the caller's signal: a capture that gives up + // on it, or is cancelled, must not take it down. A discovery that fails is forgotten, so the next + // capture starts a new one. + const discoveries = createDetachedAttempts({ + waitMs: TARGET_DISCOVERY_WAIT_MS, + }); return async (device, appBundleId, signal, refresh) => { signal.throwIfAborted(); const key = `${device.id}:${appBundleId}`; @@ -47,48 +53,19 @@ export function createSimulatorSnapshotTargetResolver(): SimulatorSnapshotTarget if (observed === cached.processStartTime) return cached; } targets.delete(key); - let discovery = discoveries.get(key); - if (!discovery) { - // One discovery per target at a time, detached from the caller's signal: a capture that - // gives up on it, or is cancelled, must not take it down. A discovery that fails is - // forgotten, so the next capture starts a new one. - discovery = resolveSimulatorSnapshotTarget(device, appBundleId, runtimeByDevice) - .then((target) => { - targets.set(key, target); - return target; - }) - .finally(() => discoveries.delete(key)); - discovery.catch(() => undefined); - discoveries.set(key, discovery); - } - return await awaitDiscovery(discovery, signal, device, appBundleId); + return await discoveries.value(key, { + start: async () => { + const target = await resolveSimulatorSnapshotTarget(device, appBundleId, runtimeByDevice); + targets.set(key, target); + return target; + }, + wait: (waitMs, stop) => + waitForDetachedAttempt({ waitMs, signal, stop, cancelled: () => signal.reason }), + pending: () => targetError('simulator-target-discovery-pending', device, appBundleId), + }); }; } -async function awaitDiscovery( - discovery: Promise, - signal: AbortSignal, - device: DeviceInfo, - appBundleId: string, -): Promise { - let timer: ReturnType | undefined; - let onAbort: (() => void) | undefined; - const bound = new Promise((_, reject) => { - timer = setTimeout( - () => reject(targetError('simulator-target-discovery-pending', device, appBundleId)), - TARGET_DISCOVERY_WAIT_MS, - ); - onAbort = () => reject(signal.reason); - signal.addEventListener('abort', onAbort, { once: true }); - }); - try { - return await Promise.race([discovery, bound]); - } finally { - clearTimeout(timer); - if (onAbort) signal.removeEventListener('abort', onAbort); - } -} - async function resolveSimulatorSnapshotTarget( device: DeviceInfo, appBundleId: string,