Skip to content

Commit 6adf53c

Browse files
committed
test(ios-snapshot): prove the discovery wait stops when the discovery settles
The stop path inside `waitForDiscoveryAttempt` had no test: the listener registration, the listener removal and the already-aborted check could each be deleted with every suite green. `snapshot-target.test.ts` now runs a deferred spawn through `createSimulatorSnapshotTargetResolver`, lets a second caller join the pending discovery, settles it, and asserts on the two things a leaked wait costs — a timer still pending and a listener still on the caller's `AbortSignal` — with fake timers, so neither is a timing race. `deadline.test.ts` measures the same two properties for the preparation owner and drops its already-aborted case. That case was one of the things the review asked about, and it is unreachable rather than untested: `value()` creates the stop moments before calling and aborts it in a `finally`, so the check can never be true and the stop's `{ once: true }` listener is always released by that abort. Both waits now drop the check and the redundant removal, and keep the cleanup that does matter, which is the caller's own signal: it outlives the wait, and forgetting it would leave a listener per capture.
1 parent 479b11c commit 6adf53c

4 files changed

Lines changed: 75 additions & 37 deletions

File tree

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,49 @@
1-
import assert from 'node:assert/strict';
2-
import { test } from 'vitest';
1+
import { expect, test, vi } from 'vitest';
32
import { createSnapshotSourceDeadline, waitForSnapshotSourceDelay } from './deadline.ts';
43
import { SnapshotSourceError } from './errors.ts';
54

65
const WAIT_CODE = 'bridge-preparation-pending';
76

87
test('a stopped delay returns without spending the rest of the deadline', async () => {
9-
const stop = new AbortController();
10-
const deadline = createSnapshotSourceDeadline(60_000, undefined);
11-
const waiting = waitForSnapshotSourceDelay(deadline, 60_000, WAIT_CODE, stop.signal);
8+
vi.useFakeTimers();
9+
try {
10+
const stop = new AbortController();
11+
const deadline = createSnapshotSourceDeadline(60_000, undefined);
12+
let settled = false;
13+
const waiting = waitForSnapshotSourceDelay(deadline, 60_000, WAIT_CODE, stop.signal).then(
14+
() => {
15+
settled = true;
16+
},
17+
);
1218

13-
const startedAt = Date.now();
14-
stop.abort();
15-
await waiting;
16-
assert.ok(Date.now() - startedAt < 5_000, 'a stopped wait does not sleep out its budget');
17-
});
18-
19-
test('a delay started with its stop already aborted does not sleep', async () => {
20-
const stop = new AbortController();
21-
stop.abort();
22-
const deadline = createSnapshotSourceDeadline(60_000, undefined);
19+
await vi.advanceTimersByTimeAsync(0);
20+
expect(settled).toBe(false);
21+
stop.abort();
22+
await waiting;
2323

24-
const startedAt = Date.now();
25-
await waitForSnapshotSourceDelay(deadline, 60_000, WAIT_CODE, stop.signal);
26-
assert.ok(Date.now() - startedAt < 5_000);
24+
expect(settled).toBe(true);
25+
expect(vi.getTimerCount()).toBe(0);
26+
} finally {
27+
vi.useRealTimers();
28+
}
2729
});
2830

2931
test('an aborted caller signal stays typed cancellation next to a stop', async () => {
30-
const caller = new AbortController();
31-
const deadline = createSnapshotSourceDeadline(60_000, caller.signal);
32-
const waiting = waitForSnapshotSourceDelay(
33-
deadline,
34-
60_000,
35-
WAIT_CODE,
36-
new AbortController().signal,
37-
);
32+
vi.useFakeTimers();
33+
try {
34+
const caller = new AbortController();
35+
const stop = new AbortController();
36+
const deadline = createSnapshotSourceDeadline(60_000, caller.signal);
37+
const waiting = waitForSnapshotSourceDelay(deadline, 60_000, WAIT_CODE, stop.signal);
3838

39-
caller.abort();
40-
await assert.rejects(waiting, (error: unknown) => {
41-
assert.ok(error instanceof SnapshotSourceError);
42-
assert.equal(error.failureKind, 'cancelled');
43-
assert.equal(error.failureCode, 'abort-signal');
44-
return true;
45-
});
39+
caller.abort();
40+
await expect(waiting).rejects.toBeInstanceOf(SnapshotSourceError);
41+
await expect(waiting).rejects.toMatchObject({
42+
failureKind: 'cancelled',
43+
failureCode: 'abort-signal',
44+
});
45+
expect(vi.getTimerCount()).toBe(0);
46+
} finally {
47+
vi.useRealTimers();
48+
}
4649
});

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export function remainingSnapshotSourceMs(deadline: SnapshotSourceDeadline, code
2828
* Sleeps inside the caller's own deadline. `stop` is for a caller that no longer needs the sleep
2929
* because the work it was waiting on answered elsewhere: the delay resolves instead of burning its
3030
* 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.
3135
*/
3236
export async function waitForSnapshotSourceDelay(
3337
deadline: SnapshotSourceDeadline,
@@ -48,10 +52,8 @@ export async function waitForSnapshotSourceDelay(
4852
settled = true;
4953
clearTimeout(timer);
5054
deadline.signal?.removeEventListener('abort', onAbort);
51-
stop?.removeEventListener('abort', onStop);
5255
action();
5356
};
54-
if (stop?.aborted) return finish(resolve);
5557
deadline.signal?.addEventListener('abort', onAbort, { once: true });
5658
if (deadline.signal?.aborted) onAbort();
5759
stop?.addEventListener('abort', onStop, { once: true });

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { listenerCount } from 'node:events';
12
import { expect, test, vi } from 'vitest';
23
import type { DeviceInfo } from '@agent-device/kernel/device';
34
import { createLocalAppleToolProvider, withAppleToolProvider } from './core/tool-provider.ts';
@@ -140,6 +141,11 @@ test('an aborted request cannot reuse a cached target', async () => {
140141
});
141142
});
142143

144+
/** Node counts EventTarget listeners through the same entry point it uses for emitters. */
145+
function abortListeners(signal: AbortSignal): number {
146+
return listenerCount(signal as unknown as Parameters<typeof listenerCount>[0], 'abort');
147+
}
148+
143149
function deferredSpawn(fixture: ReturnType<typeof targetFixture>) {
144150
let release!: () => void;
145151
const released = new Promise<void>((resolve) => {
@@ -263,3 +269,28 @@ test('a failed runtime probe does not release the slot while the launch-job prob
263269
vi.useRealTimers();
264270
}
265271
});
272+
273+
test('a discovery that settles leaves no timer or abort listener behind for its waiters', async () => {
274+
const fixture = targetFixture();
275+
const release = deferredSpawn(fixture);
276+
vi.useFakeTimers();
277+
try {
278+
await withAppleToolProvider(fixture.provider, async () => {
279+
const starting = new AbortController();
280+
const joining = new AbortController();
281+
const first = fixture.resolve(ios, app, starting.signal);
282+
const second = fixture.resolve(ios, app, joining.signal);
283+
await vi.advanceTimersByTimeAsync(0);
284+
285+
release();
286+
await vi.advanceTimersByTimeAsync(0);
287+
await Promise.all([first, second]);
288+
289+
expect(abortListeners(starting.signal)).toBe(0);
290+
expect(abortListeners(joining.signal)).toBe(0);
291+
expect(vi.getTimerCount()).toBe(0);
292+
});
293+
} finally {
294+
vi.useRealTimers();
295+
}
296+
});

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ export function createSimulatorSnapshotTargetResolver(): SimulatorSnapshotTarget
6969
* One caller's wait for a discovery it did not start. Resolving is the wait being spent, not the
7070
* discovery failing: a client abort rejects with its own reason so it stays typed `cancelled`, while
7171
* `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.
7276
*/
7377
function waitForDiscoveryAttempt(
7478
waitMs: number,
@@ -82,10 +86,8 @@ function waitForDiscoveryAttempt(
8286
function finish(settle: () => void): void {
8387
clearTimeout(timer);
8488
signal.removeEventListener('abort', onAbort);
85-
stop.removeEventListener('abort', onStop);
8689
settle();
8790
}
88-
if (stop.aborted) return finish(resolve);
8991
signal.addEventListener('abort', onAbort, { once: true });
9092
stop.addEventListener('abort', onStop, { once: true });
9193
});

0 commit comments

Comments
 (0)