@peerbit/shared-log 13.2.33 → 13.2.35
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/src/index.d.ts +7 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +284 -91
- package/dist/src/index.js.map +1 -1
- package/dist/src/peer-session.d.ts +2 -1
- package/dist/src/peer-session.d.ts.map +1 -1
- package/dist/src/peer-session.js +8 -2
- package/dist/src/peer-session.js.map +1 -1
- package/dist/src/replication-announcement.d.ts +1 -0
- package/dist/src/replication-announcement.d.ts.map +1 -1
- package/dist/src/replication-announcement.js +16 -2
- package/dist/src/replication-announcement.js.map +1 -1
- package/dist/src/replication-info-v2-receive.d.ts +84 -4
- package/dist/src/replication-info-v2-receive.d.ts.map +1 -1
- package/dist/src/replication-info-v2-receive.js +441 -7
- package/dist/src/replication-info-v2-receive.js.map +1 -1
- package/dist/src/replication-info-v2-send.d.ts +20 -0
- package/dist/src/replication-info-v2-send.d.ts.map +1 -1
- package/dist/src/replication-info-v2-send.js +177 -55
- package/dist/src/replication-info-v2-send.js.map +1 -1
- package/dist/src/sync/factory.js +1 -1
- package/dist/src/sync/factory.js.map +1 -1
- package/package.json +11 -11
- package/src/index.ts +350 -123
- package/src/peer-session.ts +8 -0
- package/src/replication-announcement.ts +17 -2
- package/src/replication-info-v2-receive.ts +634 -11
- package/src/replication-info-v2-send.ts +227 -65
- package/src/sync/factory.ts +1 -1
|
@@ -25,6 +25,7 @@ const DEFAULT_MAX_REQUEST_RETRY_MS = 30_000;
|
|
|
25
25
|
const DEFAULT_REQUEST_MAX_ATTEMPTS = 7;
|
|
26
26
|
const DEFAULT_LEGACY_FALLBACK_DELAY_MS = 5_000;
|
|
27
27
|
const MAX_U64 = (1n << 64n) - 1n;
|
|
28
|
+
const MAX_BACKOFF_EXPONENT = 20;
|
|
28
29
|
|
|
29
30
|
const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => {
|
|
30
31
|
if (left.byteLength !== right.byteLength) {
|
|
@@ -64,6 +65,7 @@ export type ReplicationInfoV2ReceivePhase =
|
|
|
64
65
|
|
|
65
66
|
type LocalCapabilityReady = {
|
|
66
67
|
peerHash: string;
|
|
68
|
+
receiveEpoch: object | null;
|
|
67
69
|
receiverTransportSession: bigint;
|
|
68
70
|
/**
|
|
69
71
|
* The local capability envelope and the later RequestV2 envelope use the
|
|
@@ -71,6 +73,45 @@ type LocalCapabilityReady = {
|
|
|
71
73
|
* be strictly newer, so never construct it in this captured millisecond.
|
|
72
74
|
*/
|
|
73
75
|
requestNotBeforeMs: number;
|
|
76
|
+
advertisement?: ReplicationInfoV2LocalCapabilityAdvertisement;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type LocalCapabilityReadyProperties = {
|
|
80
|
+
peerHash: string;
|
|
81
|
+
peerSession: object;
|
|
82
|
+
receiveEpoch: object | null;
|
|
83
|
+
receiverTransportSession: bigint;
|
|
84
|
+
requestNotBeforeMs: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type ReplicationInfoV2LocalCapabilityAdvertisementHandle = {
|
|
88
|
+
firstAttempt: Promise<void>;
|
|
89
|
+
releaseLegacyBarrier(): void;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type ReplicationInfoV2LocalCapabilityContext = {
|
|
93
|
+
peerHash: string;
|
|
94
|
+
target: PublicSignKey;
|
|
95
|
+
lifecycleSignal: AbortSignal;
|
|
96
|
+
legacyBarrierReleased: boolean;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type ReplicationInfoV2LocalCapabilityAdvertisement = {
|
|
100
|
+
peerHash: string;
|
|
101
|
+
target: PublicSignKey;
|
|
102
|
+
peerSession: object;
|
|
103
|
+
receiveEpoch: object | null;
|
|
104
|
+
lifecycleSignal: AbortSignal;
|
|
105
|
+
onLifecycleAbort: () => void;
|
|
106
|
+
controller: AbortController;
|
|
107
|
+
context: ReplicationInfoV2LocalCapabilityContext;
|
|
108
|
+
attempts: number;
|
|
109
|
+
ready: boolean;
|
|
110
|
+
acknowledgedReady?: LocalCapabilityReady;
|
|
111
|
+
receiverTransportSession?: bigint;
|
|
112
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
113
|
+
inFlight?: Promise<void>;
|
|
114
|
+
firstAttempt?: Promise<void>;
|
|
74
115
|
};
|
|
75
116
|
|
|
76
117
|
export type ReplicationInfoV2LocalCapabilityRefresh = {
|
|
@@ -131,6 +172,11 @@ export type ReplicationInfoV2ReceiveDeps = {
|
|
|
131
172
|
getSelfKey: () => PublicSignKey;
|
|
132
173
|
getReceiverTransportSession: () => bigint;
|
|
133
174
|
isClosed: () => boolean;
|
|
175
|
+
isPeerSessionCurrent: (peerHash: string, peerSession: object) => boolean;
|
|
176
|
+
isReceiveEpochCurrent: (
|
|
177
|
+
peerHash: string,
|
|
178
|
+
receiveEpoch: object | null,
|
|
179
|
+
) => boolean;
|
|
134
180
|
isPeerStateCurrent: (
|
|
135
181
|
peerHash: string,
|
|
136
182
|
peerSession: object,
|
|
@@ -153,6 +199,7 @@ export type ReplicationInfoV2ReceiveDeps = {
|
|
|
153
199
|
signal: AbortSignal;
|
|
154
200
|
}) => Promise<ReplicationInfoV2LocalCapabilityRefresh | undefined>;
|
|
155
201
|
onRequestError?: (error: unknown) => void;
|
|
202
|
+
onLocalCapabilityError?: (error: unknown) => void;
|
|
156
203
|
now?: () => number;
|
|
157
204
|
requestRetryMs?: number;
|
|
158
205
|
maxRequestRetryMs?: number;
|
|
@@ -169,6 +216,14 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
169
216
|
_receiveStates!: Map<string, ReplicationInfoV2ReceiveState>;
|
|
170
217
|
_cutoverPeerSessions!: WeakSet<object>;
|
|
171
218
|
_localCapabilityReadyBySession!: WeakMap<object, LocalCapabilityReady>;
|
|
219
|
+
_localCapabilityContextBySession!: WeakMap<
|
|
220
|
+
object,
|
|
221
|
+
ReplicationInfoV2LocalCapabilityContext
|
|
222
|
+
>;
|
|
223
|
+
_localCapabilityAdvertisementsByPeer!: Map<
|
|
224
|
+
string,
|
|
225
|
+
ReplicationInfoV2LocalCapabilityAdvertisement
|
|
226
|
+
>;
|
|
172
227
|
_reservedAdmissionsByPeer!: Map<string, ReplicationInfoV2ReceiveAdmission>;
|
|
173
228
|
|
|
174
229
|
private readonly now: () => number;
|
|
@@ -198,6 +253,8 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
198
253
|
this._receiveStates = new Map();
|
|
199
254
|
this._cutoverPeerSessions = new WeakSet();
|
|
200
255
|
this._localCapabilityReadyBySession = new WeakMap();
|
|
256
|
+
this._localCapabilityContextBySession = new WeakMap();
|
|
257
|
+
this._localCapabilityAdvertisementsByPeer = new Map();
|
|
201
258
|
this._reservedAdmissionsByPeer = new Map();
|
|
202
259
|
}
|
|
203
260
|
|
|
@@ -206,27 +263,46 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
206
263
|
this._receiveStates = new Map();
|
|
207
264
|
this._cutoverPeerSessions = new WeakSet();
|
|
208
265
|
this._localCapabilityReadyBySession = new WeakMap();
|
|
266
|
+
this._localCapabilityContextBySession = new WeakMap();
|
|
267
|
+
this._localCapabilityAdvertisementsByPeer = new Map();
|
|
209
268
|
this._reservedAdmissionsByPeer = new Map();
|
|
210
269
|
}
|
|
211
270
|
|
|
212
271
|
clearForClose(): void {
|
|
272
|
+
for (const advertisement of [
|
|
273
|
+
...(this._localCapabilityAdvertisementsByPeer?.values() ?? []),
|
|
274
|
+
]) {
|
|
275
|
+
this.clearLocalCapabilityAdvertisement(advertisement);
|
|
276
|
+
}
|
|
277
|
+
this._localCapabilityAdvertisementsByPeer?.clear();
|
|
213
278
|
for (const state of this._receiveStates?.values() ?? []) {
|
|
214
279
|
this.clearState(state);
|
|
215
280
|
}
|
|
216
281
|
this._receiveStates?.clear();
|
|
217
282
|
this._cutoverPeerSessions = new WeakSet();
|
|
218
283
|
this._localCapabilityReadyBySession = new WeakMap();
|
|
284
|
+
this._localCapabilityContextBySession = new WeakMap();
|
|
219
285
|
}
|
|
220
286
|
|
|
221
287
|
clearPeer(peerHash: string, expectedSession?: object): void {
|
|
288
|
+
const advertisement =
|
|
289
|
+
this._localCapabilityAdvertisementsByPeer.get(peerHash);
|
|
290
|
+
if (
|
|
291
|
+
advertisement &&
|
|
292
|
+
(!expectedSession || advertisement.peerSession === expectedSession)
|
|
293
|
+
) {
|
|
294
|
+
this.clearLocalCapabilityAdvertisement(advertisement);
|
|
295
|
+
}
|
|
222
296
|
const state = this._receiveStates.get(peerHash);
|
|
223
297
|
if (state && (!expectedSession || state.peerSession === expectedSession)) {
|
|
224
298
|
this.clearState(state);
|
|
225
299
|
this._localCapabilityReadyBySession.delete(state.peerSession);
|
|
300
|
+
this._localCapabilityContextBySession.delete(state.peerSession);
|
|
226
301
|
this._cutoverPeerSessions.delete(state.peerSession);
|
|
227
302
|
}
|
|
228
303
|
if (expectedSession) {
|
|
229
304
|
this._localCapabilityReadyBySession.delete(expectedSession);
|
|
305
|
+
this._localCapabilityContextBySession.delete(expectedSession);
|
|
230
306
|
this._cutoverPeerSessions.delete(expectedSession);
|
|
231
307
|
}
|
|
232
308
|
}
|
|
@@ -259,14 +335,462 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
259
335
|
}
|
|
260
336
|
}
|
|
261
337
|
|
|
262
|
-
|
|
263
|
-
|
|
338
|
+
private clearLocalCapabilityAdvertisement(
|
|
339
|
+
state: ReplicationInfoV2LocalCapabilityAdvertisement,
|
|
340
|
+
options?: { preserveContext?: boolean },
|
|
341
|
+
): void {
|
|
342
|
+
if (state.timer) {
|
|
343
|
+
clearTimeout(state.timer);
|
|
344
|
+
state.timer = undefined;
|
|
345
|
+
}
|
|
346
|
+
state.lifecycleSignal.removeEventListener("abort", state.onLifecycleAbort);
|
|
347
|
+
state.controller.abort();
|
|
348
|
+
const wasMapped =
|
|
349
|
+
this._localCapabilityAdvertisementsByPeer.get(state.peerHash) === state;
|
|
350
|
+
if (wasMapped) {
|
|
351
|
+
this._localCapabilityAdvertisementsByPeer.delete(state.peerHash);
|
|
352
|
+
}
|
|
353
|
+
const ready = this._localCapabilityReadyBySession.get(state.peerSession);
|
|
354
|
+
if (ready?.advertisement === state) {
|
|
355
|
+
this._localCapabilityReadyBySession.delete(state.peerSession);
|
|
356
|
+
}
|
|
357
|
+
if (
|
|
358
|
+
wasMapped &&
|
|
359
|
+
!options?.preserveContext &&
|
|
360
|
+
this._localCapabilityContextBySession.get(state.peerSession) ===
|
|
361
|
+
state.context
|
|
362
|
+
) {
|
|
363
|
+
this._localCapabilityContextBySession.delete(state.peerSession);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private isLocalCapabilityAdvertisementOwnerCurrent(
|
|
368
|
+
state: ReplicationInfoV2LocalCapabilityAdvertisement,
|
|
369
|
+
): boolean {
|
|
370
|
+
return (
|
|
371
|
+
this._localCapabilityAdvertisementsByPeer.get(state.peerHash) === state &&
|
|
372
|
+
this._localCapabilityContextBySession.get(state.peerSession) ===
|
|
373
|
+
state.context &&
|
|
374
|
+
state.context.peerHash === state.peerHash &&
|
|
375
|
+
state.context.lifecycleSignal === state.lifecycleSignal &&
|
|
376
|
+
state.context.target.equals(state.target) &&
|
|
377
|
+
!state.controller.signal.aborted &&
|
|
378
|
+
!state.lifecycleSignal.aborted &&
|
|
379
|
+
!this.deps.isClosed() &&
|
|
380
|
+
this.deps.isPeerSessionCurrent(state.peerHash, state.peerSession) &&
|
|
381
|
+
(state.receiverTransportSession === undefined ||
|
|
382
|
+
this.deps.getReceiverTransportSession() ===
|
|
383
|
+
state.receiverTransportSession)
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private isLocalCapabilityAdvertisementGenerationCurrent(
|
|
388
|
+
state: ReplicationInfoV2LocalCapabilityAdvertisement,
|
|
389
|
+
): boolean {
|
|
390
|
+
return (
|
|
391
|
+
this.isLocalCapabilityAdvertisementOwnerCurrent(state) &&
|
|
392
|
+
this.deps.isReceiveEpochCurrent(state.peerHash, state.receiveEpoch)
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private isLocalCapabilityAdvertisementReadyOpen(
|
|
397
|
+
state: ReplicationInfoV2LocalCapabilityAdvertisement,
|
|
398
|
+
): boolean {
|
|
399
|
+
return (
|
|
400
|
+
this.isLocalCapabilityAdvertisementGenerationCurrent(state) &&
|
|
401
|
+
this.deps.isPeerStateCurrent(
|
|
402
|
+
state.peerHash,
|
|
403
|
+
state.peerSession,
|
|
404
|
+
state.receiveEpoch,
|
|
405
|
+
)
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private localCapabilityRetryDelay(
|
|
410
|
+
state: ReplicationInfoV2LocalCapabilityAdvertisement,
|
|
411
|
+
): number {
|
|
412
|
+
const exponent = Math.max(0, state.attempts - 1);
|
|
413
|
+
return Math.min(
|
|
414
|
+
this.maxRequestRetryMs,
|
|
415
|
+
this.requestRetryMs * 2 ** Math.min(exponent, MAX_BACKOFF_EXPONENT),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private armLocalCapabilityAdvertisement(
|
|
420
|
+
state: ReplicationInfoV2LocalCapabilityAdvertisement,
|
|
421
|
+
): void {
|
|
422
|
+
if (state.timer || state.inFlight || state.ready) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (state.acknowledgedReady && !state.context.legacyBarrierReleased) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
|
|
429
|
+
this.clearLocalCapabilityAdvertisement(state);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (!this.isLocalCapabilityAdvertisementGenerationCurrent(state)) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
state.timer = setTimeout(() => {
|
|
436
|
+
state.timer = undefined;
|
|
437
|
+
if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
|
|
438
|
+
this.clearLocalCapabilityAdvertisement(state);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!this.isLocalCapabilityAdvertisementGenerationCurrent(state)) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (!this.isLocalCapabilityAdvertisementReadyOpen(state)) {
|
|
445
|
+
this.armLocalCapabilityAdvertisement(state);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
void this.runLocalCapabilityAdvertisement(state);
|
|
449
|
+
}, this.localCapabilityRetryDelay(state));
|
|
450
|
+
state.timer.unref?.();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private async runLocalCapabilityAdvertisement(
|
|
454
|
+
state: ReplicationInfoV2LocalCapabilityAdvertisement,
|
|
455
|
+
): Promise<void> {
|
|
456
|
+
if (state.inFlight) {
|
|
457
|
+
await state.inFlight;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (state.ready) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
|
|
464
|
+
this.clearLocalCapabilityAdvertisement(state);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (!this.isLocalCapabilityAdvertisementGenerationCurrent(state)) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (state.acknowledgedReady) {
|
|
471
|
+
if (state.context.legacyBarrierReleased) {
|
|
472
|
+
this.promoteLocalCapabilityAdvertisement(state);
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (!this.isLocalCapabilityAdvertisementReadyOpen(state)) {
|
|
477
|
+
this.armLocalCapabilityAdvertisement(state);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
state.attempts = Math.min(state.attempts + 1, MAX_BACKOFF_EXPONENT + 1);
|
|
481
|
+
let operation: Promise<void>;
|
|
482
|
+
operation = (async () => {
|
|
483
|
+
const refreshed = await this.deps.refreshLocalCapability({
|
|
484
|
+
peerHash: state.peerHash,
|
|
485
|
+
target: state.target,
|
|
486
|
+
peerSession: state.peerSession,
|
|
487
|
+
receiveEpoch: state.receiveEpoch,
|
|
488
|
+
signal: AbortSignal.any([
|
|
489
|
+
state.controller.signal,
|
|
490
|
+
state.lifecycleSignal,
|
|
491
|
+
]),
|
|
492
|
+
});
|
|
493
|
+
if (
|
|
494
|
+
!refreshed ||
|
|
495
|
+
!this.isLocalCapabilityAdvertisementGenerationCurrent(state) ||
|
|
496
|
+
this.deps.getReceiverTransportSession() !==
|
|
497
|
+
refreshed.receiverTransportSession
|
|
498
|
+
) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
state.receiverTransportSession = refreshed.receiverTransportSession;
|
|
502
|
+
state.acknowledgedReady = {
|
|
503
|
+
peerHash: state.peerHash,
|
|
504
|
+
receiveEpoch: state.receiveEpoch,
|
|
505
|
+
receiverTransportSession: refreshed.receiverTransportSession,
|
|
506
|
+
requestNotBeforeMs: refreshed.requestNotBeforeMs,
|
|
507
|
+
advertisement: state,
|
|
508
|
+
};
|
|
509
|
+
state.attempts = 0;
|
|
510
|
+
this.promoteLocalCapabilityAdvertisement(state);
|
|
511
|
+
})()
|
|
512
|
+
.catch((error) => {
|
|
513
|
+
if (
|
|
514
|
+
!state.controller.signal.aborted &&
|
|
515
|
+
!state.lifecycleSignal.aborted &&
|
|
516
|
+
!this.deps.isClosed()
|
|
517
|
+
) {
|
|
518
|
+
this.deps.onLocalCapabilityError?.(error);
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
.finally(() => {
|
|
522
|
+
if (state.inFlight === operation) {
|
|
523
|
+
state.inFlight = undefined;
|
|
524
|
+
}
|
|
525
|
+
if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
|
|
526
|
+
this.clearLocalCapabilityAdvertisement(state);
|
|
527
|
+
} else if (
|
|
528
|
+
this.isLocalCapabilityAdvertisementGenerationCurrent(state) &&
|
|
529
|
+
!state.ready
|
|
530
|
+
) {
|
|
531
|
+
if (!state.acknowledgedReady || state.context.legacyBarrierReleased) {
|
|
532
|
+
this.armLocalCapabilityAdvertisement(state);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
state.inFlight = operation;
|
|
537
|
+
await operation;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Start local authenticated-apply advertisement independently of the legacy
|
|
542
|
+
* join path. ACK and legacy publication are a two-phase barrier: the first
|
|
543
|
+
* attempt always settles independently, while readiness is promoted only
|
|
544
|
+
* after the host releases the legacy barrier. Failed attempts leave one
|
|
545
|
+
* exact-session worker retrying with capped exponential backoff.
|
|
546
|
+
*/
|
|
547
|
+
advertiseLocalCapability(properties: {
|
|
548
|
+
target: PublicSignKey;
|
|
549
|
+
peerSession: object;
|
|
550
|
+
receiveEpoch: object | null;
|
|
551
|
+
signal: AbortSignal;
|
|
552
|
+
}): ReplicationInfoV2LocalCapabilityAdvertisementHandle {
|
|
553
|
+
const peerHash = properties.target.hashcode();
|
|
554
|
+
if (
|
|
555
|
+
properties.signal.aborted ||
|
|
556
|
+
this.deps.isClosed() ||
|
|
557
|
+
!this.deps.isPeerSessionCurrent(peerHash, properties.peerSession) ||
|
|
558
|
+
!this.deps.isReceiveEpochCurrent(peerHash, properties.receiveEpoch)
|
|
559
|
+
) {
|
|
560
|
+
return {
|
|
561
|
+
firstAttempt: Promise.resolve(),
|
|
562
|
+
releaseLegacyBarrier: () => {},
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
let context = this._localCapabilityContextBySession.get(
|
|
566
|
+
properties.peerSession,
|
|
567
|
+
);
|
|
568
|
+
if (
|
|
569
|
+
context &&
|
|
570
|
+
(context.peerHash !== peerHash ||
|
|
571
|
+
!context.target.equals(properties.target) ||
|
|
572
|
+
context.lifecycleSignal !== properties.signal)
|
|
573
|
+
) {
|
|
574
|
+
return {
|
|
575
|
+
firstAttempt: Promise.resolve(),
|
|
576
|
+
releaseLegacyBarrier: () => {},
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
if (!context) {
|
|
580
|
+
context = {
|
|
581
|
+
peerHash,
|
|
582
|
+
target: properties.target,
|
|
583
|
+
lifecycleSignal: properties.signal,
|
|
584
|
+
legacyBarrierReleased: false,
|
|
585
|
+
};
|
|
586
|
+
this._localCapabilityContextBySession.set(
|
|
587
|
+
properties.peerSession,
|
|
588
|
+
context,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
let state = this._localCapabilityAdvertisementsByPeer.get(peerHash);
|
|
592
|
+
if (
|
|
593
|
+
state &&
|
|
594
|
+
(state.peerSession !== properties.peerSession ||
|
|
595
|
+
state.context !== context ||
|
|
596
|
+
state.lifecycleSignal !== properties.signal ||
|
|
597
|
+
!state.target.equals(properties.target))
|
|
598
|
+
) {
|
|
599
|
+
this.clearLocalCapabilityAdvertisement(state);
|
|
600
|
+
state = undefined;
|
|
601
|
+
}
|
|
602
|
+
if (state && state.receiveEpoch !== properties.receiveEpoch) {
|
|
603
|
+
this.clearLocalCapabilityAdvertisement(state, { preserveContext: true });
|
|
604
|
+
state = undefined;
|
|
605
|
+
}
|
|
606
|
+
if (state && !this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
|
|
607
|
+
this.clearLocalCapabilityAdvertisement(state);
|
|
608
|
+
state = undefined;
|
|
609
|
+
}
|
|
610
|
+
if (
|
|
611
|
+
this._localCapabilityContextBySession.get(properties.peerSession) !==
|
|
612
|
+
context
|
|
613
|
+
) {
|
|
614
|
+
return {
|
|
615
|
+
firstAttempt: Promise.resolve(),
|
|
616
|
+
releaseLegacyBarrier: () => {},
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
if (!state) {
|
|
620
|
+
const controller = new AbortController();
|
|
621
|
+
const advertisement: ReplicationInfoV2LocalCapabilityAdvertisement = {
|
|
622
|
+
peerHash,
|
|
623
|
+
target: properties.target,
|
|
624
|
+
peerSession: properties.peerSession,
|
|
625
|
+
receiveEpoch: properties.receiveEpoch,
|
|
626
|
+
lifecycleSignal: properties.signal,
|
|
627
|
+
onLifecycleAbort: () => {},
|
|
628
|
+
controller,
|
|
629
|
+
context,
|
|
630
|
+
attempts: 0,
|
|
631
|
+
ready: false,
|
|
632
|
+
receiverTransportSession: this.deps.getReceiverTransportSession(),
|
|
633
|
+
};
|
|
634
|
+
advertisement.onLifecycleAbort = () =>
|
|
635
|
+
this.clearLocalCapabilityAdvertisement(advertisement);
|
|
636
|
+
properties.signal.addEventListener(
|
|
637
|
+
"abort",
|
|
638
|
+
advertisement.onLifecycleAbort,
|
|
639
|
+
{
|
|
640
|
+
once: true,
|
|
641
|
+
},
|
|
642
|
+
);
|
|
643
|
+
this._localCapabilityAdvertisementsByPeer.set(peerHash, advertisement);
|
|
644
|
+
state = advertisement;
|
|
645
|
+
}
|
|
646
|
+
const firstAttempt =
|
|
647
|
+
state.firstAttempt ??
|
|
648
|
+
(state.firstAttempt = this.runLocalCapabilityAdvertisement(state));
|
|
649
|
+
const advertisement = state;
|
|
650
|
+
return {
|
|
651
|
+
firstAttempt,
|
|
652
|
+
releaseLegacyBarrier: () =>
|
|
653
|
+
this.releaseLocalCapabilityLegacyBarrier(
|
|
654
|
+
advertisement.peerSession,
|
|
655
|
+
context,
|
|
656
|
+
),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private releaseLocalCapabilityLegacyBarrier(
|
|
661
|
+
peerSession: object,
|
|
662
|
+
context: ReplicationInfoV2LocalCapabilityContext,
|
|
663
|
+
): void {
|
|
664
|
+
if (context.legacyBarrierReleased) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (
|
|
668
|
+
this._localCapabilityContextBySession.get(peerSession) !== context ||
|
|
669
|
+
context.lifecycleSignal.aborted ||
|
|
670
|
+
this.deps.isClosed() ||
|
|
671
|
+
!this.deps.isPeerSessionCurrent(context.peerHash, peerSession)
|
|
672
|
+
) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
context.legacyBarrierReleased = true;
|
|
676
|
+
const state = this._localCapabilityAdvertisementsByPeer.get(
|
|
677
|
+
context.peerHash,
|
|
678
|
+
);
|
|
679
|
+
if (state?.peerSession === peerSession && state.context === context) {
|
|
680
|
+
if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
|
|
681
|
+
this.clearLocalCapabilityAdvertisement(state);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
this.promoteLocalCapabilityAdvertisement(state);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private promoteLocalCapabilityAdvertisement(
|
|
689
|
+
state: ReplicationInfoV2LocalCapabilityAdvertisement,
|
|
690
|
+
): boolean {
|
|
691
|
+
const ready = state.acknowledgedReady;
|
|
692
|
+
if (
|
|
693
|
+
state.ready ||
|
|
694
|
+
!state.context.legacyBarrierReleased ||
|
|
695
|
+
!ready ||
|
|
696
|
+
ready.receiveEpoch !== state.receiveEpoch ||
|
|
697
|
+
ready.advertisement !== state ||
|
|
698
|
+
!this.isLocalCapabilityAdvertisementOwnerCurrent(state) ||
|
|
699
|
+
!this.isLocalCapabilityAdvertisementGenerationCurrent(state) ||
|
|
700
|
+
this.deps.getReceiverTransportSession() !== ready.receiverTransportSession
|
|
701
|
+
) {
|
|
702
|
+
return state.ready;
|
|
703
|
+
}
|
|
704
|
+
if (!this.isLocalCapabilityAdvertisementReadyOpen(state)) {
|
|
705
|
+
this.armLocalCapabilityAdvertisement(state);
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
if (
|
|
709
|
+
!this.recordLocalCapabilityReady(
|
|
710
|
+
{
|
|
711
|
+
peerHash: state.peerHash,
|
|
712
|
+
peerSession: state.peerSession,
|
|
713
|
+
receiveEpoch: state.receiveEpoch,
|
|
714
|
+
receiverTransportSession: ready.receiverTransportSession,
|
|
715
|
+
requestNotBeforeMs: ready.requestNotBeforeMs,
|
|
716
|
+
},
|
|
717
|
+
state,
|
|
718
|
+
)
|
|
719
|
+
) {
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
state.ready = true;
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Re-advertise one exact current recovery epoch from the stable membership
|
|
728
|
+
* context captured during opening. The opening handle owns barrier release;
|
|
729
|
+
* recovery never bypasses a legacy snapshot or role publication still in
|
|
730
|
+
* progress.
|
|
731
|
+
*/
|
|
732
|
+
reAdvertiseLocalCapabilityForRecovery(properties: {
|
|
264
733
|
peerHash: string;
|
|
265
734
|
peerSession: object;
|
|
266
735
|
receiveEpoch: object | null;
|
|
267
|
-
receiverTransportSession: bigint;
|
|
268
|
-
requestNotBeforeMs: number;
|
|
269
736
|
}): boolean {
|
|
737
|
+
const context = this._localCapabilityContextBySession.get(
|
|
738
|
+
properties.peerSession,
|
|
739
|
+
);
|
|
740
|
+
if (
|
|
741
|
+
!context ||
|
|
742
|
+
context.peerHash !== properties.peerHash ||
|
|
743
|
+
context.lifecycleSignal.aborted ||
|
|
744
|
+
this.deps.isClosed() ||
|
|
745
|
+
!this.deps.isPeerSessionCurrent(
|
|
746
|
+
properties.peerHash,
|
|
747
|
+
properties.peerSession,
|
|
748
|
+
) ||
|
|
749
|
+
!this.deps.isReceiveEpochCurrent(
|
|
750
|
+
properties.peerHash,
|
|
751
|
+
properties.receiveEpoch,
|
|
752
|
+
)
|
|
753
|
+
) {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
const state = this._receiveStates.get(properties.peerHash);
|
|
757
|
+
if (
|
|
758
|
+
state?.peerSession === properties.peerSession &&
|
|
759
|
+
state.receiveEpoch === properties.receiveEpoch &&
|
|
760
|
+
state.receiverBinding !== undefined
|
|
761
|
+
) {
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
const ready = this._localCapabilityReadyBySession.get(
|
|
765
|
+
properties.peerSession,
|
|
766
|
+
);
|
|
767
|
+
if (
|
|
768
|
+
ready?.peerHash === properties.peerHash &&
|
|
769
|
+
ready.receiveEpoch === properties.receiveEpoch &&
|
|
770
|
+
ready.receiverTransportSession === this.deps.getReceiverTransportSession()
|
|
771
|
+
) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
this.advertiseLocalCapability({
|
|
775
|
+
target: context.target,
|
|
776
|
+
peerSession: properties.peerSession,
|
|
777
|
+
receiveEpoch: properties.receiveEpoch,
|
|
778
|
+
signal: context.lifecycleSignal,
|
|
779
|
+
});
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Record success of this session's ACKed local APPLY advertisement. */
|
|
784
|
+
markLocalCapabilityReady(
|
|
785
|
+
properties: LocalCapabilityReadyProperties,
|
|
786
|
+
): boolean {
|
|
787
|
+
return this.recordLocalCapabilityReady(properties);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private recordLocalCapabilityReady(
|
|
791
|
+
properties: LocalCapabilityReadyProperties,
|
|
792
|
+
advertisement?: ReplicationInfoV2LocalCapabilityAdvertisement,
|
|
793
|
+
): boolean {
|
|
270
794
|
const { peerHash, peerSession, receiverTransportSession } = properties;
|
|
271
795
|
const state = this._receiveStates.get(peerHash);
|
|
272
796
|
if (
|
|
@@ -283,8 +807,10 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
283
807
|
|
|
284
808
|
const ready: LocalCapabilityReady = {
|
|
285
809
|
peerHash,
|
|
810
|
+
receiveEpoch: properties.receiveEpoch,
|
|
286
811
|
receiverTransportSession,
|
|
287
812
|
requestNotBeforeMs: properties.requestNotBeforeMs,
|
|
813
|
+
advertisement,
|
|
288
814
|
};
|
|
289
815
|
this._localCapabilityReadyBySession.set(peerSession, ready);
|
|
290
816
|
if (
|
|
@@ -388,7 +914,11 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
388
914
|
});
|
|
389
915
|
}
|
|
390
916
|
const ready = this._localCapabilityReadyBySession.get(peerSession);
|
|
391
|
-
if (
|
|
917
|
+
if (
|
|
918
|
+
ready?.peerHash === peerHash &&
|
|
919
|
+
ready.receiveEpoch === receiveEpoch &&
|
|
920
|
+
state.receiverBinding === undefined
|
|
921
|
+
) {
|
|
392
922
|
this.bindLocalCapability(state, ready);
|
|
393
923
|
}
|
|
394
924
|
if (state.phase !== "active") {
|
|
@@ -422,7 +952,7 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
422
952
|
};
|
|
423
953
|
this._receiveStates.set(peerHash, state);
|
|
424
954
|
const ready = this._localCapabilityReadyBySession.get(peerSession);
|
|
425
|
-
if (ready?.peerHash === peerHash) {
|
|
955
|
+
if (ready?.peerHash === peerHash && ready.receiveEpoch === receiveEpoch) {
|
|
426
956
|
this.bindLocalCapability(state, ready);
|
|
427
957
|
this.armRequest(state, 0);
|
|
428
958
|
}
|
|
@@ -461,6 +991,44 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
461
991
|
return true;
|
|
462
992
|
}
|
|
463
993
|
|
|
994
|
+
/**
|
|
995
|
+
* Resume only a request generation that exhausted its bounded retries.
|
|
996
|
+
* Wait/liveness callers may nudge recovery without invalidating an active,
|
|
997
|
+
* timed or in-flight request and without advancing the receive epoch.
|
|
998
|
+
*/
|
|
999
|
+
resumeParkedRequest(properties: {
|
|
1000
|
+
peerHash: string;
|
|
1001
|
+
peerSession: object;
|
|
1002
|
+
receiveEpoch: object | null;
|
|
1003
|
+
}): boolean {
|
|
1004
|
+
const state = this._receiveStates.get(properties.peerHash);
|
|
1005
|
+
if (
|
|
1006
|
+
!state ||
|
|
1007
|
+
state.peerSession !== properties.peerSession ||
|
|
1008
|
+
state.receiveEpoch !== properties.receiveEpoch ||
|
|
1009
|
+
!this.deps.isPeerStateCurrent(
|
|
1010
|
+
properties.peerHash,
|
|
1011
|
+
properties.peerSession,
|
|
1012
|
+
properties.receiveEpoch,
|
|
1013
|
+
) ||
|
|
1014
|
+
state.phase === "active" ||
|
|
1015
|
+
!state.requestParked ||
|
|
1016
|
+
state.requestTimer !== undefined ||
|
|
1017
|
+
state.requestInFlight !== undefined ||
|
|
1018
|
+
this._reservedAdmissionsByPeer.has(properties.peerHash) ||
|
|
1019
|
+
state.receiverBinding === undefined ||
|
|
1020
|
+
state.lastSequence === MAX_U64
|
|
1021
|
+
) {
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
state.requestAttempts = 0;
|
|
1025
|
+
state.requestsSinceCapabilityRefresh = 0;
|
|
1026
|
+
state.capabilityRefreshRequired = true;
|
|
1027
|
+
state.requestParked = false;
|
|
1028
|
+
this.armRequest(state, 0);
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
464
1032
|
private transitionToResync(
|
|
465
1033
|
state: ReplicationInfoV2ReceiveState,
|
|
466
1034
|
options?: { force?: boolean; refreshCapability?: boolean },
|
|
@@ -624,6 +1192,14 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
624
1192
|
const { state } = admission;
|
|
625
1193
|
this._reservedAdmissionsByPeer.set(peerHash, admission);
|
|
626
1194
|
state.reservedAdmission = admission;
|
|
1195
|
+
if (state.requestTimer) {
|
|
1196
|
+
clearTimeout(state.requestTimer);
|
|
1197
|
+
state.requestTimer = undefined;
|
|
1198
|
+
}
|
|
1199
|
+
if (state.legacyFallbackTimer) {
|
|
1200
|
+
clearTimeout(state.legacyFallbackTimer);
|
|
1201
|
+
state.legacyFallbackTimer = undefined;
|
|
1202
|
+
}
|
|
627
1203
|
return admission;
|
|
628
1204
|
}
|
|
629
1205
|
|
|
@@ -644,6 +1220,30 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
644
1220
|
(currentState !== state && currentState.phase !== "active"))
|
|
645
1221
|
) {
|
|
646
1222
|
this.transitionToResync(currentState, { force: true });
|
|
1223
|
+
} else if (
|
|
1224
|
+
currentState === state &&
|
|
1225
|
+
!admission.committed &&
|
|
1226
|
+
this.isStateCurrent(state) &&
|
|
1227
|
+
state.phase !== "active" &&
|
|
1228
|
+
state.requestTimer === undefined &&
|
|
1229
|
+
state.requestInFlight === undefined &&
|
|
1230
|
+
!state.requestParked
|
|
1231
|
+
) {
|
|
1232
|
+
// Reserving a Full pauses this generation's retry timer. If its host
|
|
1233
|
+
// apply lane releases without committing, restore the bounded request
|
|
1234
|
+
// worker so the exact current generation cannot become stranded.
|
|
1235
|
+
this.armRequest(state, 0);
|
|
1236
|
+
}
|
|
1237
|
+
if (
|
|
1238
|
+
currentState === state &&
|
|
1239
|
+
this.isStateCurrent(state) &&
|
|
1240
|
+
state.phase === "active" &&
|
|
1241
|
+
state.legacyFallbackFingerprint !== undefined
|
|
1242
|
+
) {
|
|
1243
|
+
// A reservation also pauses compat sidecar recovery. Matching commits
|
|
1244
|
+
// clear the evidence; an uncommitted or non-matching frame restores its
|
|
1245
|
+
// bounded fallback without invalidating work in the host apply lane.
|
|
1246
|
+
this.armLegacyFallback(state);
|
|
647
1247
|
}
|
|
648
1248
|
}
|
|
649
1249
|
|
|
@@ -751,6 +1351,19 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
751
1351
|
if (state.legacyFallbackTimer) {
|
|
752
1352
|
return true;
|
|
753
1353
|
}
|
|
1354
|
+
this.armLegacyFallback(state);
|
|
1355
|
+
return true;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
private armLegacyFallback(state: ReplicationInfoV2ReceiveState): void {
|
|
1359
|
+
if (
|
|
1360
|
+
state.legacyFallbackTimer ||
|
|
1361
|
+
state.phase !== "active" ||
|
|
1362
|
+
state.legacyFallbackFingerprint === undefined ||
|
|
1363
|
+
this._reservedAdmissionsByPeer.has(state.peerHash)
|
|
1364
|
+
) {
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
754
1367
|
state.legacyFallbackTimer = setTimeout(() => {
|
|
755
1368
|
state.legacyFallbackTimer = undefined;
|
|
756
1369
|
if (this.isStateCurrent(state) && state.phase === "active") {
|
|
@@ -761,7 +1374,6 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
761
1374
|
}
|
|
762
1375
|
}, this.legacyFallbackDelayMs);
|
|
763
1376
|
state.legacyFallbackTimer.unref?.();
|
|
764
|
-
return true;
|
|
765
1377
|
}
|
|
766
1378
|
|
|
767
1379
|
private clearLegacyFallback(state: ReplicationInfoV2ReceiveState): void {
|
|
@@ -888,8 +1500,7 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
888
1500
|
this._receiveStates.get(state.peerHash) !== state ||
|
|
889
1501
|
state.controller.signal.aborted ||
|
|
890
1502
|
this.deps.isClosed() ||
|
|
891
|
-
|
|
892
|
-
this._reservedAdmissionsByPeer.get(state.peerHash)?.state !== state) ||
|
|
1503
|
+
this._reservedAdmissionsByPeer.has(state.peerHash) ||
|
|
893
1504
|
state.receiverBinding === undefined ||
|
|
894
1505
|
state.phase === "active" ||
|
|
895
1506
|
state.requestParked ||
|
|
@@ -919,6 +1530,7 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
919
1530
|
private async refreshGrant(
|
|
920
1531
|
state: ReplicationInfoV2ReceiveState,
|
|
921
1532
|
): Promise<boolean> {
|
|
1533
|
+
const version = state.version;
|
|
922
1534
|
const refreshed = await this.deps.refreshLocalCapability({
|
|
923
1535
|
peerHash: state.peerHash,
|
|
924
1536
|
target: state.target,
|
|
@@ -928,6 +1540,8 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
928
1540
|
});
|
|
929
1541
|
if (
|
|
930
1542
|
!refreshed ||
|
|
1543
|
+
state.version !== version ||
|
|
1544
|
+
this._reservedAdmissionsByPeer.has(state.peerHash) ||
|
|
931
1545
|
!this.isStateCurrent(state) ||
|
|
932
1546
|
this.deps.getReceiverTransportSession() !==
|
|
933
1547
|
refreshed.receiverTransportSession
|
|
@@ -937,6 +1551,7 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
937
1551
|
|
|
938
1552
|
const ready: LocalCapabilityReady = {
|
|
939
1553
|
peerHash: state.peerHash,
|
|
1554
|
+
receiveEpoch: state.receiveEpoch,
|
|
940
1555
|
receiverTransportSession: refreshed.receiverTransportSession,
|
|
941
1556
|
requestNotBeforeMs: refreshed.requestNotBeforeMs,
|
|
942
1557
|
};
|
|
@@ -958,7 +1573,10 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
958
1573
|
if (state.requestInFlight) {
|
|
959
1574
|
return;
|
|
960
1575
|
}
|
|
961
|
-
if (
|
|
1576
|
+
if (
|
|
1577
|
+
!this.isStateCurrent(state) ||
|
|
1578
|
+
this._reservedAdmissionsByPeer.has(state.peerHash)
|
|
1579
|
+
) {
|
|
962
1580
|
return;
|
|
963
1581
|
}
|
|
964
1582
|
if (
|
|
@@ -981,13 +1599,17 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
981
1599
|
return;
|
|
982
1600
|
}
|
|
983
1601
|
}
|
|
984
|
-
if (
|
|
1602
|
+
if (
|
|
1603
|
+
!this.isStateCurrent(state) ||
|
|
1604
|
+
this._reservedAdmissionsByPeer.has(state.peerHash)
|
|
1605
|
+
) {
|
|
985
1606
|
return;
|
|
986
1607
|
}
|
|
987
1608
|
const ready = this._localCapabilityReadyBySession.get(state.peerSession);
|
|
988
1609
|
if (
|
|
989
1610
|
!ready ||
|
|
990
1611
|
ready.peerHash !== state.peerHash ||
|
|
1612
|
+
ready.receiveEpoch !== state.receiveEpoch ||
|
|
991
1613
|
ready.receiverTransportSession !== state.receiverTransportSession
|
|
992
1614
|
) {
|
|
993
1615
|
return;
|
|
@@ -1026,6 +1648,7 @@ export class ReplicationInfoV2ReceiveCoordinator {
|
|
|
1026
1648
|
if (
|
|
1027
1649
|
this._receiveStates.get(state.peerHash) === state &&
|
|
1028
1650
|
!state.controller.signal.aborted &&
|
|
1651
|
+
!this._reservedAdmissionsByPeer.has(state.peerHash) &&
|
|
1029
1652
|
!state.requestTimer &&
|
|
1030
1653
|
state.phase !== "active"
|
|
1031
1654
|
) {
|