@syncular/client 0.26.0 → 0.26.3

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 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 announcements continue as heartbeats while followers are attached. If
199
- a tab can acquire neither a response nor a new lock grant before the configured
200
- `followerCallTimeoutMs`, `handle.leadership` becomes
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 }`. Calls then reject immediately.
203
- A later announcement rebinds the same handle; a granted Web Lock promotes it.
204
- An unreachable `BroadcastChannel` is never treated as evidence that the lock
205
- owner is stale, so it never authorizes a second worker or database owner.
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:
@@ -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 | ByeMessage | BoundMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
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
- /** Re-announce leadership (on promotion and on a follower `hello`). */
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
- /** Re-announce leadership (on promotion and on a follower `hello`). */
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 (or is contesting) — tell it who leads.
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
- #waitingTimer;
212
- #blockedTimer;
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 timer = setTimeout(() => {
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
- clearTimeout(timer);
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
- timer: undefined,
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.timer = setTimeout(() => {
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
- const inflight = {
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
- timer: setTimeout(() => {
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.#armBoundDeadline();
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
- if (inflight.timer !== undefined)
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
- if (queued.timer !== undefined)
410
- clearTimeout(queued.timer);
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.#channel.postMessage({
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
- if (inflight.timer !== undefined)
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
- if (queued.timer !== undefined)
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.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
494
+ this.#cancelBlocked = this.#schedule(() => this.#setBlocked(), this.#callTimeoutMs);
492
495
  }
493
- #armBoundDeadline() {
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.#waitingTimer = setTimeout(() => {
496
- this.#setState({ state: 'waiting', reason: 'leader-announcement' });
497
- }, Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)));
498
- this.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
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
- if (this.#waitingTimer !== undefined)
502
- clearTimeout(this.#waitingTimer);
503
- if (this.#blockedTimer !== undefined)
504
- clearTimeout(this.#blockedTimer);
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
  }
@@ -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
- /** Deadline for a follower call (covers the leader-handover gap). */
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;
@@ -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.0",
3
+ "version": "0.26.3",
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.0"
92
+ "@syncular/core": "0.26.3"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.26.0",
95
+ "@syncular/server": "0.26.3",
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
- /** Re-announce leadership (on promotion and on a follower `hello`). */
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 (or is contesting) — tell it who leads.
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
- timer: ReturnType<typeof setTimeout> | undefined;
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
- timer: ReturnType<typeof setTimeout> | undefined;
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
- #waitingTimer: ReturnType<typeof setTimeout> | undefined;
349
- #blockedTimer: ReturnType<typeof setTimeout> | undefined;
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 timer = setTimeout(() => {
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
- clearTimeout(timer);
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
- timer: undefined,
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.timer = setTimeout(() => {
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
- const inflight: InFlight = {
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
- timer: setTimeout(() => {
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.#armBoundDeadline();
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
- if (inflight.timer !== undefined) clearTimeout(inflight.timer);
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
- if (queued.timer !== undefined) clearTimeout(queued.timer);
597
- queued.timer = undefined;
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.#channel.postMessage({
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
- if (inflight.timer !== undefined) clearTimeout(inflight.timer);
615
+ inflight.cancelTimer();
633
616
  inflight.reject(closedError);
634
617
  }
635
618
  this.#inFlight.clear();
636
619
  for (const queued of this.#queue) {
637
- if (queued.timer !== undefined) clearTimeout(queued.timer);
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.#blockedTimer = setTimeout(
685
+ this.#cancelBlocked = this.#schedule(
683
686
  () => this.#setBlocked(),
684
687
  this.#callTimeoutMs,
685
688
  );
686
689
  }
687
690
 
688
- #armBoundDeadline(): void {
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.#waitingTimer = setTimeout(
691
- () => {
692
- this.#setState({ state: 'waiting', reason: 'leader-announcement' });
693
- },
694
- Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)),
695
- );
696
- this.#blockedTimer = setTimeout(
697
- () => this.#setBlocked(),
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
- if (this.#waitingTimer !== undefined) clearTimeout(this.#waitingTimer);
704
- if (this.#blockedTimer !== undefined) clearTimeout(this.#blockedTimer);
705
- this.#waitingTimer = undefined;
706
- this.#blockedTimer = undefined;
710
+ this.#cancelProbe?.();
711
+ this.#cancelBlocked?.();
712
+ this.#cancelProbe = undefined;
713
+ this.#cancelBlocked = undefined;
707
714
  }
708
715
  }
@@ -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
- /** Deadline for a follower call (covers the leader-handover gap). */
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
  }