@syncular/client 0.26.0 → 0.26.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/README.md +35 -7
- package/dist/multi-tab.d.ts +11 -12
- package/dist/multi-tab.js +86 -78
- package/dist/worker-host.d.ts +5 -1
- package/dist/worker-host.js +0 -2
- package/package.json +3 -3
- package/src/multi-tab.ts +99 -92
- package/src/worker-host.ts +5 -9
package/README.md
CHANGED
|
@@ -195,14 +195,42 @@ flushed to the new leader on its announce; past the deadline they fail
|
|
|
195
195
|
loudly with `client.follower_timeout` (never a silent hang), and an
|
|
196
196
|
overflowing queue rejects rather than growing unbounded.
|
|
197
197
|
|
|
198
|
-
Leader
|
|
199
|
-
|
|
200
|
-
`followerCallTimeoutMs
|
|
198
|
+
**Leader liveness.** Followers probe the leader; the leader runs no liveness
|
|
199
|
+
timer. A bound follower that has heard no announcement for a third of
|
|
200
|
+
`followerCallTimeoutMs` (default 10 s) posts a `hello`, and the leader answers
|
|
201
|
+
every `hello` with an announcement from its message handler. Browsers throttle
|
|
202
|
+
the timers of hidden tabs (Chrome's intensive throttling wakes them about once
|
|
203
|
+
a minute) but still dispatch their `BroadcastChannel` messages, so a leader in
|
|
204
|
+
a background tab keeps its followers bound. When a probe stays unanswered for
|
|
205
|
+
the rest of `followerCallTimeoutMs`, `handle.leadership` becomes
|
|
201
206
|
`{ state: 'blocked', reason: 'leader-unreachable', code:
|
|
202
|
-
'client.follower_timeout', retryable: true }
|
|
203
|
-
A
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
'client.follower_timeout', retryable: true }` and calls reject immediately.
|
|
208
|
+
A leader tab that processes no messages (hung or frozen) therefore blocks its
|
|
209
|
+
followers within `followerCallTimeoutMs`; a closed leader releases its Web
|
|
210
|
+
Lock and hands over without waiting for that deadline. A blocked follower keeps
|
|
211
|
+
probing at the same interval: an answer rebinds the same handle, and a granted
|
|
212
|
+
Web Lock promotes it. A single call that runs past `followerCallTimeoutMs`
|
|
213
|
+
rejects with `client.follower_timeout` and leaves the handle bound, because
|
|
214
|
+
reachability is decided by the probe alone. An unreachable `BroadcastChannel`
|
|
215
|
+
is never treated as evidence that the lock owner is stale, so it never
|
|
216
|
+
authorizes a second worker or database owner.
|
|
217
|
+
|
|
218
|
+
**Upgrade note: reload every open tab.** Followers running 0.26.0 or older
|
|
219
|
+
wait for a leader heartbeat that current leaders no longer send. Such a tab
|
|
220
|
+
next to a current leader reports `leader-unreachable` with
|
|
221
|
+
`client.follower_timeout` after `followerCallTimeoutMs`, unless a current
|
|
222
|
+
follower's probe happens to make the leader announce, and stays that way
|
|
223
|
+
until it reloads. A current follower next to an older leader works, because
|
|
224
|
+
older leaders answer every `hello`. After upgrading, reload every open tab of
|
|
225
|
+
the origin.
|
|
226
|
+
|
|
227
|
+
**A hidden leader keeps leadership.** A visible tab does not take leadership
|
|
228
|
+
from a hidden leader. Web Locks `steal` revokes the lease while the leader's
|
|
229
|
+
worker still holds the OPFS database and may be mid-commit, so a transfer
|
|
230
|
+
needs a cooperative close-and-release handshake, and every focus change
|
|
231
|
+
between tabs would restart the worker, reopen the database, and reconnect the
|
|
232
|
+
realtime socket. A hidden leader answers its followers from its message
|
|
233
|
+
handler, so leadership moves only when the leader tab closes.
|
|
206
234
|
|
|
207
235
|
**Presence semantics — one device, one peer.** All tabs share the leader's
|
|
208
236
|
single connection, so a device is exactly ONE presence peer collectively:
|
package/dist/multi-tab.d.ts
CHANGED
|
@@ -28,14 +28,6 @@ interface HelloMessage {
|
|
|
28
28
|
* advance even if it never saw the previous leader's announce). */
|
|
29
29
|
readonly epoch?: number;
|
|
30
30
|
}
|
|
31
|
-
interface ByeMessage {
|
|
32
|
-
readonly t: 'bye';
|
|
33
|
-
readonly fromId: string;
|
|
34
|
-
}
|
|
35
|
-
interface BoundMessage {
|
|
36
|
-
readonly t: 'bound';
|
|
37
|
-
readonly fromId: string;
|
|
38
|
-
}
|
|
39
31
|
interface ReqMessage {
|
|
40
32
|
readonly t: 'req';
|
|
41
33
|
readonly epoch: number;
|
|
@@ -66,7 +58,7 @@ interface EventMessage {
|
|
|
66
58
|
readonly epoch: number;
|
|
67
59
|
readonly event: SyncWorkerEvent;
|
|
68
60
|
}
|
|
69
|
-
export type MultiTabMessage = HelloMessage |
|
|
61
|
+
export type MultiTabMessage = HelloMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
|
|
70
62
|
/**
|
|
71
63
|
* The tiny cross-tab channel surface we depend on — the DOM `BroadcastChannel`
|
|
72
64
|
* satisfies it. Injectable so bun tests can pair two instances by name.
|
|
@@ -100,20 +92,26 @@ export declare class LeaderBridge {
|
|
|
100
92
|
epoch: number;
|
|
101
93
|
clientId: string;
|
|
102
94
|
invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
|
|
103
|
-
heartbeatMs?: number;
|
|
104
95
|
});
|
|
105
|
-
/**
|
|
96
|
+
/** Announce leadership (on promotion and in answer to every `hello`). */
|
|
106
97
|
announce(): void;
|
|
107
98
|
/** Fan a worker event out to all followers on the current epoch. */
|
|
108
99
|
broadcastEvent(event: SyncWorkerEvent): void;
|
|
109
100
|
close(): void;
|
|
110
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Timer seam for {@link FollowerLink}: run `callback` after `delayMs` and
|
|
104
|
+
* return a function that cancels it. Tests inject a manual clock.
|
|
105
|
+
*/
|
|
106
|
+
type FollowerSchedule = (callback: () => void, delayMs: number) => () => void;
|
|
111
107
|
/**
|
|
112
108
|
* Runs on a follower tab. Sends `req`s to the leader and settles them on the
|
|
113
109
|
* matching `res`; queues calls (bounded, timed) while no leader is bound (the
|
|
114
110
|
* handover gap) and flushes them once an `announce` binds a new leader.
|
|
115
111
|
* Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
|
|
116
|
-
* the resolved leader `clientId` back through `onLeaderChange`.
|
|
112
|
+
* the resolved leader `clientId` back through `onLeaderChange`. Probes the
|
|
113
|
+
* bound leader with `hello` after a quiet period and reports `blocked` only
|
|
114
|
+
* when a probe goes unanswered (see the module comment).
|
|
117
115
|
*/
|
|
118
116
|
export declare class FollowerLink {
|
|
119
117
|
#private;
|
|
@@ -125,6 +123,7 @@ export declare class FollowerLink {
|
|
|
125
123
|
onStateChange?: (state: LeadershipState) => void;
|
|
126
124
|
callTimeoutMs?: number;
|
|
127
125
|
queueLimit?: number;
|
|
126
|
+
schedule?: FollowerSchedule;
|
|
128
127
|
});
|
|
129
128
|
get epoch(): number;
|
|
130
129
|
get maxEpochSeen(): number;
|
package/dist/multi-tab.js
CHANGED
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
* survive `postMessage` on a BroadcastChannel):
|
|
12
12
|
*
|
|
13
13
|
* follower → leader
|
|
14
|
-
* hello {t,epoch?,fromId} — "who's the leader?" on join
|
|
14
|
+
* hello {t,epoch?,fromId} — "who's the leader?" on join,
|
|
15
|
+
* and the follower's liveness probe
|
|
15
16
|
* req {t,epoch,fromId,reqId,method,args}
|
|
16
|
-
* bye {t,fromId} — follower leaving (best effort)
|
|
17
17
|
* leader → all
|
|
18
|
-
* announce {t,epoch,clientId} — "I am the leader, this epoch"
|
|
18
|
+
* announce {t,epoch,clientId} — "I am the leader, this epoch";
|
|
19
|
+
* the answer to every hello
|
|
19
20
|
* res {t,epoch,reqId,ok,value|error} — reply to one req
|
|
20
21
|
* event {t,epoch,event} — fan-out (invalidate/presence/…)
|
|
21
22
|
*
|
|
@@ -29,6 +30,16 @@
|
|
|
29
30
|
* promoter reads the highest epoch it has seen and adds one, so successive
|
|
30
31
|
* leaders always strictly increase it even across the lock-handover gap.
|
|
31
32
|
*
|
|
33
|
+
* Liveness is follower-driven: the leader runs no timer. Browsers throttle
|
|
34
|
+
* the timers of a hidden tab (Chrome's intensive throttling wakes them about
|
|
35
|
+
* once a minute) but still dispatch its BroadcastChannel messages, so a leader
|
|
36
|
+
* heartbeat misses its schedule in a background leader tab while that tab is
|
|
37
|
+
* alive. Instead, a bound follower that has heard no `announce` for a third of
|
|
38
|
+
* its call timeout posts a `hello`, and goes `blocked` only when that probe
|
|
39
|
+
* stays unanswered for the rest of the timeout. A leader tab whose main thread
|
|
40
|
+
* processes no messages (hung or frozen) therefore still blocks its followers
|
|
41
|
+
* within `callTimeoutMs`, and a closed leader hands over through the lock.
|
|
42
|
+
*
|
|
32
43
|
* Presence identity: all tabs share the leader's one connection, so a device
|
|
33
44
|
* is exactly ONE presence peer collectively — `(actorId, leaderClientId)`.
|
|
34
45
|
* A follower's `setPresence` forwards to the leader's single publisher; there
|
|
@@ -73,23 +84,17 @@ export class LeaderBridge {
|
|
|
73
84
|
#clientId;
|
|
74
85
|
#invoke;
|
|
75
86
|
#onMessage;
|
|
76
|
-
#heartbeatMs;
|
|
77
|
-
#followers = new Set();
|
|
78
|
-
#heartbeat;
|
|
79
87
|
#closed = false;
|
|
80
88
|
constructor(options) {
|
|
81
89
|
this.#channel = options.channel;
|
|
82
90
|
this.#epoch = options.epoch;
|
|
83
91
|
this.#clientId = options.clientId;
|
|
84
92
|
this.#invoke = options.invoke;
|
|
85
|
-
this.#heartbeatMs =
|
|
86
|
-
options.heartbeatMs ??
|
|
87
|
-
Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
|
|
88
93
|
this.#onMessage = (event) => this.#handle(event.data);
|
|
89
94
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
90
95
|
this.announce();
|
|
91
96
|
}
|
|
92
|
-
/**
|
|
97
|
+
/** Announce leadership (on promotion and in answer to every `hello`). */
|
|
93
98
|
announce() {
|
|
94
99
|
if (this.#closed)
|
|
95
100
|
return;
|
|
@@ -109,23 +114,10 @@ export class LeaderBridge {
|
|
|
109
114
|
if (this.#closed)
|
|
110
115
|
return;
|
|
111
116
|
if (message.t === 'hello') {
|
|
112
|
-
// A follower joined
|
|
113
|
-
this.#trackFollower(message.fromId);
|
|
117
|
+
// A follower joined or probes liveness: tell it who leads.
|
|
114
118
|
this.announce();
|
|
115
119
|
return;
|
|
116
120
|
}
|
|
117
|
-
if (message.t === 'bound') {
|
|
118
|
-
this.#trackFollower(message.fromId);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
if (message.t === 'bye') {
|
|
122
|
-
this.#followers.delete(message.fromId);
|
|
123
|
-
if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
|
|
124
|
-
clearInterval(this.#heartbeat);
|
|
125
|
-
this.#heartbeat = undefined;
|
|
126
|
-
}
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
121
|
if (message.t !== 'req')
|
|
130
122
|
return;
|
|
131
123
|
// Ignore requests stamped for a different (dead/older) leader; that
|
|
@@ -162,19 +154,10 @@ export class LeaderBridge {
|
|
|
162
154
|
});
|
|
163
155
|
});
|
|
164
156
|
}
|
|
165
|
-
#trackFollower(fromId) {
|
|
166
|
-
this.#followers.add(fromId);
|
|
167
|
-
if (this.#heartbeat !== undefined)
|
|
168
|
-
return;
|
|
169
|
-
this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
|
|
170
|
-
}
|
|
171
157
|
close() {
|
|
172
158
|
if (this.#closed)
|
|
173
159
|
return;
|
|
174
160
|
this.#closed = true;
|
|
175
|
-
if (this.#heartbeat !== undefined)
|
|
176
|
-
clearInterval(this.#heartbeat);
|
|
177
|
-
this.#heartbeat = undefined;
|
|
178
161
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
179
162
|
this.#channel.close();
|
|
180
163
|
}
|
|
@@ -184,7 +167,9 @@ export class LeaderBridge {
|
|
|
184
167
|
* matching `res`; queues calls (bounded, timed) while no leader is bound (the
|
|
185
168
|
* handover gap) and flushes them once an `announce` binds a new leader.
|
|
186
169
|
* Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
|
|
187
|
-
* the resolved leader `clientId` back through `onLeaderChange`.
|
|
170
|
+
* the resolved leader `clientId` back through `onLeaderChange`. Probes the
|
|
171
|
+
* bound leader with `hello` after a quiet period and reports `blocked` only
|
|
172
|
+
* when a probe goes unanswered (see the module comment).
|
|
188
173
|
*/
|
|
189
174
|
export class FollowerLink {
|
|
190
175
|
#channel;
|
|
@@ -193,7 +178,10 @@ export class FollowerLink {
|
|
|
193
178
|
#onLeaderChange;
|
|
194
179
|
#onStateChange;
|
|
195
180
|
#callTimeoutMs;
|
|
181
|
+
/** Quiet period before a bound follower probes; also the blocked re-probe interval. */
|
|
182
|
+
#probeAfterMs;
|
|
196
183
|
#queueLimit;
|
|
184
|
+
#schedule;
|
|
197
185
|
#onMessage;
|
|
198
186
|
/** -1 until the first `announce` binds us to a leader. */
|
|
199
187
|
#epoch = -1;
|
|
@@ -208,8 +196,8 @@ export class FollowerLink {
|
|
|
208
196
|
state: 'waiting',
|
|
209
197
|
reason: 'leader-announcement',
|
|
210
198
|
};
|
|
211
|
-
#
|
|
212
|
-
#
|
|
199
|
+
#cancelProbe;
|
|
200
|
+
#cancelBlocked;
|
|
213
201
|
/** Resolvers waiting for the first `announce` to bind a leader. */
|
|
214
202
|
#bindWaiters = [];
|
|
215
203
|
constructor(options) {
|
|
@@ -220,12 +208,19 @@ export class FollowerLink {
|
|
|
220
208
|
this.#onStateChange = options.onStateChange ?? (() => { });
|
|
221
209
|
this.#callTimeoutMs =
|
|
222
210
|
options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
|
|
211
|
+
this.#probeAfterMs = Math.max(1, Math.floor(this.#callTimeoutMs / 3));
|
|
223
212
|
this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
|
|
213
|
+
this.#schedule =
|
|
214
|
+
options.schedule ??
|
|
215
|
+
((callback, delayMs) => {
|
|
216
|
+
const timer = setTimeout(callback, delayMs);
|
|
217
|
+
return () => clearTimeout(timer);
|
|
218
|
+
});
|
|
224
219
|
this.#onMessage = (event) => this.#handle(event.data);
|
|
225
220
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
226
|
-
// Ask the current leader to announce itself.
|
|
227
|
-
this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
|
|
228
221
|
this.#armUnboundDeadline();
|
|
222
|
+
// Ask the current leader to announce itself.
|
|
223
|
+
this.#probe();
|
|
229
224
|
}
|
|
230
225
|
get epoch() {
|
|
231
226
|
return this.#epoch;
|
|
@@ -260,13 +255,13 @@ export class FollowerLink {
|
|
|
260
255
|
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
|
|
261
256
|
}
|
|
262
257
|
return new Promise((resolve, reject) => {
|
|
263
|
-
const
|
|
258
|
+
const cancelTimer = this.#schedule(() => {
|
|
264
259
|
this.#dropBindWaiter(settle);
|
|
265
260
|
this.#setBlocked();
|
|
266
261
|
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader announced within the follower bind timeout', true));
|
|
267
262
|
}, timeoutMs);
|
|
268
263
|
const settle = () => {
|
|
269
|
-
|
|
264
|
+
cancelTimer();
|
|
270
265
|
resolve();
|
|
271
266
|
};
|
|
272
267
|
this.#bindWaiters.push(settle);
|
|
@@ -299,7 +294,7 @@ export class FollowerLink {
|
|
|
299
294
|
args,
|
|
300
295
|
resolve,
|
|
301
296
|
reject,
|
|
302
|
-
|
|
297
|
+
cancelTimer: undefined,
|
|
303
298
|
};
|
|
304
299
|
if (this.#epoch < 0) {
|
|
305
300
|
// No leader bound yet — queue with a deadline so we never hang.
|
|
@@ -308,7 +303,7 @@ export class FollowerLink {
|
|
|
308
303
|
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'follower call queue overflow while awaiting a leader', true));
|
|
309
304
|
return;
|
|
310
305
|
}
|
|
311
|
-
queued.
|
|
306
|
+
queued.cancelTimer = this.#schedule(() => {
|
|
312
307
|
this.#dropQueued(queued);
|
|
313
308
|
this.#setBlocked();
|
|
314
309
|
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader answered within the follower call timeout', true));
|
|
@@ -321,16 +316,17 @@ export class FollowerLink {
|
|
|
321
316
|
}
|
|
322
317
|
#send(queued) {
|
|
323
318
|
const reqId = this.#nextReqId++;
|
|
324
|
-
|
|
319
|
+
// The deadline fails this one call. Leader reachability is decided by the
|
|
320
|
+
// liveness probe alone, so a slow call against a live leader leaves the
|
|
321
|
+
// link bound.
|
|
322
|
+
this.#inFlight.set(reqId, {
|
|
325
323
|
resolve: queued.resolve,
|
|
326
324
|
reject: queued.reject,
|
|
327
|
-
|
|
325
|
+
cancelTimer: this.#schedule(() => {
|
|
328
326
|
this.#inFlight.delete(reqId);
|
|
329
|
-
this.#setBlocked();
|
|
330
327
|
queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout', true));
|
|
331
328
|
}, this.#callTimeoutMs),
|
|
332
|
-
};
|
|
333
|
-
this.#inFlight.set(reqId, inflight);
|
|
329
|
+
});
|
|
334
330
|
this.#channel.postMessage({
|
|
335
331
|
t: 'req',
|
|
336
332
|
epoch: this.#epoch,
|
|
@@ -366,10 +362,7 @@ export class FollowerLink {
|
|
|
366
362
|
leaderClientId: message.clientId,
|
|
367
363
|
epoch: message.epoch,
|
|
368
364
|
});
|
|
369
|
-
this.#
|
|
370
|
-
if (changed) {
|
|
371
|
-
this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
|
|
372
|
-
}
|
|
365
|
+
this.#armLivenessProbe();
|
|
373
366
|
this.#resolveBindWaiters();
|
|
374
367
|
this.#flushQueue();
|
|
375
368
|
return;
|
|
@@ -383,8 +376,7 @@ export class FollowerLink {
|
|
|
383
376
|
if (inflight === undefined)
|
|
384
377
|
return;
|
|
385
378
|
this.#inFlight.delete(message.reqId);
|
|
386
|
-
|
|
387
|
-
clearTimeout(inflight.timer);
|
|
379
|
+
inflight.cancelTimer();
|
|
388
380
|
if (message.ok) {
|
|
389
381
|
inflight.resolve(message.value);
|
|
390
382
|
}
|
|
@@ -406,9 +398,8 @@ export class FollowerLink {
|
|
|
406
398
|
const pending = this.#queue;
|
|
407
399
|
this.#queue = [];
|
|
408
400
|
for (const queued of pending) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
queued.timer = undefined;
|
|
401
|
+
queued.cancelTimer?.();
|
|
402
|
+
queued.cancelTimer = undefined;
|
|
412
403
|
this.#send(queued);
|
|
413
404
|
}
|
|
414
405
|
}
|
|
@@ -425,11 +416,7 @@ export class FollowerLink {
|
|
|
425
416
|
this.#leaderClientId = '';
|
|
426
417
|
this.#setState({ state: 'waiting', reason: 'handover' });
|
|
427
418
|
this.#armUnboundDeadline();
|
|
428
|
-
this.#
|
|
429
|
-
t: 'hello',
|
|
430
|
-
fromId: this.#fromId,
|
|
431
|
-
epoch: this.#maxEpochSeen,
|
|
432
|
-
});
|
|
419
|
+
this.#probe();
|
|
433
420
|
}
|
|
434
421
|
close() {
|
|
435
422
|
if (this.#closed)
|
|
@@ -437,17 +424,14 @@ export class FollowerLink {
|
|
|
437
424
|
this.#closed = true;
|
|
438
425
|
this.#clearReachabilityTimers();
|
|
439
426
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
440
|
-
this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
|
|
441
427
|
const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the follower link was closed');
|
|
442
428
|
for (const inflight of this.#inFlight.values()) {
|
|
443
|
-
|
|
444
|
-
clearTimeout(inflight.timer);
|
|
429
|
+
inflight.cancelTimer();
|
|
445
430
|
inflight.reject(closedError);
|
|
446
431
|
}
|
|
447
432
|
this.#inFlight.clear();
|
|
448
433
|
for (const queued of this.#queue) {
|
|
449
|
-
|
|
450
|
-
clearTimeout(queued.timer);
|
|
434
|
+
queued.cancelTimer?.();
|
|
451
435
|
queued.reject(closedError);
|
|
452
436
|
}
|
|
453
437
|
this.#queue = [];
|
|
@@ -477,6 +461,18 @@ export class FollowerLink {
|
|
|
477
461
|
// A status listener must never break cross-tab coordination.
|
|
478
462
|
}
|
|
479
463
|
}
|
|
464
|
+
/** Ask the leader to announce itself; every live leader answers a `hello`. */
|
|
465
|
+
#probe() {
|
|
466
|
+
this.#channel.postMessage({
|
|
467
|
+
t: 'hello',
|
|
468
|
+
fromId: this.#fromId,
|
|
469
|
+
epoch: this.#maxEpochSeen,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* No leader answered in time. Calls reject immediately from here on; keep
|
|
474
|
+
* probing so a leader that answers again rebinds this link.
|
|
475
|
+
*/
|
|
480
476
|
#setBlocked() {
|
|
481
477
|
this.#clearReachabilityTimers();
|
|
482
478
|
this.#setState({
|
|
@@ -485,24 +481,36 @@ export class FollowerLink {
|
|
|
485
481
|
code: FOLLOWER_TIMEOUT_CODE,
|
|
486
482
|
retryable: true,
|
|
487
483
|
});
|
|
484
|
+
const reprobe = () => {
|
|
485
|
+
this.#cancelProbe = this.#schedule(() => {
|
|
486
|
+
reprobe();
|
|
487
|
+
this.#probe();
|
|
488
|
+
}, this.#probeAfterMs);
|
|
489
|
+
};
|
|
490
|
+
reprobe();
|
|
488
491
|
}
|
|
489
492
|
#armUnboundDeadline() {
|
|
490
493
|
this.#clearReachabilityTimers();
|
|
491
|
-
this.#
|
|
494
|
+
this.#cancelBlocked = this.#schedule(() => this.#setBlocked(), this.#callTimeoutMs);
|
|
492
495
|
}
|
|
493
|
-
|
|
496
|
+
/**
|
|
497
|
+
* An announce just proved the leader alive. After a quiet period, probe it;
|
|
498
|
+
* block only if that probe stays unanswered for the rest of the call
|
|
499
|
+
* timeout. The deadline is armed before the probe is posted so a reply that
|
|
500
|
+
* arrives synchronously clears it.
|
|
501
|
+
*/
|
|
502
|
+
#armLivenessProbe() {
|
|
494
503
|
this.#clearReachabilityTimers();
|
|
495
|
-
this.#
|
|
496
|
-
this.#
|
|
497
|
-
|
|
498
|
-
|
|
504
|
+
this.#cancelProbe = this.#schedule(() => {
|
|
505
|
+
this.#cancelProbe = undefined;
|
|
506
|
+
this.#cancelBlocked = this.#schedule(() => this.#setBlocked(), this.#callTimeoutMs - this.#probeAfterMs);
|
|
507
|
+
this.#probe();
|
|
508
|
+
}, this.#probeAfterMs);
|
|
499
509
|
}
|
|
500
510
|
#clearReachabilityTimers() {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
this.#waitingTimer = undefined;
|
|
506
|
-
this.#blockedTimer = undefined;
|
|
511
|
+
this.#cancelProbe?.();
|
|
512
|
+
this.#cancelBlocked?.();
|
|
513
|
+
this.#cancelProbe = undefined;
|
|
514
|
+
this.#cancelBlocked = undefined;
|
|
507
515
|
}
|
|
508
516
|
}
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -114,7 +114,11 @@ export interface SyncClientHandleConfig {
|
|
|
114
114
|
readonly multiTab?: boolean;
|
|
115
115
|
/** Cross-tab channel factory (default `BroadcastChannel`); injectable for tests. */
|
|
116
116
|
readonly channelFactory?: (name: string) => CrossTabChannel;
|
|
117
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Deadline for a follower call (covers the leader-handover gap), and the
|
|
119
|
+
* bound on leader silence: a follower probes after a third of it and goes
|
|
120
|
+
* `blocked` when the probe stays unanswered for the rest. Default 10 s.
|
|
121
|
+
*/
|
|
118
122
|
readonly followerCallTimeoutMs?: number;
|
|
119
123
|
/** Fires when this handle's role changes (follower → leader on promotion). */
|
|
120
124
|
readonly onRoleChange?: (role: HandleRole) => void;
|
package/dist/worker-host.js
CHANGED
|
@@ -630,7 +630,6 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
630
630
|
epoch: parts.epoch ?? 0,
|
|
631
631
|
clientId,
|
|
632
632
|
invoke,
|
|
633
|
-
heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
|
|
634
633
|
});
|
|
635
634
|
}
|
|
636
635
|
: undefined;
|
|
@@ -721,7 +720,6 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
721
720
|
epoch: nextEpoch,
|
|
722
721
|
clientId,
|
|
723
722
|
invoke,
|
|
724
|
-
heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
|
|
725
723
|
});
|
|
726
724
|
},
|
|
727
725
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.26.
|
|
92
|
+
"@syncular/core": "0.26.1"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.26.
|
|
95
|
+
"@syncular/server": "0.26.1",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/multi-tab.ts
CHANGED
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
* survive `postMessage` on a BroadcastChannel):
|
|
12
12
|
*
|
|
13
13
|
* follower → leader
|
|
14
|
-
* hello {t,epoch?,fromId} — "who's the leader?" on join
|
|
14
|
+
* hello {t,epoch?,fromId} — "who's the leader?" on join,
|
|
15
|
+
* and the follower's liveness probe
|
|
15
16
|
* req {t,epoch,fromId,reqId,method,args}
|
|
16
|
-
* bye {t,fromId} — follower leaving (best effort)
|
|
17
17
|
* leader → all
|
|
18
|
-
* announce {t,epoch,clientId} — "I am the leader, this epoch"
|
|
18
|
+
* announce {t,epoch,clientId} — "I am the leader, this epoch";
|
|
19
|
+
* the answer to every hello
|
|
19
20
|
* res {t,epoch,reqId,ok,value|error} — reply to one req
|
|
20
21
|
* event {t,epoch,event} — fan-out (invalidate/presence/…)
|
|
21
22
|
*
|
|
@@ -29,6 +30,16 @@
|
|
|
29
30
|
* promoter reads the highest epoch it has seen and adds one, so successive
|
|
30
31
|
* leaders always strictly increase it even across the lock-handover gap.
|
|
31
32
|
*
|
|
33
|
+
* Liveness is follower-driven: the leader runs no timer. Browsers throttle
|
|
34
|
+
* the timers of a hidden tab (Chrome's intensive throttling wakes them about
|
|
35
|
+
* once a minute) but still dispatch its BroadcastChannel messages, so a leader
|
|
36
|
+
* heartbeat misses its schedule in a background leader tab while that tab is
|
|
37
|
+
* alive. Instead, a bound follower that has heard no `announce` for a third of
|
|
38
|
+
* its call timeout posts a `hello`, and goes `blocked` only when that probe
|
|
39
|
+
* stays unanswered for the rest of the timeout. A leader tab whose main thread
|
|
40
|
+
* processes no messages (hung or frozen) therefore still blocks its followers
|
|
41
|
+
* within `callTimeoutMs`, and a closed leader hands over through the lock.
|
|
42
|
+
*
|
|
32
43
|
* Presence identity: all tabs share the leader's one connection, so a device
|
|
33
44
|
* is exactly ONE presence peer collectively — `(actorId, leaderClientId)`.
|
|
34
45
|
* A follower's `setPresence` forwards to the leader's single publisher; there
|
|
@@ -76,16 +87,6 @@ interface HelloMessage {
|
|
|
76
87
|
readonly epoch?: number;
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
interface ByeMessage {
|
|
80
|
-
readonly t: 'bye';
|
|
81
|
-
readonly fromId: string;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
interface BoundMessage {
|
|
85
|
-
readonly t: 'bound';
|
|
86
|
-
readonly fromId: string;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
90
|
interface ReqMessage {
|
|
90
91
|
readonly t: 'req';
|
|
91
92
|
readonly epoch: number;
|
|
@@ -118,8 +119,6 @@ interface EventMessage {
|
|
|
118
119
|
|
|
119
120
|
export type MultiTabMessage =
|
|
120
121
|
| HelloMessage
|
|
121
|
-
| ByeMessage
|
|
122
|
-
| BoundMessage
|
|
123
122
|
| ReqMessage
|
|
124
123
|
| AnnounceMessage
|
|
125
124
|
| ResMessage
|
|
@@ -181,9 +180,6 @@ export class LeaderBridge {
|
|
|
181
180
|
args: readonly unknown[],
|
|
182
181
|
) => Promise<unknown>;
|
|
183
182
|
readonly #onMessage: (event: { data: MultiTabMessage }) => void;
|
|
184
|
-
readonly #heartbeatMs: number;
|
|
185
|
-
readonly #followers = new Set<string>();
|
|
186
|
-
#heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
187
183
|
#closed = false;
|
|
188
184
|
|
|
189
185
|
constructor(options: {
|
|
@@ -191,21 +187,17 @@ export class LeaderBridge {
|
|
|
191
187
|
epoch: number;
|
|
192
188
|
clientId: string;
|
|
193
189
|
invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
|
|
194
|
-
heartbeatMs?: number;
|
|
195
190
|
}) {
|
|
196
191
|
this.#channel = options.channel;
|
|
197
192
|
this.#epoch = options.epoch;
|
|
198
193
|
this.#clientId = options.clientId;
|
|
199
194
|
this.#invoke = options.invoke;
|
|
200
|
-
this.#heartbeatMs =
|
|
201
|
-
options.heartbeatMs ??
|
|
202
|
-
Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
|
|
203
195
|
this.#onMessage = (event) => this.#handle(event.data);
|
|
204
196
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
205
197
|
this.announce();
|
|
206
198
|
}
|
|
207
199
|
|
|
208
|
-
/**
|
|
200
|
+
/** Announce leadership (on promotion and in answer to every `hello`). */
|
|
209
201
|
announce(): void {
|
|
210
202
|
if (this.#closed) return;
|
|
211
203
|
this.#channel.postMessage({
|
|
@@ -224,23 +216,10 @@ export class LeaderBridge {
|
|
|
224
216
|
#handle(message: MultiTabMessage): void {
|
|
225
217
|
if (this.#closed) return;
|
|
226
218
|
if (message.t === 'hello') {
|
|
227
|
-
// A follower joined
|
|
228
|
-
this.#trackFollower(message.fromId);
|
|
219
|
+
// A follower joined or probes liveness: tell it who leads.
|
|
229
220
|
this.announce();
|
|
230
221
|
return;
|
|
231
222
|
}
|
|
232
|
-
if (message.t === 'bound') {
|
|
233
|
-
this.#trackFollower(message.fromId);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
if (message.t === 'bye') {
|
|
237
|
-
this.#followers.delete(message.fromId);
|
|
238
|
-
if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
|
|
239
|
-
clearInterval(this.#heartbeat);
|
|
240
|
-
this.#heartbeat = undefined;
|
|
241
|
-
}
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
223
|
if (message.t !== 'req') return;
|
|
245
224
|
// Ignore requests stamped for a different (dead/older) leader; that
|
|
246
225
|
// follower will re-stamp once it sees our announce.
|
|
@@ -281,17 +260,9 @@ export class LeaderBridge {
|
|
|
281
260
|
);
|
|
282
261
|
}
|
|
283
262
|
|
|
284
|
-
#trackFollower(fromId: string): void {
|
|
285
|
-
this.#followers.add(fromId);
|
|
286
|
-
if (this.#heartbeat !== undefined) return;
|
|
287
|
-
this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
263
|
close(): void {
|
|
291
264
|
if (this.#closed) return;
|
|
292
265
|
this.#closed = true;
|
|
293
|
-
if (this.#heartbeat !== undefined) clearInterval(this.#heartbeat);
|
|
294
|
-
this.#heartbeat = undefined;
|
|
295
266
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
296
267
|
this.#channel.close();
|
|
297
268
|
}
|
|
@@ -301,18 +272,24 @@ export class LeaderBridge {
|
|
|
301
272
|
// Follower side: proxy the logical API to the leader over the channel
|
|
302
273
|
// ---------------------------------------------------------------------------
|
|
303
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Timer seam for {@link FollowerLink}: run `callback` after `delayMs` and
|
|
277
|
+
* return a function that cancels it. Tests inject a manual clock.
|
|
278
|
+
*/
|
|
279
|
+
type FollowerSchedule = (callback: () => void, delayMs: number) => () => void;
|
|
280
|
+
|
|
304
281
|
interface QueuedCall {
|
|
305
282
|
readonly method: string;
|
|
306
283
|
readonly args: readonly unknown[];
|
|
307
284
|
readonly resolve: (value: unknown) => void;
|
|
308
285
|
readonly reject: (error: unknown) => void;
|
|
309
|
-
|
|
286
|
+
cancelTimer: (() => void) | undefined;
|
|
310
287
|
}
|
|
311
288
|
|
|
312
289
|
interface InFlight {
|
|
313
290
|
readonly resolve: (value: unknown) => void;
|
|
314
291
|
readonly reject: (error: unknown) => void;
|
|
315
|
-
|
|
292
|
+
readonly cancelTimer: () => void;
|
|
316
293
|
}
|
|
317
294
|
|
|
318
295
|
/**
|
|
@@ -320,7 +297,9 @@ interface InFlight {
|
|
|
320
297
|
* matching `res`; queues calls (bounded, timed) while no leader is bound (the
|
|
321
298
|
* handover gap) and flushes them once an `announce` binds a new leader.
|
|
322
299
|
* Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
|
|
323
|
-
* the resolved leader `clientId` back through `onLeaderChange`.
|
|
300
|
+
* the resolved leader `clientId` back through `onLeaderChange`. Probes the
|
|
301
|
+
* bound leader with `hello` after a quiet period and reports `blocked` only
|
|
302
|
+
* when a probe goes unanswered (see the module comment).
|
|
324
303
|
*/
|
|
325
304
|
export class FollowerLink {
|
|
326
305
|
readonly #channel: CrossTabChannel;
|
|
@@ -329,7 +308,10 @@ export class FollowerLink {
|
|
|
329
308
|
readonly #onLeaderChange: (clientId: string) => void;
|
|
330
309
|
readonly #onStateChange: (state: LeadershipState) => void;
|
|
331
310
|
readonly #callTimeoutMs: number;
|
|
311
|
+
/** Quiet period before a bound follower probes; also the blocked re-probe interval. */
|
|
312
|
+
readonly #probeAfterMs: number;
|
|
332
313
|
readonly #queueLimit: number;
|
|
314
|
+
readonly #schedule: FollowerSchedule;
|
|
333
315
|
readonly #onMessage: (event: { data: MultiTabMessage }) => void;
|
|
334
316
|
|
|
335
317
|
/** -1 until the first `announce` binds us to a leader. */
|
|
@@ -345,8 +327,8 @@ export class FollowerLink {
|
|
|
345
327
|
state: 'waiting',
|
|
346
328
|
reason: 'leader-announcement',
|
|
347
329
|
};
|
|
348
|
-
#
|
|
349
|
-
#
|
|
330
|
+
#cancelProbe: (() => void) | undefined;
|
|
331
|
+
#cancelBlocked: (() => void) | undefined;
|
|
350
332
|
/** Resolvers waiting for the first `announce` to bind a leader. */
|
|
351
333
|
#bindWaiters: Array<() => void> = [];
|
|
352
334
|
|
|
@@ -358,6 +340,7 @@ export class FollowerLink {
|
|
|
358
340
|
onStateChange?: (state: LeadershipState) => void;
|
|
359
341
|
callTimeoutMs?: number;
|
|
360
342
|
queueLimit?: number;
|
|
343
|
+
schedule?: FollowerSchedule;
|
|
361
344
|
}) {
|
|
362
345
|
this.#channel = options.channel;
|
|
363
346
|
this.#fromId = options.fromId;
|
|
@@ -366,12 +349,19 @@ export class FollowerLink {
|
|
|
366
349
|
this.#onStateChange = options.onStateChange ?? (() => {});
|
|
367
350
|
this.#callTimeoutMs =
|
|
368
351
|
options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
|
|
352
|
+
this.#probeAfterMs = Math.max(1, Math.floor(this.#callTimeoutMs / 3));
|
|
369
353
|
this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
|
|
354
|
+
this.#schedule =
|
|
355
|
+
options.schedule ??
|
|
356
|
+
((callback, delayMs) => {
|
|
357
|
+
const timer = setTimeout(callback, delayMs);
|
|
358
|
+
return () => clearTimeout(timer);
|
|
359
|
+
});
|
|
370
360
|
this.#onMessage = (event) => this.#handle(event.data);
|
|
371
361
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
372
|
-
// Ask the current leader to announce itself.
|
|
373
|
-
this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
|
|
374
362
|
this.#armUnboundDeadline();
|
|
363
|
+
// Ask the current leader to announce itself.
|
|
364
|
+
this.#probe();
|
|
375
365
|
}
|
|
376
366
|
|
|
377
367
|
get epoch(): number {
|
|
@@ -413,7 +403,7 @@ export class FollowerLink {
|
|
|
413
403
|
);
|
|
414
404
|
}
|
|
415
405
|
return new Promise((resolve, reject) => {
|
|
416
|
-
const
|
|
406
|
+
const cancelTimer = this.#schedule(() => {
|
|
417
407
|
this.#dropBindWaiter(settle);
|
|
418
408
|
this.#setBlocked();
|
|
419
409
|
reject(
|
|
@@ -425,7 +415,7 @@ export class FollowerLink {
|
|
|
425
415
|
);
|
|
426
416
|
}, timeoutMs);
|
|
427
417
|
const settle = (): void => {
|
|
428
|
-
|
|
418
|
+
cancelTimer();
|
|
429
419
|
resolve();
|
|
430
420
|
};
|
|
431
421
|
this.#bindWaiters.push(settle);
|
|
@@ -466,7 +456,7 @@ export class FollowerLink {
|
|
|
466
456
|
args,
|
|
467
457
|
resolve,
|
|
468
458
|
reject,
|
|
469
|
-
|
|
459
|
+
cancelTimer: undefined,
|
|
470
460
|
};
|
|
471
461
|
if (this.#epoch < 0) {
|
|
472
462
|
// No leader bound yet — queue with a deadline so we never hang.
|
|
@@ -481,7 +471,7 @@ export class FollowerLink {
|
|
|
481
471
|
);
|
|
482
472
|
return;
|
|
483
473
|
}
|
|
484
|
-
queued.
|
|
474
|
+
queued.cancelTimer = this.#schedule(() => {
|
|
485
475
|
this.#dropQueued(queued);
|
|
486
476
|
this.#setBlocked();
|
|
487
477
|
reject(
|
|
@@ -501,12 +491,14 @@ export class FollowerLink {
|
|
|
501
491
|
|
|
502
492
|
#send(queued: QueuedCall): void {
|
|
503
493
|
const reqId = this.#nextReqId++;
|
|
504
|
-
|
|
494
|
+
// The deadline fails this one call. Leader reachability is decided by the
|
|
495
|
+
// liveness probe alone, so a slow call against a live leader leaves the
|
|
496
|
+
// link bound.
|
|
497
|
+
this.#inFlight.set(reqId, {
|
|
505
498
|
resolve: queued.resolve,
|
|
506
499
|
reject: queued.reject,
|
|
507
|
-
|
|
500
|
+
cancelTimer: this.#schedule(() => {
|
|
508
501
|
this.#inFlight.delete(reqId);
|
|
509
|
-
this.#setBlocked();
|
|
510
502
|
queued.reject(
|
|
511
503
|
new ClientSyncError(
|
|
512
504
|
FOLLOWER_TIMEOUT_CODE,
|
|
@@ -515,8 +507,7 @@ export class FollowerLink {
|
|
|
515
507
|
),
|
|
516
508
|
);
|
|
517
509
|
}, this.#callTimeoutMs),
|
|
518
|
-
};
|
|
519
|
-
this.#inFlight.set(reqId, inflight);
|
|
510
|
+
});
|
|
520
511
|
this.#channel.postMessage({
|
|
521
512
|
t: 'req',
|
|
522
513
|
epoch: this.#epoch,
|
|
@@ -551,10 +542,7 @@ export class FollowerLink {
|
|
|
551
542
|
leaderClientId: message.clientId,
|
|
552
543
|
epoch: message.epoch,
|
|
553
544
|
});
|
|
554
|
-
this.#
|
|
555
|
-
if (changed) {
|
|
556
|
-
this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
|
|
557
|
-
}
|
|
545
|
+
this.#armLivenessProbe();
|
|
558
546
|
this.#resolveBindWaiters();
|
|
559
547
|
this.#flushQueue();
|
|
560
548
|
return;
|
|
@@ -566,7 +554,7 @@ export class FollowerLink {
|
|
|
566
554
|
const inflight = this.#inFlight.get(message.reqId);
|
|
567
555
|
if (inflight === undefined) return;
|
|
568
556
|
this.#inFlight.delete(message.reqId);
|
|
569
|
-
|
|
557
|
+
inflight.cancelTimer();
|
|
570
558
|
if (message.ok) {
|
|
571
559
|
inflight.resolve(message.value);
|
|
572
560
|
} else {
|
|
@@ -593,8 +581,8 @@ export class FollowerLink {
|
|
|
593
581
|
const pending = this.#queue;
|
|
594
582
|
this.#queue = [];
|
|
595
583
|
for (const queued of pending) {
|
|
596
|
-
|
|
597
|
-
queued.
|
|
584
|
+
queued.cancelTimer?.();
|
|
585
|
+
queued.cancelTimer = undefined;
|
|
598
586
|
this.#send(queued);
|
|
599
587
|
}
|
|
600
588
|
}
|
|
@@ -611,11 +599,7 @@ export class FollowerLink {
|
|
|
611
599
|
this.#leaderClientId = '';
|
|
612
600
|
this.#setState({ state: 'waiting', reason: 'handover' });
|
|
613
601
|
this.#armUnboundDeadline();
|
|
614
|
-
this.#
|
|
615
|
-
t: 'hello',
|
|
616
|
-
fromId: this.#fromId,
|
|
617
|
-
epoch: this.#maxEpochSeen,
|
|
618
|
-
});
|
|
602
|
+
this.#probe();
|
|
619
603
|
}
|
|
620
604
|
|
|
621
605
|
close(): void {
|
|
@@ -623,18 +607,17 @@ export class FollowerLink {
|
|
|
623
607
|
this.#closed = true;
|
|
624
608
|
this.#clearReachabilityTimers();
|
|
625
609
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
626
|
-
this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
|
|
627
610
|
const closedError = new ClientSyncError(
|
|
628
611
|
WORKER_FAILED_CODE,
|
|
629
612
|
'the follower link was closed',
|
|
630
613
|
);
|
|
631
614
|
for (const inflight of this.#inFlight.values()) {
|
|
632
|
-
|
|
615
|
+
inflight.cancelTimer();
|
|
633
616
|
inflight.reject(closedError);
|
|
634
617
|
}
|
|
635
618
|
this.#inFlight.clear();
|
|
636
619
|
for (const queued of this.#queue) {
|
|
637
|
-
|
|
620
|
+
queued.cancelTimer?.();
|
|
638
621
|
queued.reject(closedError);
|
|
639
622
|
}
|
|
640
623
|
this.#queue = [];
|
|
@@ -667,6 +650,19 @@ export class FollowerLink {
|
|
|
667
650
|
}
|
|
668
651
|
}
|
|
669
652
|
|
|
653
|
+
/** Ask the leader to announce itself; every live leader answers a `hello`. */
|
|
654
|
+
#probe(): void {
|
|
655
|
+
this.#channel.postMessage({
|
|
656
|
+
t: 'hello',
|
|
657
|
+
fromId: this.#fromId,
|
|
658
|
+
epoch: this.#maxEpochSeen,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* No leader answered in time. Calls reject immediately from here on; keep
|
|
664
|
+
* probing so a leader that answers again rebinds this link.
|
|
665
|
+
*/
|
|
670
666
|
#setBlocked(): void {
|
|
671
667
|
this.#clearReachabilityTimers();
|
|
672
668
|
this.#setState({
|
|
@@ -675,34 +671,45 @@ export class FollowerLink {
|
|
|
675
671
|
code: FOLLOWER_TIMEOUT_CODE,
|
|
676
672
|
retryable: true,
|
|
677
673
|
});
|
|
674
|
+
const reprobe = (): void => {
|
|
675
|
+
this.#cancelProbe = this.#schedule(() => {
|
|
676
|
+
reprobe();
|
|
677
|
+
this.#probe();
|
|
678
|
+
}, this.#probeAfterMs);
|
|
679
|
+
};
|
|
680
|
+
reprobe();
|
|
678
681
|
}
|
|
679
682
|
|
|
680
683
|
#armUnboundDeadline(): void {
|
|
681
684
|
this.#clearReachabilityTimers();
|
|
682
|
-
this.#
|
|
685
|
+
this.#cancelBlocked = this.#schedule(
|
|
683
686
|
() => this.#setBlocked(),
|
|
684
687
|
this.#callTimeoutMs,
|
|
685
688
|
);
|
|
686
689
|
}
|
|
687
690
|
|
|
688
|
-
|
|
691
|
+
/**
|
|
692
|
+
* An announce just proved the leader alive. After a quiet period, probe it;
|
|
693
|
+
* block only if that probe stays unanswered for the rest of the call
|
|
694
|
+
* timeout. The deadline is armed before the probe is posted so a reply that
|
|
695
|
+
* arrives synchronously clears it.
|
|
696
|
+
*/
|
|
697
|
+
#armLivenessProbe(): void {
|
|
689
698
|
this.#clearReachabilityTimers();
|
|
690
|
-
this.#
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
this.#callTimeoutMs,
|
|
699
|
-
);
|
|
699
|
+
this.#cancelProbe = this.#schedule(() => {
|
|
700
|
+
this.#cancelProbe = undefined;
|
|
701
|
+
this.#cancelBlocked = this.#schedule(
|
|
702
|
+
() => this.#setBlocked(),
|
|
703
|
+
this.#callTimeoutMs - this.#probeAfterMs,
|
|
704
|
+
);
|
|
705
|
+
this.#probe();
|
|
706
|
+
}, this.#probeAfterMs);
|
|
700
707
|
}
|
|
701
708
|
|
|
702
709
|
#clearReachabilityTimers(): void {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
this.#
|
|
706
|
-
this.#
|
|
710
|
+
this.#cancelProbe?.();
|
|
711
|
+
this.#cancelBlocked?.();
|
|
712
|
+
this.#cancelProbe = undefined;
|
|
713
|
+
this.#cancelBlocked = undefined;
|
|
707
714
|
}
|
|
708
715
|
}
|
package/src/worker-host.ts
CHANGED
|
@@ -220,7 +220,11 @@ export interface SyncClientHandleConfig {
|
|
|
220
220
|
readonly multiTab?: boolean;
|
|
221
221
|
/** Cross-tab channel factory (default `BroadcastChannel`); injectable for tests. */
|
|
222
222
|
readonly channelFactory?: (name: string) => CrossTabChannel;
|
|
223
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* Deadline for a follower call (covers the leader-handover gap), and the
|
|
225
|
+
* bound on leader silence: a follower probes after a third of it and goes
|
|
226
|
+
* `blocked` when the probe stays unanswered for the rest. Default 10 s.
|
|
227
|
+
*/
|
|
224
228
|
readonly followerCallTimeoutMs?: number;
|
|
225
229
|
/** Fires when this handle's role changes (follower → leader on promotion). */
|
|
226
230
|
readonly onRoleChange?: (role: HandleRole) => void;
|
|
@@ -1029,10 +1033,6 @@ async function bootLeader(
|
|
|
1029
1033
|
epoch: parts.epoch ?? 0,
|
|
1030
1034
|
clientId,
|
|
1031
1035
|
invoke,
|
|
1032
|
-
heartbeatMs: Math.max(
|
|
1033
|
-
10,
|
|
1034
|
-
Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
|
|
1035
|
-
),
|
|
1036
1036
|
});
|
|
1037
1037
|
}
|
|
1038
1038
|
: undefined;
|
|
@@ -1139,10 +1139,6 @@ async function bootFollower(
|
|
|
1139
1139
|
epoch: nextEpoch,
|
|
1140
1140
|
clientId,
|
|
1141
1141
|
invoke,
|
|
1142
|
-
heartbeatMs: Math.max(
|
|
1143
|
-
10,
|
|
1144
|
-
Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
|
|
1145
|
-
),
|
|
1146
1142
|
});
|
|
1147
1143
|
},
|
|
1148
1144
|
}
|