@steve31415/baselib 3.32.0 → 3.33.0
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/app-update-browser/build-update.d.ts +7 -0
- package/dist/app-update-browser/build-update.js +12 -2
- package/dist/sync-browser/client.d.ts +10 -0
- package/dist/sync-browser/client.js +75 -10
- package/dist/sync-browser/first-load-retry.d.ts +58 -0
- package/dist/sync-browser/first-load-retry.js +167 -0
- package/package.json +1 -1
|
@@ -65,10 +65,17 @@ export declare class BuildUpdateMonitor {
|
|
|
65
65
|
/** Deployment push is intentionally only a prompt to ask canonical HTTP;
|
|
66
66
|
* it carries no traffic authority of its own and runs while hidden too. */
|
|
67
67
|
observeDeploymentHint(): void;
|
|
68
|
+
/** Asks canonical HTTP for the traffic build. Nothing to ask once the page
|
|
69
|
+
* is reloading: the navigation aborts the request, so a check then can only
|
|
70
|
+
* log a false failure (digest, 2026-10-06: a page hidden and shown within
|
|
71
|
+
* 0.4 s ran its deferred reload on the hide and this check on the show). */
|
|
68
72
|
checkNow(): Promise<void>;
|
|
69
73
|
private observeTrafficBuild;
|
|
70
74
|
private markAvailable;
|
|
71
75
|
private maybeReload;
|
|
76
|
+
/** A method, not an inline test: TypeScript would keep the narrowing from
|
|
77
|
+
* checkNow's entry across its await. */
|
|
78
|
+
private isReloading;
|
|
72
79
|
private justReturned;
|
|
73
80
|
private setState;
|
|
74
81
|
/** Polls only while visible: an interval in a hidden page would wake it
|
|
@@ -91,8 +91,12 @@ export class BuildUpdateMonitor {
|
|
|
91
91
|
observeDeploymentHint() {
|
|
92
92
|
void this.checkNow();
|
|
93
93
|
}
|
|
94
|
+
/** Asks canonical HTTP for the traffic build. Nothing to ask once the page
|
|
95
|
+
* is reloading: the navigation aborts the request, so a check then can only
|
|
96
|
+
* log a false failure (digest, 2026-10-06: a page hidden and shown within
|
|
97
|
+
* 0.4 s ran its deferred reload on the hide and this check on the show). */
|
|
94
98
|
async checkNow() {
|
|
95
|
-
if (!navigator.onLine)
|
|
99
|
+
if (!navigator.onLine || this.isReloading())
|
|
96
100
|
return;
|
|
97
101
|
const lifecycle = this.lifecycle;
|
|
98
102
|
const requestNumber = ++this.requestNumber;
|
|
@@ -130,7 +134,8 @@ export class BuildUpdateMonitor {
|
|
|
130
134
|
catch (error) {
|
|
131
135
|
if (lifecycle !== this.lifecycle ||
|
|
132
136
|
requestNumber < this.appliedRequestNumber ||
|
|
133
|
-
!navigator.onLine
|
|
137
|
+
!navigator.onLine ||
|
|
138
|
+
this.isReloading())
|
|
134
139
|
return;
|
|
135
140
|
if (!this.checkFailure) {
|
|
136
141
|
this.checkFailure = true;
|
|
@@ -185,6 +190,11 @@ export class BuildUpdateMonitor {
|
|
|
185
190
|
else
|
|
186
191
|
location.reload();
|
|
187
192
|
}
|
|
193
|
+
/** A method, not an inline test: TypeScript would keep the narrowing from
|
|
194
|
+
* checkNow's entry across its await. */
|
|
195
|
+
isReloading() {
|
|
196
|
+
return this.state.kind === 'reloading';
|
|
197
|
+
}
|
|
188
198
|
justReturned() {
|
|
189
199
|
return (document.visibilityState === 'visible' &&
|
|
190
200
|
this.returnedAt !== null &&
|
|
@@ -157,6 +157,8 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
157
157
|
private loopEscalated;
|
|
158
158
|
private reconciliationTail;
|
|
159
159
|
private initialization;
|
|
160
|
+
/** Non-null while the first bootstrap is loading, retries included. */
|
|
161
|
+
private firstLoadRetry;
|
|
160
162
|
private disposed;
|
|
161
163
|
private hiddenSince;
|
|
162
164
|
private hiddenSocketFailed;
|
|
@@ -181,6 +183,14 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
181
183
|
constructor(options: SyncClientOptions<B, C, S, Summary>);
|
|
182
184
|
init(): Promise<void>;
|
|
183
185
|
private initialize;
|
|
186
|
+
/** The first bootstrap. One that gets no HTTP answer (fetch's TypeError: no
|
|
187
|
+
* network, or one that reaches nothing — a tab Android Chrome restored from
|
|
188
|
+
* its cache) is retried until it loads, while init() stays pending and the
|
|
189
|
+
* state says `disconnected`; any other failure rejects, as it always did
|
|
190
|
+
* (docs/SYNC-INIT-RECOVERY-PLAN.md). Rejects with a LifecycleAbort once
|
|
191
|
+
* halted, disposed, or abandoned by a failed outbox. */
|
|
192
|
+
private loadFirstBootstrap;
|
|
193
|
+
private createFirstLoadRetry;
|
|
184
194
|
dispose(): void;
|
|
185
195
|
submit(event: ClientEvent<C>, summary: Summary): Promise<void>;
|
|
186
196
|
/** Current-tab entries only; recovered work is retried silently. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FirstLoadRetry } from './first-load-retry.js';
|
|
1
2
|
const RECONNECT_DELAYS_SECONDS = [1, 2, 4, 8, 16, 30];
|
|
2
3
|
const PING_INTERVAL_MS = 30_000;
|
|
3
4
|
const CONNECT_WATCHDOG_MS = 10_000;
|
|
@@ -91,6 +92,8 @@ export class SyncClient {
|
|
|
91
92
|
loopEscalated = false;
|
|
92
93
|
reconciliationTail = Promise.resolve();
|
|
93
94
|
initialization = null;
|
|
95
|
+
/** Non-null while the first bootstrap is loading, retries included. */
|
|
96
|
+
firstLoadRetry = null;
|
|
94
97
|
disposed = false;
|
|
95
98
|
// Hidden-page state (docs/SYNC-BACKGROUND-PLAN.md).
|
|
96
99
|
hiddenSince = null;
|
|
@@ -158,16 +161,7 @@ export class SyncClient {
|
|
|
158
161
|
throw error;
|
|
159
162
|
});
|
|
160
163
|
this.options.markPerformance?.('bootstrap-start');
|
|
161
|
-
const bootstrapPromise = this.
|
|
162
|
-
.then((bootstrap) => {
|
|
163
|
-
this.options.markPerformance?.('bootstrap-response');
|
|
164
|
-
return bootstrap;
|
|
165
|
-
})
|
|
166
|
-
.catch((error) => {
|
|
167
|
-
if (!isLifecycleAbort(error))
|
|
168
|
-
this.options.logger.error('bootstrap failed', { error });
|
|
169
|
-
throw error;
|
|
170
|
-
});
|
|
164
|
+
const bootstrapPromise = this.loadFirstBootstrap();
|
|
171
165
|
let recovered;
|
|
172
166
|
let bootstrap;
|
|
173
167
|
try {
|
|
@@ -177,6 +171,10 @@ export class SyncClient {
|
|
|
177
171
|
}
|
|
178
172
|
catch (error) {
|
|
179
173
|
this.abortFetches();
|
|
174
|
+
this.firstLoadRetry?.stop();
|
|
175
|
+
// A halt or dispose is a deliberate stop, not a failed load.
|
|
176
|
+
if (this.halted || this.disposed)
|
|
177
|
+
return;
|
|
180
178
|
throw isLifecycleAbort(error) ? error.reason : error;
|
|
181
179
|
}
|
|
182
180
|
if (this.stopBeforeStoreMutation())
|
|
@@ -202,11 +200,77 @@ export class SyncClient {
|
|
|
202
200
|
this.installRuntimeListeners();
|
|
203
201
|
this.connect();
|
|
204
202
|
}
|
|
203
|
+
/** The first bootstrap. One that gets no HTTP answer (fetch's TypeError: no
|
|
204
|
+
* network, or one that reaches nothing — a tab Android Chrome restored from
|
|
205
|
+
* its cache) is retried until it loads, while init() stays pending and the
|
|
206
|
+
* state says `disconnected`; any other failure rejects, as it always did
|
|
207
|
+
* (docs/SYNC-INIT-RECOVERY-PLAN.md). Rejects with a LifecycleAbort once
|
|
208
|
+
* halted, disposed, or abandoned by a failed outbox. */
|
|
209
|
+
async loadFirstBootstrap() {
|
|
210
|
+
const retry = this.createFirstLoadRetry();
|
|
211
|
+
this.firstLoadRetry = retry;
|
|
212
|
+
try {
|
|
213
|
+
for (;;) {
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
this.abortControllers.add(controller);
|
|
216
|
+
retry.attemptStarted(() => controller.abort());
|
|
217
|
+
try {
|
|
218
|
+
const bootstrap = await this.options.fetchBootstrap(controller.signal);
|
|
219
|
+
retry.attemptEnded('loaded');
|
|
220
|
+
this.options.markPerformance?.('bootstrap-response');
|
|
221
|
+
retry.recovered();
|
|
222
|
+
return bootstrap;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
retry.attemptEnded('failed');
|
|
226
|
+
if (this.halted || this.disposed || retry.stopped)
|
|
227
|
+
throw new LifecycleAbort(error);
|
|
228
|
+
// An abort here is the retry replacing a stale attempt: no answer.
|
|
229
|
+
if (!controller.signal.aborted && !(error instanceof TypeError)) {
|
|
230
|
+
this.options.logger.error('bootstrap failed', { reachedServer: true, failures: retry.failures, error });
|
|
231
|
+
this.publish({ kind: 'error', pending: this.pendingCount(), message: 'Failed to load' });
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
retry.failed(error);
|
|
235
|
+
if (retry.failures === 1)
|
|
236
|
+
this.publish({ kind: 'disconnected', pending: this.pendingCount() });
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
this.abortControllers.delete(controller);
|
|
240
|
+
}
|
|
241
|
+
const proceed = await retry.next();
|
|
242
|
+
if (!proceed || this.stopBeforeStoreMutation())
|
|
243
|
+
throw new LifecycleAbort('first load stopped');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
retry.stop();
|
|
248
|
+
if (this.firstLoadRetry === retry)
|
|
249
|
+
this.firstLoadRetry = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
createFirstLoadRetry() {
|
|
253
|
+
const runtime = this.options.runtime;
|
|
254
|
+
return new FirstLoadRetry({
|
|
255
|
+
now: () => this.now(),
|
|
256
|
+
visible: () => runtime.visibility() !== 'hidden',
|
|
257
|
+
setTimer: (callback, delayMs) => this.setTimer(callback, delayMs),
|
|
258
|
+
clearTimer: (timer) => runtime.clearTimeout(timer),
|
|
259
|
+
onOnline: (listener) => runtime.onOnline(listener),
|
|
260
|
+
onVisibilityChange: (listener) => runtime.onVisibilityChange(listener),
|
|
261
|
+
logger: this.options.logger,
|
|
262
|
+
visibleDelaysMs: RECONNECT_DELAYS_SECONDS.map((seconds) => seconds * 1000),
|
|
263
|
+
hiddenDelayMs: this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS,
|
|
264
|
+
escalateAfterMs: OUTAGE_ESCALATE_MS,
|
|
265
|
+
staleAttemptMs: CONNECT_WATCHDOG_MS,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
205
268
|
dispose() {
|
|
206
269
|
if (this.disposed)
|
|
207
270
|
return;
|
|
208
271
|
this.disposed = true;
|
|
209
272
|
this.abortFetches();
|
|
273
|
+
this.firstLoadRetry?.stop();
|
|
210
274
|
for (const remove of this.removeListeners.splice(0))
|
|
211
275
|
remove();
|
|
212
276
|
this.clearAllTimers();
|
|
@@ -1488,6 +1552,7 @@ export class SyncClient {
|
|
|
1488
1552
|
return;
|
|
1489
1553
|
this.halted = true;
|
|
1490
1554
|
this.abortFetches();
|
|
1555
|
+
this.firstLoadRetry?.stop();
|
|
1491
1556
|
this.clearAllTimers();
|
|
1492
1557
|
const socket = this.socket;
|
|
1493
1558
|
this.socket = null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Logger } from '../log-browser.js';
|
|
2
|
+
type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
|
|
3
|
+
export interface FirstLoadRetryOptions {
|
|
4
|
+
now(): number;
|
|
5
|
+
visible(): boolean;
|
|
6
|
+
setTimer(callback: () => void, delayMs: number): TimeoutHandle;
|
|
7
|
+
clearTimer(timer: TimeoutHandle): void;
|
|
8
|
+
onOnline(listener: () => void): () => void;
|
|
9
|
+
onVisibilityChange(listener: () => void): () => void;
|
|
10
|
+
logger: Pick<Logger, 'info' | 'warn' | 'error'>;
|
|
11
|
+
/** The wait after each failure while visible; the last one repeats. */
|
|
12
|
+
visibleDelaysMs: readonly number[];
|
|
13
|
+
hiddenDelayMs: number;
|
|
14
|
+
/** Not loaded after this much visible, on-time loading: one ERROR. */
|
|
15
|
+
escalateAfterMs: number;
|
|
16
|
+
/** A trigger replaces an attempt that has run this long; an attempt this
|
|
17
|
+
* long is logged (INFO `bootstrap slow`). */
|
|
18
|
+
staleAttemptMs: number;
|
|
19
|
+
}
|
|
20
|
+
export declare class FirstLoadRetry {
|
|
21
|
+
private readonly options;
|
|
22
|
+
private failureCount;
|
|
23
|
+
private readonly startedAt;
|
|
24
|
+
/** Ladder position of the next visible wait; a return restarts it. */
|
|
25
|
+
private step;
|
|
26
|
+
private escalated;
|
|
27
|
+
private escalateTimer;
|
|
28
|
+
private waitTimer;
|
|
29
|
+
private wake;
|
|
30
|
+
private attempt;
|
|
31
|
+
/** A trigger arrived during the running attempt: if it fails, no wait. */
|
|
32
|
+
private triggered;
|
|
33
|
+
private stoppedFlag;
|
|
34
|
+
private readonly removeListeners;
|
|
35
|
+
/** Created as the first attempt starts. */
|
|
36
|
+
constructor(options: FirstLoadRetryOptions);
|
|
37
|
+
get failures(): number;
|
|
38
|
+
get stopped(): boolean;
|
|
39
|
+
/** `abort` cancels this attempt (its fetch's AbortSignal). */
|
|
40
|
+
attemptStarted(abort: () => void): void;
|
|
41
|
+
attemptEnded(outcome: 'loaded' | 'failed'): void;
|
|
42
|
+
/** An attempt failed without an HTTP answer (or was replaced as stale).
|
|
43
|
+
* One WARN per streak. */
|
|
44
|
+
failed(error: unknown): void;
|
|
45
|
+
/** Resolves true when the next attempt is due (timer, `online`, a return
|
|
46
|
+
* to visible, or a trigger during the attempt that just failed), false
|
|
47
|
+
* once stopped. */
|
|
48
|
+
next(): Promise<boolean>;
|
|
49
|
+
recovered(): void;
|
|
50
|
+
/** Removes listeners and timers and ends a running wait (`next` → false). */
|
|
51
|
+
stop(): void;
|
|
52
|
+
private trigger;
|
|
53
|
+
private release;
|
|
54
|
+
/** ERROR only once the load has lasted `escalateAfterMs` in view, on time;
|
|
55
|
+
* re-armed on each return. */
|
|
56
|
+
private armEscalation;
|
|
57
|
+
}
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// First-load recovery (docs/SYNC-INIT-RECOVERY-PLAN.md): while the first
|
|
2
|
+
// bootstrap has not loaded, this decides when the next attempt is due and
|
|
3
|
+
// owns the streak's logs. SyncClient makes the attempts, one at a time; a
|
|
4
|
+
// trigger (`online`, a return to visible) never starts a second attempt next
|
|
5
|
+
// to a running one: it ends the wait between attempts, or marks the running
|
|
6
|
+
// attempt so that its failure retries at once, or, when that attempt has
|
|
7
|
+
// already run `staleAttemptMs`, aborts it so a fresh one starts.
|
|
8
|
+
/** A timer this late ran after the device slept or the page was frozen. */
|
|
9
|
+
const LATE_TIMER_MS = 1_000;
|
|
10
|
+
/** An escalation timer this late: that time was not spent loading in front
|
|
11
|
+
* of the user (as the socket's `sync outage ongoing`). */
|
|
12
|
+
const ESCALATE_LATE_TOLERANCE_MS = 5_000;
|
|
13
|
+
export class FirstLoadRetry {
|
|
14
|
+
options;
|
|
15
|
+
failureCount = 0;
|
|
16
|
+
startedAt;
|
|
17
|
+
/** Ladder position of the next visible wait; a return restarts it. */
|
|
18
|
+
step = 0;
|
|
19
|
+
escalated = false;
|
|
20
|
+
escalateTimer = null;
|
|
21
|
+
waitTimer = null;
|
|
22
|
+
wake = null;
|
|
23
|
+
attempt = null;
|
|
24
|
+
/** A trigger arrived during the running attempt: if it fails, no wait. */
|
|
25
|
+
triggered = false;
|
|
26
|
+
stoppedFlag = false;
|
|
27
|
+
removeListeners;
|
|
28
|
+
/** Created as the first attempt starts. */
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.startedAt = options.now();
|
|
32
|
+
this.removeListeners = [
|
|
33
|
+
options.onOnline(() => this.trigger()),
|
|
34
|
+
options.onVisibilityChange(() => {
|
|
35
|
+
if (!this.options.visible())
|
|
36
|
+
return;
|
|
37
|
+
this.step = 0;
|
|
38
|
+
this.armEscalation();
|
|
39
|
+
this.trigger();
|
|
40
|
+
}),
|
|
41
|
+
];
|
|
42
|
+
this.armEscalation();
|
|
43
|
+
}
|
|
44
|
+
get failures() {
|
|
45
|
+
return this.failureCount;
|
|
46
|
+
}
|
|
47
|
+
get stopped() {
|
|
48
|
+
return this.stoppedFlag;
|
|
49
|
+
}
|
|
50
|
+
/** `abort` cancels this attempt (its fetch's AbortSignal). */
|
|
51
|
+
attemptStarted(abort) {
|
|
52
|
+
this.attempt = { startedAt: this.options.now(), abort };
|
|
53
|
+
this.triggered = false;
|
|
54
|
+
}
|
|
55
|
+
attemptEnded(outcome) {
|
|
56
|
+
const attempt = this.attempt;
|
|
57
|
+
this.attempt = null;
|
|
58
|
+
if (attempt === null || this.stoppedFlag)
|
|
59
|
+
return;
|
|
60
|
+
const attemptMs = this.options.now() - attempt.startedAt;
|
|
61
|
+
if (attemptMs >= this.options.staleAttemptMs) {
|
|
62
|
+
this.options.logger.info('bootstrap slow', { attemptMs, outcome, failures: this.failureCount });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** An attempt failed without an HTTP answer (or was replaced as stale).
|
|
66
|
+
* One WARN per streak. */
|
|
67
|
+
failed(error) {
|
|
68
|
+
this.failureCount++;
|
|
69
|
+
if (this.failureCount > 1)
|
|
70
|
+
return;
|
|
71
|
+
this.options.logger.warn('bootstrap failed', { reachedServer: false, hidden: !this.options.visible(), error });
|
|
72
|
+
}
|
|
73
|
+
/** Resolves true when the next attempt is due (timer, `online`, a return
|
|
74
|
+
* to visible, or a trigger during the attempt that just failed), false
|
|
75
|
+
* once stopped. */
|
|
76
|
+
next() {
|
|
77
|
+
if (this.stoppedFlag)
|
|
78
|
+
return Promise.resolve(false);
|
|
79
|
+
if (this.triggered) {
|
|
80
|
+
this.triggered = false;
|
|
81
|
+
return Promise.resolve(true);
|
|
82
|
+
}
|
|
83
|
+
const visible = this.options.visible();
|
|
84
|
+
const delays = this.options.visibleDelaysMs;
|
|
85
|
+
const delayMs = visible ? delays[Math.min(this.step, delays.length - 1)] : this.options.hiddenDelayMs;
|
|
86
|
+
if (visible)
|
|
87
|
+
this.step++;
|
|
88
|
+
const dueAt = this.options.now() + delayMs;
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
this.wake = resolve;
|
|
91
|
+
this.waitTimer = this.options.setTimer(() => {
|
|
92
|
+
this.waitTimer = null;
|
|
93
|
+
// Late: the device slept or the page was frozen — a phone whose
|
|
94
|
+
// screen went off with the page in front sends no visibilitychange.
|
|
95
|
+
// Waking is a return: restart the ladder.
|
|
96
|
+
if (this.options.now() - dueAt >= LATE_TIMER_MS)
|
|
97
|
+
this.step = 0;
|
|
98
|
+
this.release(true);
|
|
99
|
+
}, delayMs);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
recovered() {
|
|
103
|
+
if (this.failureCount > 0) {
|
|
104
|
+
this.options.logger.info('bootstrap recovered', {
|
|
105
|
+
failures: this.failureCount,
|
|
106
|
+
loadingForMs: Math.max(0, this.options.now() - this.startedAt),
|
|
107
|
+
escalated: this.escalated,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
this.stop();
|
|
111
|
+
}
|
|
112
|
+
/** Removes listeners and timers and ends a running wait (`next` → false). */
|
|
113
|
+
stop() {
|
|
114
|
+
if (this.stoppedFlag)
|
|
115
|
+
return;
|
|
116
|
+
this.stoppedFlag = true;
|
|
117
|
+
for (const remove of this.removeListeners.splice(0))
|
|
118
|
+
remove();
|
|
119
|
+
if (this.escalateTimer !== null)
|
|
120
|
+
this.options.clearTimer(this.escalateTimer);
|
|
121
|
+
this.escalateTimer = null;
|
|
122
|
+
this.release(false);
|
|
123
|
+
}
|
|
124
|
+
trigger() {
|
|
125
|
+
if (this.wake !== null) {
|
|
126
|
+
this.release(true);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const attempt = this.attempt;
|
|
130
|
+
if (attempt === null)
|
|
131
|
+
return;
|
|
132
|
+
this.triggered = true;
|
|
133
|
+
// Probably hung: on a dead path a request can wait out the OS connect
|
|
134
|
+
// timeout (minutes), and this trigger says the network or page changed.
|
|
135
|
+
if (this.options.now() - attempt.startedAt >= this.options.staleAttemptMs)
|
|
136
|
+
attempt.abort();
|
|
137
|
+
}
|
|
138
|
+
release(proceed) {
|
|
139
|
+
if (this.waitTimer !== null)
|
|
140
|
+
this.options.clearTimer(this.waitTimer);
|
|
141
|
+
this.waitTimer = null;
|
|
142
|
+
const wake = this.wake;
|
|
143
|
+
this.wake = null;
|
|
144
|
+
wake?.(proceed);
|
|
145
|
+
}
|
|
146
|
+
/** ERROR only once the load has lasted `escalateAfterMs` in view, on time;
|
|
147
|
+
* re-armed on each return. */
|
|
148
|
+
armEscalation() {
|
|
149
|
+
if (this.stoppedFlag || this.escalated || !this.options.visible())
|
|
150
|
+
return;
|
|
151
|
+
if (this.escalateTimer !== null)
|
|
152
|
+
this.options.clearTimer(this.escalateTimer);
|
|
153
|
+
const armedAt = this.options.now();
|
|
154
|
+
this.escalateTimer = this.options.setTimer(() => {
|
|
155
|
+
this.escalateTimer = null;
|
|
156
|
+
const lateMs = this.options.now() - armedAt - this.options.escalateAfterMs;
|
|
157
|
+
if (!this.options.visible() || lateMs > ESCALATE_LATE_TOLERANCE_MS)
|
|
158
|
+
return;
|
|
159
|
+
this.escalated = true;
|
|
160
|
+
this.options.logger.error('bootstrap still failing', {
|
|
161
|
+
failures: this.failureCount,
|
|
162
|
+
loadingForMs: Math.max(0, this.options.now() - this.startedAt),
|
|
163
|
+
attemptMs: this.attempt === null ? null : this.options.now() - this.attempt.startedAt,
|
|
164
|
+
});
|
|
165
|
+
}, this.options.escalateAfterMs);
|
|
166
|
+
}
|
|
167
|
+
}
|
package/package.json
CHANGED