Skip to content

Commit 58e61bf

Browse files
committed
refactor(ios-snapshot): build a detached wait once, and read the retirement rule where it applies
Three reviewers in a row had to be satisfied about the same promise plumbing because it existed twice: the discovery wait and the bridge-preparation wait each hand-rolled the timer, the caller's abort listener, the stop listener and the cleanup that keeps a leak from costing a timer and a listener per capture. `waitForDetachedAttempt` now implements the wait that `value()`'s contract describes, and both owners hand it their own sleep length and their own cancellation error — which also means the invariant is deleted-and-caught in one place instead of two. `disableGenerationFor` said one thing about one call site, so the rule moves inline next to the set it edits: a failed bridge retires the app generation, a bridge that is merely still building does not.
1 parent 33232d1 commit 58e61bf

4 files changed

Lines changed: 55 additions & 70 deletions

File tree

packages/platform-apple/src/detached-attempt.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,41 @@ type Attempt<Value> = {
6363
settled: Promise<void>;
6464
};
6565

66+
/**
67+
* The wait an owner hands to `value()`, built once so the promise every owner needs is the promise
68+
* this module describes: sleeps `waitMs`, resolves when that sleep is spent or when `stop` says the
69+
* answer arrived elsewhere, and rejects only on the caller's own abort so that stays typed.
70+
*
71+
* `stop` needs neither cleanup nor an already-aborted check: `value()` creates it moments before
72+
* calling and aborts it in a `finally`, which releases the `{ once: true }` listener. The caller's
73+
* signal outlives the wait and does have its listener removed.
74+
*/
75+
export function waitForDetachedAttempt(
76+
params: Readonly<{
77+
waitMs: number;
78+
/** The caller's own deadline signal; a wait inside it keeps a client abort a client abort. */
79+
signal: AbortSignal | undefined;
80+
stop: AbortSignal;
81+
/** The rejection for the caller aborting, so each owner keeps its own error type. */
82+
cancelled: () => unknown;
83+
}>,
84+
): Promise<void> {
85+
const { waitMs, signal, stop, cancelled } = params;
86+
return new Promise<void>((resolve, reject) => {
87+
const onAbort = () => finish(() => reject(cancelled()));
88+
const onStop = () => finish(resolve);
89+
const timer = setTimeout(() => finish(resolve), waitMs);
90+
function finish(settle: () => void): void {
91+
clearTimeout(timer);
92+
signal?.removeEventListener('abort', onAbort);
93+
settle();
94+
}
95+
signal?.addEventListener('abort', onAbort, { once: true });
96+
if (signal?.aborted) onAbort();
97+
stop.addEventListener('abort', onStop, { once: true });
98+
});
99+
}
100+
66101
export function createDetachedAttempts<Value>(
67102
deps: Readonly<{
68103
waitMs: number;

packages/platform-apple/src/snapshot-route.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,11 @@ async function fallbackAfterFailure(
265265
disabledGenerations: Set<string>,
266266
cause?: unknown,
267267
): Promise<SnapshotResult> {
268-
disableGenerationFor(failure, failedTarget, disabledGenerations);
268+
// A failed bridge is evidence about this app generation, so its captures take the runner until the
269+
// generation is rebaselined. A bridge that is merely still being prepared is evidence about the
270+
// daemon's build queue instead: the same generation has to be able to use it as soon as it exists,
271+
// which is what let one cold host cost every later capture of a stable screen (#2491).
272+
if (failure.kind !== 'preparing') disabledGenerations.add(generationKey(failedTarget));
269273
emitRouteDiagnostic(
270274
failure.code,
271275
{ id: failedTarget.udid },
@@ -284,21 +288,6 @@ async function fallbackAfterFailure(
284288
);
285289
}
286290

287-
/**
288-
* A failed bridge is evidence about this app generation, so its captures take the runner until the
289-
* generation is rebaselined. A bridge that is merely still being prepared is evidence about the
290-
* daemon's build queue instead: the same generation must be able to use it as soon as it exists,
291-
* which is what let one cold host cost every later capture of a stable screen (#2491).
292-
*/
293-
function disableGenerationFor(
294-
failure: SnapshotSourceFailure,
295-
failedTarget: SimulatorSnapshotTarget,
296-
disabledGenerations: Set<string>,
297-
): void {
298-
if (failure.kind === 'preparing') return;
299-
disabledGenerations.add(generationKey(failedTarget));
300-
}
301-
302291
async function runFallback(
303292
deviceId: string,
304293
input: CaptureSnapshotInput,
Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Deadline } from '@agent-device/host-kit/retry';
2+
import { waitForDetachedAttempt } from '../detached-attempt.ts';
23
import { snapshotSourceError } from './errors.ts';
34

45
export type SnapshotSourceDeadline = Readonly<{
@@ -24,14 +25,13 @@ export function remainingSnapshotSourceMs(deadline: SnapshotSourceDeadline, code
2425
return Math.max(1, Math.floor(remainingMs));
2526
}
2627

28+
/** A sleep nobody has asked to end early. */
29+
const NO_STOP = new AbortController().signal;
30+
2731
/**
28-
* Sleeps inside the caller's own deadline. `stop` is for a caller that no longer needs the sleep
29-
* because the work it was waiting on answered elsewhere: the delay resolves instead of burning its
30-
* remaining budget, while an aborted `deadline` stays a typed `cancelled`.
31-
*
32-
* A stop is only ever created by the code that calls this and is always aborted by it afterwards,
33-
* so it needs no already-aborted check and no listener removal; the deadline's signal is the
34-
* caller's and does.
32+
* Sleeps inside the caller's own deadline, so a client abort stays a typed `cancelled` instead of
33+
* arriving as a fresh timeout. `stop` is for a caller that no longer needs the sleep because the work
34+
* it was waiting on answered elsewhere: the delay ends without burning the rest of its budget.
3535
*/
3636
export async function waitForSnapshotSourceDelay(
3737
deadline: SnapshotSourceDeadline,
@@ -40,22 +40,10 @@ export async function waitForSnapshotSourceDelay(
4040
stop?: AbortSignal,
4141
): Promise<void> {
4242
const delayMs = Math.min(requestedMs, remainingSnapshotSourceMs(deadline, code));
43-
await new Promise<void>((resolve, reject) => {
44-
let settled = false;
45-
const timer = setTimeout(() => finish(resolve), delayMs);
46-
const onAbort = () => {
47-
finish(() => reject(snapshotSourceError('cancelled', 'abort-signal')));
48-
};
49-
const onStop = () => finish(resolve);
50-
const finish = (action: () => void) => {
51-
if (settled) return;
52-
settled = true;
53-
clearTimeout(timer);
54-
deadline.signal?.removeEventListener('abort', onAbort);
55-
action();
56-
};
57-
deadline.signal?.addEventListener('abort', onAbort, { once: true });
58-
if (deadline.signal?.aborted) onAbort();
59-
stop?.addEventListener('abort', onStop, { once: true });
43+
await waitForDetachedAttempt({
44+
waitMs: delayMs,
45+
signal: deadline.signal,
46+
stop: stop ?? NO_STOP,
47+
cancelled: () => snapshotSourceError('cancelled', 'abort-signal'),
6048
});
6149
}

packages/platform-apple/src/snapshot-target.ts

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { DeviceInfo } from '@agent-device/kernel/device';
22
import { AppError } from '@agent-device/kernel/errors';
3-
import { createDetachedAttempts } from './detached-attempt.ts';
3+
import { createDetachedAttempts, waitForDetachedAttempt } from './detached-attempt.ts';
44
import { runSimctl } from './core/apps-simctl.ts';
55
import { readSnapshotTargetProcessStartTime } from './snapshot-process.ts';
66

@@ -59,40 +59,13 @@ export function createSimulatorSnapshotTargetResolver(): SimulatorSnapshotTarget
5959
targets.set(key, target);
6060
return target;
6161
},
62-
wait: (waitMs, stop) => waitForDiscoveryAttempt(waitMs, signal, stop),
62+
wait: (waitMs, stop) =>
63+
waitForDetachedAttempt({ waitMs, signal, stop, cancelled: () => signal.reason }),
6364
pending: () => targetError('simulator-target-discovery-pending', device, appBundleId),
6465
});
6566
};
6667
}
6768

68-
/**
69-
* One caller's wait for a discovery it did not start. Resolving is the wait being spent, not the
70-
* discovery failing: a client abort rejects with its own reason so it stays typed `cancelled`, while
71-
* `stop` means this caller already has its answer and is only letting go of the timer.
72-
*
73-
* The stop needs no cleanup here and no already-aborted check: `value()` creates it moments before
74-
* calling this and aborts it in a `finally`, so the listener is gone once that abort fires. The
75-
* caller's signal outlives this wait and does need its listener removed.
76-
*/
77-
function waitForDiscoveryAttempt(
78-
waitMs: number,
79-
signal: AbortSignal,
80-
stop: AbortSignal,
81-
): Promise<void> {
82-
return new Promise<void>((resolve, reject) => {
83-
const onAbort = () => finish(() => reject(signal.reason));
84-
const onStop = () => finish(resolve);
85-
const timer = setTimeout(() => finish(resolve), waitMs);
86-
function finish(settle: () => void): void {
87-
clearTimeout(timer);
88-
signal.removeEventListener('abort', onAbort);
89-
settle();
90-
}
91-
signal.addEventListener('abort', onAbort, { once: true });
92-
stop.addEventListener('abort', onStop, { once: true });
93-
});
94-
}
95-
9669
async function resolveSimulatorSnapshotTarget(
9770
device: DeviceInfo,
9871
appBundleId: string,

0 commit comments

Comments
 (0)