Skip to content

Commit 5391485

Browse files
committed
fix(ios-snapshot): prepare the AX bridge off the capture deadline
A cold host pays for the AX bridge inside the capture that happens to ask for it first, so the first capture of a session spent its whole deadline in a toolchain probe or a clang build and the lane lost the `wait` that was polling for a screen (#2491). Preparation is now a detached single-flight per runtime: the first capture that finds it running waits out a short budget and is served by the XCTest runner, the build keeps going for whoever asks next, and a failed attempt is answered as-is until a retry window measured from the failure expires. The grant is once per attempt rather than once per capture. A `wait` poll cycles every 200 ms or so, and a budget paid per poll would cost a cold build more in captured polls than the build itself costs, with every one of those captures ending up on the runner anyway. Both this and the pending target discovery in `snapshot-target.ts` are the same shape — one attempt per key, detached from whoever started it, a bounded wait, a typed answer while it runs — and they had already started to forget failures differently. One seam owns that shape; each owner declares its own wait budget, wait grant, retry window and pending error, and keeps its own cache of a finished value, because only the owner knows when that value stops being valid. The preparation carries the signal `close()` aborts. Once a capture has answered, no request owns this attempt any more: without that, `xcrun` could keep running for two minutes past shutdown and its cache write would land in a directory belonging to a source that is gone.
1 parent aba54b4 commit 5391485

11 files changed

Lines changed: 932 additions & 81 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { createDetachedAttempts } from './detached-attempt.ts';
4+
5+
const PENDING = new Error('still-running');
6+
7+
/** A wait whose slice is instantly spent, which is how a caller reports "still running". */
8+
const spentWait = (calls: { count: number }) => async () => {
9+
calls.count += 1;
10+
};
11+
12+
test('one attempt answers every caller that arrives while it runs', async () => {
13+
const attempts = createDetachedAttempts<number>({ waitMs: 20 });
14+
let release = () => {};
15+
const running = new Promise<void>((resolve) => {
16+
release = resolve;
17+
});
18+
let starts = 0;
19+
const waitCalls = { count: 0 };
20+
const params = {
21+
start: async () => {
22+
starts += 1;
23+
await running;
24+
return 7;
25+
},
26+
wait: spentWait(waitCalls),
27+
pending: () => PENDING,
28+
};
29+
30+
const first = await attempts.value('key', params).then(() => undefined, identity);
31+
const second = await attempts.value('key', params).then(() => undefined, identity);
32+
33+
assert.equal(first, PENDING);
34+
assert.equal(second, PENDING);
35+
assert.equal(starts, 1);
36+
37+
release();
38+
await settle();
39+
assert.equal(await attempts.value('key', params), 7);
40+
});
41+
42+
test('the first-caller grant spends the wait budget once per attempt', async () => {
43+
const attempts = createDetachedAttempts<number>({ waitMs: 20, waitGrant: 'first-caller' });
44+
let release = () => {};
45+
const running = new Promise<void>((resolve) => {
46+
release = resolve;
47+
});
48+
const waitCalls = { count: 0 };
49+
const params = {
50+
start: async () => {
51+
await running;
52+
return 7;
53+
},
54+
wait: spentWait(waitCalls),
55+
pending: () => PENDING,
56+
};
57+
58+
await assert.rejects(attempts.value('key', params), (error) => error === PENDING);
59+
await assert.rejects(attempts.value('key', params), (error) => error === PENDING);
60+
assert.equal(waitCalls.count, 1);
61+
62+
release();
63+
await settle();
64+
assert.equal(await attempts.value('key', params), 7);
65+
});
66+
67+
test('the every-caller grant lets each capture wait for the same attempt', async () => {
68+
const attempts = createDetachedAttempts<number>({ waitMs: 20 });
69+
let release = () => {};
70+
const running = new Promise<void>((resolve) => {
71+
release = resolve;
72+
});
73+
const waitCalls = { count: 0 };
74+
const params = {
75+
start: async () => {
76+
await running;
77+
return 7;
78+
},
79+
wait: spentWait(waitCalls),
80+
pending: () => PENDING,
81+
};
82+
83+
await assert.rejects(attempts.value('key', params), (error) => error === PENDING);
84+
await assert.rejects(attempts.value('key', params), (error) => error === PENDING);
85+
assert.equal(waitCalls.count, 2);
86+
release();
87+
});
88+
89+
test('a caller that waits while the attempt settles is served the value', async () => {
90+
const attempts = createDetachedAttempts<number>({ waitMs: 20 });
91+
let release = () => {};
92+
const running = new Promise<void>((resolve) => {
93+
release = resolve;
94+
});
95+
const started = attempts.value('key', {
96+
start: async () => {
97+
await running;
98+
return 7;
99+
},
100+
wait: async () => {
101+
await running;
102+
},
103+
pending: () => PENDING,
104+
});
105+
106+
release();
107+
assert.equal(await started, 7);
108+
});
109+
110+
test('the retry window is measured from the failure, not from the start of the attempt', async () => {
111+
let clockMs = 0;
112+
const attempts = createDetachedAttempts<number>({
113+
waitMs: 20,
114+
retryAfterMs: 60_000,
115+
now: () => clockMs,
116+
});
117+
let starts = 0;
118+
const params = {
119+
start: async () => {
120+
starts += 1;
121+
// A cold host can spend the whole window building before it fails at all.
122+
clockMs += 90_000;
123+
throw new Error('build-failed');
124+
},
125+
wait: async () => {},
126+
pending: () => PENDING,
127+
};
128+
129+
await assert.rejects(attempts.value('key', params), /build-failed/);
130+
assert.equal(starts, 1);
131+
await assert.rejects(attempts.value('key', params), /build-failed/);
132+
assert.equal(starts, 1);
133+
134+
clockMs += 60_001;
135+
await assert.rejects(attempts.value('key', params), /build-failed/);
136+
assert.equal(starts, 2);
137+
});
138+
139+
test('without a retry window the next caller starts a fresh attempt', async () => {
140+
const attempts = createDetachedAttempts<number>({ waitMs: 20 });
141+
let starts = 0;
142+
const params = {
143+
start: async () => {
144+
starts += 1;
145+
throw new Error('probe-failed');
146+
},
147+
wait: async () => {},
148+
pending: () => PENDING,
149+
};
150+
151+
await assert.rejects(attempts.value('key', params), /probe-failed/);
152+
await assert.rejects(attempts.value('key', params), /probe-failed/);
153+
assert.equal(starts, 2);
154+
});
155+
156+
test('close aborts a running attempt and the next caller starts its own', async () => {
157+
const attempts = createDetachedAttempts<number>({ waitMs: 20 });
158+
let release = () => {};
159+
const running = new Promise<void>((resolve) => {
160+
release = resolve;
161+
});
162+
const seen: AbortSignal[] = [];
163+
const params = {
164+
start: async (signal: AbortSignal) => {
165+
seen.push(signal);
166+
await running;
167+
return 7;
168+
},
169+
wait: async () => {},
170+
pending: () => PENDING,
171+
};
172+
173+
await assert.rejects(attempts.value('key', params), (error) => error === PENDING);
174+
assert.equal(seen[0]?.aborted, false);
175+
176+
attempts.close();
177+
assert.equal(seen[0]?.aborted, true);
178+
179+
await assert.rejects(attempts.value('key', params), (error) => error === PENDING);
180+
assert.equal(seen.length, 2);
181+
release();
182+
});
183+
184+
test('a wait that rejects with the caller own cancellation stays that cancellation', async () => {
185+
const attempts = createDetachedAttempts<number>({ waitMs: 20 });
186+
const cancelled = new Error('cancelled-by-request');
187+
188+
await assert.rejects(
189+
attempts.value('key', {
190+
start: async () => 7,
191+
wait: () => Promise.reject(cancelled),
192+
pending: () => PENDING,
193+
}),
194+
(error) => error === cancelled,
195+
);
196+
});
197+
198+
test('an attempt that throws before awaiting is reported as its own failure', async () => {
199+
const attempts = createDetachedAttempts<number>({ waitMs: 20 });
200+
201+
await assert.rejects(
202+
attempts.value('key', {
203+
start: () => {
204+
throw new Error('source-missing');
205+
},
206+
wait: async () => {},
207+
pending: () => PENDING,
208+
}),
209+
/source-missing/,
210+
);
211+
});
212+
213+
function identity(error: unknown): unknown {
214+
return error;
215+
}
216+
217+
/** Lets a released attempt reach the settle handler before the next read. */
218+
function settle(): Promise<void> {
219+
return new Promise((resolve) => {
220+
setTimeout(resolve, 0);
221+
});
222+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Work that has to outlive the request that started it.
3+
*
4+
* A capture that gives up on a slow probe or build must not take that work down with it: the next
5+
* capture wants the same result, and on a cold host the cost is host setup rather than request work
6+
* (#2491). So one attempt runs per key, detached from whoever started it, and every caller that
7+
* arrives while it runs is answered from that attempt instead of launching a second one.
8+
*
9+
* What each owner declares — rather than re-implements — is how long a caller may wait for an
10+
* attempt (`waitMs`), whether every waiting caller may spend that budget or only the first one to
11+
* arrive (`waitGrant`), how long a failed attempt keeps being answered as-is before another attempt
12+
* starts (`retryAfterMs`, omitted for work worth retrying at once), and what an attempt that is
13+
* still running costs the caller (`pending`). A settled success leaves the table, because only the
14+
* owner knows when its value stops being valid; that owner caches it and invalidates it on its own
15+
* terms. `close()` is for owners with a lifecycle: it aborts the running attempts so a build or
16+
* probe cannot outlive the source that started it.
17+
*/
18+
19+
export type DetachedAttempts<Value> = Readonly<{
20+
/**
21+
* The value for `key`, starting a detached attempt when none is running or the last failure is
22+
* old enough to retry. Resolves when the attempt is ready, throws its failure, or throws
23+
* `pending()` when it is still running once this caller's wait is spent.
24+
*/
25+
value(
26+
key: string,
27+
params: Readonly<{
28+
/** Called only when no attempt is running; must honour `signal` for `close()` to reach it. */
29+
start: (signal: AbortSignal) => Promise<Value>;
30+
/**
31+
* Sleeps inside the caller's own deadline, so a client abort rejects this call with the
32+
* caller's typed cancellation instead of reporting a fresh `pending`.
33+
*/
34+
wait: (waitMs: number) => Promise<void>;
35+
pending: () => Error;
36+
}>,
37+
): Promise<Value>;
38+
/** Aborts every running attempt and forgets all of them. */
39+
close(): void;
40+
}>;
41+
42+
/** Whether a caller that arrives while an attempt runs may wait for it. */
43+
export type DetachedAttemptWaitGrant =
44+
/** Each caller spends the wait budget, for work measured in seconds that every caller would
45+
* rather wait out than give up on. */
46+
| 'every-caller'
47+
/** Only the first caller to arrive spends it, for work that outlives any one capture: a poll
48+
* loop that has already paid the budget once should not pay it again per poll (#2491). */
49+
| 'first-caller';
50+
51+
type Attempt<Value> = {
52+
status: 'pending' | 'ready' | 'failed';
53+
value: Value;
54+
error: unknown;
55+
/** When the attempt stopped being pending. A retry window is measured from here, so an attempt
56+
* that failed late is not already eligible for a retry the moment it fails. */
57+
settledAtMs: number;
58+
waitSpent: boolean;
59+
controller: AbortController;
60+
settled: Promise<void>;
61+
};
62+
63+
export function createDetachedAttempts<Value>(
64+
deps: Readonly<{
65+
waitMs: number;
66+
waitGrant?: DetachedAttemptWaitGrant;
67+
retryAfterMs?: number;
68+
/** The clock the retry window is measured against; injected so a test can move time. */
69+
now?: () => number;
70+
}>,
71+
): DetachedAttempts<Value> {
72+
const now = deps.now ?? Date.now;
73+
const waitGrant = deps.waitGrant ?? 'every-caller';
74+
const attempts = new Map<string, Attempt<Value>>();
75+
76+
const startAttempt = (
77+
key: string,
78+
start: (signal: AbortSignal) => Promise<Value>,
79+
): Attempt<Value> => {
80+
const attempt: Attempt<Value> = {
81+
status: 'pending',
82+
value: undefined as Value,
83+
error: undefined,
84+
settledAtMs: now(),
85+
waitSpent: false,
86+
controller: new AbortController(),
87+
settled: undefined as unknown as Promise<void>,
88+
};
89+
let settle = () => {};
90+
attempt.settled = new Promise<void>((resolve) => {
91+
settle = resolve;
92+
});
93+
attempts.set(key, attempt);
94+
const finish = (status: 'ready' | 'failed', outcome: Value | unknown) => {
95+
attempt.status = status;
96+
if (status === 'ready') attempt.value = outcome as Value;
97+
else attempt.error = outcome;
98+
attempt.settledAtMs = now();
99+
// A success is the owner's to cache, because only the owner knows when the value stops being
100+
// valid. A failure stays only as long as it should keep being answered as-is.
101+
if (status === 'ready' || deps.retryAfterMs === undefined) {
102+
if (attempts.get(key) === attempt) attempts.delete(key);
103+
}
104+
settle();
105+
};
106+
// The attempt outlives every caller that asked for it, so a rejection nobody is awaiting yet
107+
// must never surface as an unhandled rejection; later callers read it from the record.
108+
try {
109+
void start(attempt.controller.signal).then(
110+
(value) => finish('ready', value),
111+
(error: unknown) => finish('failed', error),
112+
);
113+
} catch (error) {
114+
finish('failed', error);
115+
}
116+
return attempt;
117+
};
118+
119+
const currentAttempt = (
120+
key: string,
121+
start: (signal: AbortSignal) => Promise<Value>,
122+
): Attempt<Value> => {
123+
const attempt = attempts.get(key);
124+
if (!attempt) return startAttempt(key, start);
125+
if (attempt.status !== 'failed') return attempt;
126+
const failedAgoMs = now() - attempt.settledAtMs;
127+
if (deps.retryAfterMs === undefined || failedAgoMs >= deps.retryAfterMs) {
128+
return startAttempt(key, start);
129+
}
130+
return attempt;
131+
};
132+
133+
return {
134+
value: async (key, params) => {
135+
const attempt = currentAttempt(key, params.start);
136+
if (attempt.status === 'pending' && (waitGrant === 'every-caller' || !attempt.waitSpent)) {
137+
// Marked before waiting: two captures arriving together must not each spend the budget.
138+
attempt.waitSpent = true;
139+
await Promise.race([attempt.settled, params.wait(deps.waitMs)]);
140+
}
141+
if (attempt.status === 'ready') return attempt.value;
142+
if (attempt.status === 'failed') throw attempt.error;
143+
throw params.pending();
144+
},
145+
close: () => {
146+
const running = [...attempts.values()];
147+
attempts.clear();
148+
for (const attempt of running) attempt.controller.abort();
149+
},
150+
};
151+
}

0 commit comments

Comments
 (0)