@oxyhq/core 2.4.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.fedcm.js +52 -2
- package/dist/cjs/utils/coldBoot.js +66 -17
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.fedcm.js +52 -2
- package/dist/esm/utils/coldBoot.js +66 -17
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/utils/coldBoot.d.ts +26 -0
- package/package.json +1 -1
- package/src/mixins/OxyServices.fedcm.ts +56 -2
- package/src/mixins/__tests__/fedcm.test.ts +52 -0
- package/src/utils/__tests__/coldBoot.test.ts +150 -0
- package/src/utils/coldBoot.ts +96 -17
|
@@ -305,6 +305,7 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
|
|
|
305
305
|
readonly DEFAULT_CONFIG_URL: "https://auth.oxy.so/fedcm.json";
|
|
306
306
|
readonly FEDCM_TIMEOUT: 15000;
|
|
307
307
|
readonly FEDCM_SILENT_TIMEOUT: 4000;
|
|
308
|
+
readonly FEDCM_ABORT_SETTLE_GRACE_MS: 500;
|
|
308
309
|
/**
|
|
309
310
|
* Check if FedCM is supported in the current browser
|
|
310
311
|
*/
|
|
@@ -83,6 +83,32 @@ export interface RunColdBootOptions<S> {
|
|
|
83
83
|
* the runner does not guard against an observer that itself throws.
|
|
84
84
|
*/
|
|
85
85
|
readonly onStepError?: (id: string, error: unknown) => void;
|
|
86
|
+
/**
|
|
87
|
+
* Optional HARD overall deadline (ms) for the entire ordered step loop —
|
|
88
|
+
* defense-in-depth so a single non-settling step can NEVER hang the whole
|
|
89
|
+
* cold boot forever.
|
|
90
|
+
*
|
|
91
|
+
* Each step's `run()` is raced against the SHARED remaining time. If a step
|
|
92
|
+
* fails to settle before the deadline, the runner abandons the await for that
|
|
93
|
+
* step (reporting it via `onStepDeadline`) and CONTINUES to the next step,
|
|
94
|
+
* each now racing against an already-expired deadline. This is deliberate:
|
|
95
|
+
* the runner keeps iterating so the TERMINAL step (e.g. the `/sso` bounce,
|
|
96
|
+
* whose `run()` performs its side effect synchronously before its first
|
|
97
|
+
* `await`) still gets to fire. A step that has nothing to contribute after
|
|
98
|
+
* the deadline simply doesn't settle and is skipped in turn.
|
|
99
|
+
*
|
|
100
|
+
* Per-step timeouts inside `run()` remain the first line of defense and
|
|
101
|
+
* should keep every step well under this budget on a healthy load; this only
|
|
102
|
+
* trips when one of them regresses (the production FedCM-silent hang). When
|
|
103
|
+
* omitted there is NO overall deadline (unchanged legacy behaviour).
|
|
104
|
+
*/
|
|
105
|
+
readonly overallDeadlineMs?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Optional observer invoked once per step that was abandoned because the
|
|
108
|
+
* overall deadline expired before it settled. Receives the step `id`. Must
|
|
109
|
+
* not throw.
|
|
110
|
+
*/
|
|
111
|
+
readonly onStepDeadline?: (id: string) => void;
|
|
86
112
|
}
|
|
87
113
|
/**
|
|
88
114
|
* Run the ordered cold-boot steps and resolve to the first recovered session,
|
package/package.json
CHANGED
|
@@ -214,6 +214,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
214
214
|
// 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
|
|
215
215
|
public static readonly FEDCM_SILENT_TIMEOUT = 4000; // 4 seconds for silent mediation
|
|
216
216
|
|
|
217
|
+
// Grace margin between the cooperative abort deadline (`FEDCM_SILENT_TIMEOUT`
|
|
218
|
+
// / `FEDCM_TIMEOUT`) and the HARD settle of `requestIdentityCredential`. The
|
|
219
|
+
// abort fires first; a well-behaved browser surfaces its own `AbortError`
|
|
220
|
+
// within this window (keeping the existing error path intact). If — as seen
|
|
221
|
+
// in production — `navigator.credentials.get()` ignores the abort and the
|
|
222
|
+
// awaited promise never settles, the hard settle resolves the request to
|
|
223
|
+
// `null` this many ms later, guaranteeing the cold-boot step always settles.
|
|
224
|
+
// 500ms is ample for a browser to deliver an abort rejection while keeping the
|
|
225
|
+
// worst-case dead wait tight (silent: 4.5s, interactive: 15.5s).
|
|
226
|
+
public static readonly FEDCM_ABORT_SETTLE_GRACE_MS = 500;
|
|
227
|
+
|
|
217
228
|
/**
|
|
218
229
|
* Check if FedCM is supported in the current browser
|
|
219
230
|
*/
|
|
@@ -606,6 +617,37 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
606
617
|
controller.abort();
|
|
607
618
|
}, timeoutMs);
|
|
608
619
|
|
|
620
|
+
// Hard settle guarantee for the timeout path.
|
|
621
|
+
//
|
|
622
|
+
// The `setTimeout` above aborts the request's `AbortController`, which is
|
|
623
|
+
// the COOPERATIVE cancel signal. For a regular `fetch` an abort deterministically
|
|
624
|
+
// rejects the awaited promise — but `navigator.credentials.get()` is a
|
|
625
|
+
// browser-internal FedCM primitive whose abort behaviour is NOT guaranteed
|
|
626
|
+
// to settle the awaited promise in every Chrome version / internal state
|
|
627
|
+
// (the credential request can sit "pending" while the browser-side flow is
|
|
628
|
+
// stuck, ignoring the signal). If that happens, `await credentials.get(...)`
|
|
629
|
+
// never resolves OR rejects, this IIFE hangs forever, and — because this is
|
|
630
|
+
// ONE step of the ordered cold-boot sequence — the whole cold boot hangs and
|
|
631
|
+
// the terminal `/sso` bounce never fires. That was the production hang.
|
|
632
|
+
//
|
|
633
|
+
// `settlePromise` races the credential lookup against a timer that ALWAYS
|
|
634
|
+
// resolves to `null` shortly after the abort deadline. The abort still fires
|
|
635
|
+
// first (so the browser is asked to cancel), but even if `credentials.get`
|
|
636
|
+
// never settles, the race resolves and the step falls through cleanly to the
|
|
637
|
+
// next cold-boot step. The small `FEDCM_ABORT_SETTLE_GRACE_MS` margin gives a
|
|
638
|
+
// well-behaved browser the chance to surface its own AbortError (preserving
|
|
639
|
+
// the existing error path) before we force a clean `null`.
|
|
640
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
641
|
+
const settlePromise = new Promise<FedCMIdentityCredential | null>((resolve) => {
|
|
642
|
+
const ctor = this.constructor as typeof OxyServicesBase & {
|
|
643
|
+
FEDCM_ABORT_SETTLE_GRACE_MS: number;
|
|
644
|
+
};
|
|
645
|
+
settleTimer = setTimeout(() => {
|
|
646
|
+
debug.log('Request hard-settled to null', timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS, 'ms (credentials.get never settled after abort)');
|
|
647
|
+
resolve(null);
|
|
648
|
+
}, timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS);
|
|
649
|
+
});
|
|
650
|
+
|
|
609
651
|
// Normalise the caller's mode to the modern W3C value first. A modern
|
|
610
652
|
// browser accepts it; an older one (Chrome 125–131) rejects it with a
|
|
611
653
|
// synchronous TypeError, in which case we retry with the legacy value.
|
|
@@ -645,7 +687,13 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
645
687
|
debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
|
|
646
688
|
let credential: FedCMIdentityCredential | null;
|
|
647
689
|
try {
|
|
648
|
-
|
|
690
|
+
// Race the browser FedCM lookup against the hard settle guarantee so
|
|
691
|
+
// a `credentials.get` that ignores the abort signal can never hang
|
|
692
|
+
// the cold boot (see `settlePromise`).
|
|
693
|
+
credential = await Promise.race([
|
|
694
|
+
credentials.get(buildCredentialOptions(modernMode)),
|
|
695
|
+
settlePromise,
|
|
696
|
+
]);
|
|
649
697
|
} catch (modeError) {
|
|
650
698
|
// Chrome 125–131 only knows the legacy 'button'/'widget' enum and
|
|
651
699
|
// throws a synchronous TypeError for the modern 'active'/'passive'
|
|
@@ -653,7 +701,10 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
653
701
|
if (modernMode && isUnknownModeEnumError(modeError)) {
|
|
654
702
|
const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
|
|
655
703
|
debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
|
|
656
|
-
credential = await
|
|
704
|
+
credential = await Promise.race([
|
|
705
|
+
credentials.get(buildCredentialOptions(legacyMode)),
|
|
706
|
+
settlePromise,
|
|
707
|
+
]);
|
|
657
708
|
} else {
|
|
658
709
|
throw modeError;
|
|
659
710
|
}
|
|
@@ -680,6 +731,9 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
680
731
|
throw error;
|
|
681
732
|
} finally {
|
|
682
733
|
clearTimeout(timeout);
|
|
734
|
+
if (settleTimer !== undefined) {
|
|
735
|
+
clearTimeout(settleTimer);
|
|
736
|
+
}
|
|
683
737
|
// Only reset the shared lock if it still belongs to THIS request. When an
|
|
684
738
|
// interactive request aborts a slow silent one, the silent settles (and
|
|
685
739
|
// runs this `finally`) AFTER the interactive has already taken over the
|
|
@@ -192,6 +192,58 @@ describe('OxyServices FedCM nonce binding', () => {
|
|
|
192
192
|
|
|
193
193
|
await expect(oxy.silentSignInWithFedCM()).resolves.toBeNull();
|
|
194
194
|
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Production-hang regression. `navigator.credentials.get()` is a
|
|
198
|
+
* browser-internal FedCM primitive that, in some Chrome states, IGNORES its
|
|
199
|
+
* abort signal — the awaited promise never settles. The cooperative
|
|
200
|
+
* `setTimeout`→`controller.abort()` alone cannot unblock that await, so the
|
|
201
|
+
* `fedcm-silent` cold-boot step (and the whole cold boot) hangs forever and
|
|
202
|
+
* the terminal `/sso` bounce never fires.
|
|
203
|
+
*
|
|
204
|
+
* The hard settle guarantee (`Promise.race` against a timer that resolves
|
|
205
|
+
* `null` at `FEDCM_SILENT_TIMEOUT + FEDCM_ABORT_SETTLE_GRACE_MS`) must resolve
|
|
206
|
+
* the request to `null` regardless. This test models the hung primitive and
|
|
207
|
+
* asserts `silentSignInWithFedCM()` settles to `null` within that bound.
|
|
208
|
+
*/
|
|
209
|
+
it('hard-settles to null when navigator.credentials.get never settles (ignores abort)', async () => {
|
|
210
|
+
jest.useFakeTimers();
|
|
211
|
+
try {
|
|
212
|
+
installBrowserGlobals({
|
|
213
|
+
// Never resolves or rejects, and never observes the abort signal —
|
|
214
|
+
// models the production hung FedCM credential request.
|
|
215
|
+
credentialsGet: () => new Promise<unknown>(() => {}),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
219
|
+
localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
|
|
220
|
+
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
221
|
+
if (url === '/fedcm/nonce') {
|
|
222
|
+
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`unexpected request to ${url}`);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
let settled = false;
|
|
228
|
+
const promise = oxy.silentSignInWithFedCM().then((r) => {
|
|
229
|
+
settled = true;
|
|
230
|
+
return r;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Before the hard-settle deadline it must still be pending — proving the
|
|
234
|
+
// primitive really is hung (so the test would fail without the fix).
|
|
235
|
+
await jest.advanceTimersByTimeAsync(4000);
|
|
236
|
+
expect(settled).toBe(false);
|
|
237
|
+
|
|
238
|
+
// FEDCM_SILENT_TIMEOUT (4000) + FEDCM_ABORT_SETTLE_GRACE_MS (500) = 4500.
|
|
239
|
+
await jest.advanceTimersByTimeAsync(600);
|
|
240
|
+
await expect(promise).resolves.toBeNull();
|
|
241
|
+
expect(settled).toBe(true);
|
|
242
|
+
} finally {
|
|
243
|
+
jest.runOnlyPendingTimers();
|
|
244
|
+
jest.useRealTimers();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
195
247
|
});
|
|
196
248
|
|
|
197
249
|
/**
|
|
@@ -223,4 +223,154 @@ describe('runColdBoot', () => {
|
|
|
223
223
|
session: { userId: 'u-ok' },
|
|
224
224
|
});
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
describe('overall deadline (defense-in-depth)', () => {
|
|
228
|
+
beforeEach(() => {
|
|
229
|
+
jest.useFakeTimers();
|
|
230
|
+
});
|
|
231
|
+
afterEach(() => {
|
|
232
|
+
jest.runOnlyPendingTimers();
|
|
233
|
+
jest.useRealTimers();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Reproduces the production hang: a step whose `run()` promise NEVER
|
|
238
|
+
* settles (the FedCM-silent `navigator.credentials.get` that ignored its
|
|
239
|
+
* abort signal). WITHOUT a deadline the whole `runColdBoot` promise hangs
|
|
240
|
+
* forever and the terminal step never runs.
|
|
241
|
+
*/
|
|
242
|
+
it('hangs forever when a step never settles and no deadline is set', async () => {
|
|
243
|
+
const terminalRan = jest.fn();
|
|
244
|
+
let settled = false;
|
|
245
|
+
|
|
246
|
+
const outcomePromise = runColdBoot<TestSession>({
|
|
247
|
+
steps: [
|
|
248
|
+
{
|
|
249
|
+
id: 'never-settles',
|
|
250
|
+
// Never resolves or rejects — models the hung FedCM credential get.
|
|
251
|
+
run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: 'terminal',
|
|
255
|
+
run: async (): Promise<ColdBootStepResult<TestSession>> => {
|
|
256
|
+
terminalRan();
|
|
257
|
+
return { kind: 'skip' };
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
}).then((o) => {
|
|
262
|
+
settled = true;
|
|
263
|
+
return o;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Advance well past any reasonable budget; nothing can unblock it.
|
|
267
|
+
await jest.advanceTimersByTimeAsync(120000);
|
|
268
|
+
|
|
269
|
+
expect(settled).toBe(false);
|
|
270
|
+
expect(terminalRan).not.toHaveBeenCalled();
|
|
271
|
+
|
|
272
|
+
// Avoid a dangling unhandled promise in the test runner.
|
|
273
|
+
void outcomePromise;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* With `overallDeadlineMs` set, the non-settling step is abandoned at the
|
|
278
|
+
* deadline, the runner CONTINUES to the terminal step (so the cross-domain
|
|
279
|
+
* `/sso` bounce equivalent still fires), and the whole boot settles to
|
|
280
|
+
* `unauthenticated` within the bounded budget.
|
|
281
|
+
*/
|
|
282
|
+
it('abandons a non-settling step at the deadline and still runs the terminal step', async () => {
|
|
283
|
+
const terminalRan = jest.fn();
|
|
284
|
+
const onStepDeadline = jest.fn();
|
|
285
|
+
|
|
286
|
+
const outcomePromise = runColdBoot<TestSession>({
|
|
287
|
+
overallDeadlineMs: 5000,
|
|
288
|
+
onStepDeadline,
|
|
289
|
+
steps: [
|
|
290
|
+
{
|
|
291
|
+
id: 'never-settles',
|
|
292
|
+
run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: 'terminal',
|
|
296
|
+
run: async (): Promise<ColdBootStepResult<TestSession>> => {
|
|
297
|
+
terminalRan();
|
|
298
|
+
return { kind: 'skip' };
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await jest.advanceTimersByTimeAsync(5000);
|
|
305
|
+
const outcome = await outcomePromise;
|
|
306
|
+
|
|
307
|
+
expect(onStepDeadline).toHaveBeenCalledWith('never-settles');
|
|
308
|
+
expect(terminalRan).toHaveBeenCalledTimes(1);
|
|
309
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The terminal step's synchronous side effect (the real `sso-bounce`
|
|
314
|
+
* navigates BEFORE its first await) must still execute when the deadline
|
|
315
|
+
* trips on an earlier step — the cross-domain fallback is preserved.
|
|
316
|
+
*/
|
|
317
|
+
it('lets the terminal step fire its synchronous side effect after the deadline trips', async () => {
|
|
318
|
+
const bounced = jest.fn();
|
|
319
|
+
|
|
320
|
+
const outcomePromise = runColdBoot<TestSession>({
|
|
321
|
+
overallDeadlineMs: 3000,
|
|
322
|
+
steps: [
|
|
323
|
+
{
|
|
324
|
+
id: 'never-settles',
|
|
325
|
+
run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
id: 'sso-bounce',
|
|
329
|
+
run: async (): Promise<ColdBootStepResult<TestSession>> => {
|
|
330
|
+
// Synchronous navigation side effect, exactly like the real bounce.
|
|
331
|
+
bounced();
|
|
332
|
+
return { kind: 'skip' };
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await jest.advanceTimersByTimeAsync(3000);
|
|
339
|
+
await outcomePromise;
|
|
340
|
+
|
|
341
|
+
expect(bounced).toHaveBeenCalledTimes(1);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A healthy step that settles BEFORE the deadline still wins and
|
|
346
|
+
* short-circuits — the deadline never alters the happy path.
|
|
347
|
+
*/
|
|
348
|
+
it('a step that settles before the deadline wins and short-circuits', async () => {
|
|
349
|
+
const laterRan = jest.fn();
|
|
350
|
+
|
|
351
|
+
const outcomePromise = runColdBoot<TestSession>({
|
|
352
|
+
overallDeadlineMs: 10000,
|
|
353
|
+
steps: [
|
|
354
|
+
{
|
|
355
|
+
id: 'fast-winner',
|
|
356
|
+
run: async (): Promise<ColdBootStepResult<TestSession>> => {
|
|
357
|
+
await Promise.resolve();
|
|
358
|
+
return { kind: 'session', session: { userId: 'u-fast' } };
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
sessionStep('later', 'u-later', laterRan),
|
|
362
|
+
],
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
366
|
+
const outcome = await outcomePromise;
|
|
367
|
+
|
|
368
|
+
expect(outcome).toEqual({
|
|
369
|
+
kind: 'session',
|
|
370
|
+
via: 'fast-winner',
|
|
371
|
+
session: { userId: 'u-fast' },
|
|
372
|
+
});
|
|
373
|
+
expect(laterRan).not.toHaveBeenCalled();
|
|
374
|
+
});
|
|
375
|
+
});
|
|
226
376
|
});
|
package/src/utils/coldBoot.ts
CHANGED
|
@@ -73,6 +73,16 @@ export type ColdBootOutcome<S> =
|
|
|
73
73
|
| { readonly kind: 'session'; readonly via: string; readonly session: S }
|
|
74
74
|
| { readonly kind: 'unauthenticated' };
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The unique sentinel a step's `run()` resolves to (via the internal race)
|
|
78
|
+
* when the overall cold-boot deadline expires before that step settled. It is
|
|
79
|
+
* NOT a {@link ColdBootStepResult} — the runner detects it by identity and
|
|
80
|
+
* treats it as "this step did not settle in time; move on".
|
|
81
|
+
*
|
|
82
|
+
* @internal
|
|
83
|
+
*/
|
|
84
|
+
const DEADLINE_EXPIRED: unique symbol = Symbol('coldBoot.deadlineExpired');
|
|
85
|
+
|
|
76
86
|
/**
|
|
77
87
|
* Options for {@link runColdBoot}.
|
|
78
88
|
*/
|
|
@@ -85,6 +95,32 @@ export interface RunColdBootOptions<S> {
|
|
|
85
95
|
* the runner does not guard against an observer that itself throws.
|
|
86
96
|
*/
|
|
87
97
|
readonly onStepError?: (id: string, error: unknown) => void;
|
|
98
|
+
/**
|
|
99
|
+
* Optional HARD overall deadline (ms) for the entire ordered step loop —
|
|
100
|
+
* defense-in-depth so a single non-settling step can NEVER hang the whole
|
|
101
|
+
* cold boot forever.
|
|
102
|
+
*
|
|
103
|
+
* Each step's `run()` is raced against the SHARED remaining time. If a step
|
|
104
|
+
* fails to settle before the deadline, the runner abandons the await for that
|
|
105
|
+
* step (reporting it via `onStepDeadline`) and CONTINUES to the next step,
|
|
106
|
+
* each now racing against an already-expired deadline. This is deliberate:
|
|
107
|
+
* the runner keeps iterating so the TERMINAL step (e.g. the `/sso` bounce,
|
|
108
|
+
* whose `run()` performs its side effect synchronously before its first
|
|
109
|
+
* `await`) still gets to fire. A step that has nothing to contribute after
|
|
110
|
+
* the deadline simply doesn't settle and is skipped in turn.
|
|
111
|
+
*
|
|
112
|
+
* Per-step timeouts inside `run()` remain the first line of defense and
|
|
113
|
+
* should keep every step well under this budget on a healthy load; this only
|
|
114
|
+
* trips when one of them regresses (the production FedCM-silent hang). When
|
|
115
|
+
* omitted there is NO overall deadline (unchanged legacy behaviour).
|
|
116
|
+
*/
|
|
117
|
+
readonly overallDeadlineMs?: number;
|
|
118
|
+
/**
|
|
119
|
+
* Optional observer invoked once per step that was abandoned because the
|
|
120
|
+
* overall deadline expired before it settled. Receives the step `id`. Must
|
|
121
|
+
* not throw.
|
|
122
|
+
*/
|
|
123
|
+
readonly onStepDeadline?: (id: string) => void;
|
|
88
124
|
}
|
|
89
125
|
|
|
90
126
|
/**
|
|
@@ -105,32 +141,75 @@ export interface RunColdBootOptions<S> {
|
|
|
105
141
|
export async function runColdBoot<S>(
|
|
106
142
|
options: RunColdBootOptions<S>
|
|
107
143
|
): Promise<ColdBootOutcome<S>> {
|
|
108
|
-
const { steps, onStepError } = options;
|
|
144
|
+
const { steps, onStepError, overallDeadlineMs, onStepDeadline } = options;
|
|
109
145
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
146
|
+
// Arm the optional overall deadline. The budget is SHARED across the whole
|
|
147
|
+
// loop (not reset per step): a single timer resolves a reusable
|
|
148
|
+
// `DEADLINE_EXPIRED` sentinel that every per-step race can observe. Once it
|
|
149
|
+
// fires, later steps race against an already-resolved promise and so never
|
|
150
|
+
// block, yet the loop keeps iterating so the terminal step still fires.
|
|
151
|
+
const deadlineMs =
|
|
152
|
+
typeof overallDeadlineMs === 'number' &&
|
|
153
|
+
Number.isFinite(overallDeadlineMs) &&
|
|
154
|
+
overallDeadlineMs > 0
|
|
155
|
+
? overallDeadlineMs
|
|
156
|
+
: null;
|
|
157
|
+
|
|
158
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
159
|
+
let deadlinePromise: Promise<typeof DEADLINE_EXPIRED> | undefined;
|
|
160
|
+
if (deadlineMs !== null) {
|
|
161
|
+
deadlinePromise = new Promise<typeof DEADLINE_EXPIRED>((resolve) => {
|
|
162
|
+
deadlineTimer = setTimeout(() => resolve(DEADLINE_EXPIRED), deadlineMs);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
for (const step of steps) {
|
|
168
|
+
if (step.enabled) {
|
|
169
|
+
let isEnabled: boolean;
|
|
170
|
+
try {
|
|
171
|
+
isEnabled = step.enabled();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
onStepError?.(step.id, error);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!isEnabled) continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let result: ColdBootStepResult<S> | typeof DEADLINE_EXPIRED;
|
|
113
180
|
try {
|
|
114
|
-
|
|
181
|
+
// Without a deadline: legacy behaviour — await the step directly.
|
|
182
|
+
// With a deadline: race the step against the shared deadline. The
|
|
183
|
+
// step's `run()` still STARTS synchronously up to its first `await`
|
|
184
|
+
// (so a terminal step's synchronous navigation side effect always
|
|
185
|
+
// executes), but a non-settling step can no longer block the loop —
|
|
186
|
+
// the race resolves with the sentinel and we move on.
|
|
187
|
+
result = deadlinePromise
|
|
188
|
+
? await Promise.race([step.run(), deadlinePromise])
|
|
189
|
+
: await step.run();
|
|
115
190
|
} catch (error) {
|
|
116
191
|
onStepError?.(step.id, error);
|
|
117
192
|
continue;
|
|
118
193
|
}
|
|
119
|
-
if (!isEnabled) continue;
|
|
120
|
-
}
|
|
121
194
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
195
|
+
if (result === DEADLINE_EXPIRED) {
|
|
196
|
+
// The deadline tripped before this step settled. Abandon the await and
|
|
197
|
+
// continue: subsequent steps race against the already-resolved deadline
|
|
198
|
+
// (so they cannot block), which lets a terminal side-effect step still
|
|
199
|
+
// run while guaranteeing the loop terminates promptly.
|
|
200
|
+
onStepDeadline?.(step.id);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (result.kind === 'session') {
|
|
205
|
+
return { kind: 'session', via: step.id, session: result.session };
|
|
206
|
+
}
|
|
128
207
|
}
|
|
129
208
|
|
|
130
|
-
|
|
131
|
-
|
|
209
|
+
return { kind: 'unauthenticated' };
|
|
210
|
+
} finally {
|
|
211
|
+
if (deadlineTimer !== undefined) {
|
|
212
|
+
clearTimeout(deadlineTimer);
|
|
132
213
|
}
|
|
133
214
|
}
|
|
134
|
-
|
|
135
|
-
return { kind: 'unauthenticated' };
|
|
136
215
|
}
|