cross-tab-worker-databus 0.21.43 → 0.21.44

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/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## [0.21.44] - 2026-09-26
2
+
3
+ `0.21.43` guarded one clock; enumerating the rest found that the same input reached durable state, where the consequence is not a wrong number but a sentinel. No API, option, default, frame or storage key changed; the coverage floors and the zero-count arm ledger are `0.21.43`'s.
4
+
5
+ ### Fixed
6
+
7
+ - **A non-finite environment clock was written into localStorage as `null`, and `null` coerces to `0`.** `ClusterEnvironment.now` is a required field whose documented hazard is a *backwards* step, which every `now - stamped > ttl` site already reads as "not yet expired" — but a missing reading is outside that contract, and it did not degrade one field: the durable worker, route and subscriber records were written with `"updatedAt": null`, because `JSON.stringify(NaN)` is `null`. That is worse than a wrong number rather than merely different, because `null` coerces to `0` in the TTL arithmetic, so **the first tick after the clock recovered would read every record as ancient and prune the whole cluster at once**. A poisoned in-memory comparison heals when the clock recovers; a poisoned record does not. The cluster now holds the last finite reading across its ten read sites, and the bus normalizes once at the single place it binds a clock — which covers its own stamps *and* the dedup manager it hands the same function to, the site `0.21.43` guarded at its own boundary.
8
+ - **Both guards are pinned by cases that had to be rewritten to measure anything.** The cluster case first filtered records with `typeof reading !== 'number'`, and the value this bug leaves is `null`, whose `typeof` is `"object"` — the predicate skipped exactly what it existed to catch, and the guard's own mutation passed it. The bus case only read fields the broken window never touches, so it passed the unguarded binding too; driving a rewrite under the broken clock is what made it a check. Each mutation now dies at one test: `stored a non-finite heartbeatAt`, and `the recovery ledger stamps a finite time`.
9
+
1
10
  ## [0.21.43] - 2026-09-26
2
11
 
3
12
  Two claims about the storage boundary turned out to be narrower than the storage was, and a public clock could report `NaN` into the health summary. No library behavior, export, option, default, frame or storage key changed beyond the one guard; the coverage floors and the zero-count arm ledger are `0.21.42`'s.
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-NTMMSR4W.js";
10
+ } from "./chunk-QPM2OGBB.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -645,6 +645,10 @@ var WorkerClusterRuntime = class {
645
645
  channelName;
646
646
  /** Adaptive load weighting options; undefined keeps legacy topic-count routing. */
647
647
  loadWeighting;
648
+ /** The most recent finite reading of the environment's clock, and the fallback for
649
+ * a broken one — see {@link nowMs}. Seeded to 0 because a clock that has never
650
+ * produced a finite reading leaves nothing to hold. */
651
+ lastFiniteNow = 0;
648
652
  /** Rolling traffic accumulator folded into the worker record on writeRecord. */
649
653
  throughputWindow = {
650
654
  startedAt: 0,
@@ -706,7 +710,7 @@ var WorkerClusterRuntime = class {
706
710
  this.storage = canUseStorage(this.environment.storage, `${baseKey}:probe`) ? new BatchingStorageWriter(this.environment.storage) : null;
707
711
  this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`);
708
712
  this.workerId = options.workerId ?? `worker-${this.tabId}-${this.environment.randomId()}`;
709
- const now = this.environment.now();
713
+ const now = this.nowMs();
710
714
  this.currentRecord = {
711
715
  protocolVersion: CLUSTER_PROTOCOL_VERSION,
712
716
  workerId: this.workerId,
@@ -719,6 +723,33 @@ var WorkerClusterRuntime = class {
719
723
  registeredAt: now
720
724
  };
721
725
  }
726
+ /**
727
+ * The environment's clock, normalized.
728
+ *
729
+ * `ClusterEnvironment.now` is a required field, and the documented hazard is a
730
+ * *backwards* step rather than a missing reading — the library's own subtraction
731
+ * sites tolerate one, since every `now - stamped > ttl` reads a backwards clock as
732
+ * "not yet expired". A non-finite reading is outside that contract, and it does not
733
+ * degrade one field: measured before this guard, a `NaN` clock was written straight
734
+ * into durable state — the worker, route and subscriber records all carried
735
+ * `"updatedAt": null`, because `JSON.stringify(NaN)` is `null` — and that is worse
736
+ * than a wrong number rather than merely different, because `null` coerces to `0`
737
+ * in the TTL arithmetic, so the first tick after the clock recovered would read
738
+ * every record as ancient and prune the whole cluster at once.
739
+ *
740
+ * Holding the last finite reading is the same treatment `data-bus.ts` applies to
741
+ * `dedup.now` and `DedupManager` applies to its own boundary: a paused clock, not a
742
+ * poisoned one. It is a helper rather than nine conditions because the broken input
743
+ * is the clock, not any particular comparison.
744
+ */
745
+ nowMs() {
746
+ const reading = this.environment.now();
747
+ if (Number.isFinite(reading)) {
748
+ this.lastFiniteNow = reading;
749
+ return reading;
750
+ }
751
+ return this.lastFiniteNow;
752
+ }
722
753
  /** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
723
754
  start() {
724
755
  if (this.started) return;
@@ -775,7 +806,7 @@ var WorkerClusterRuntime = class {
775
806
  return;
776
807
  }
777
808
  this.channel?.addEventListener("message", this.handleMessage);
778
- const now = this.environment.now();
809
+ const now = this.nowMs();
779
810
  this.currentRecord = {
780
811
  ...this.currentRecord,
781
812
  heartbeatAt: now,
@@ -1261,7 +1292,7 @@ var WorkerClusterRuntime = class {
1261
1292
  * will never complete — while a fresh unconfirmed route may simply be
1262
1293
  * waiting out its confirmation flush and must be left alone. */
1263
1294
  isStaleHandoff(route) {
1264
- return this.environment.now() - route.updatedAt > this.workerTtlMs;
1295
+ return this.nowMs() - route.updatedAt > this.workerTtlMs;
1265
1296
  }
1266
1297
  /** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
1267
1298
  reconcile() {
@@ -1446,7 +1477,7 @@ var WorkerClusterRuntime = class {
1446
1477
  /** Read all live worker records from storage, pruning stale entries past the TTL. */
1447
1478
  readWorkers() {
1448
1479
  if (!this.storage) return [this.currentRecord];
1449
- const now = this.environment.now();
1480
+ const now = this.nowMs();
1450
1481
  const workers = [];
1451
1482
  for (const { key, value: worker } of readAllByPrefix(this.storage, this.workerPrefix)) {
1452
1483
  if (worker.workerId !== this.workerId && now - worker.heartbeatAt > this.workerTtlMs) {
@@ -1498,7 +1529,7 @@ var WorkerClusterRuntime = class {
1498
1529
  topicKey,
1499
1530
  workerId: this.workerId,
1500
1531
  tabId: this.tabId,
1501
- updatedAt: this.environment.now(),
1532
+ updatedAt: this.nowMs(),
1502
1533
  generation: 1
1503
1534
  };
1504
1535
  }
@@ -1515,7 +1546,7 @@ var WorkerClusterRuntime = class {
1515
1546
  topicKey,
1516
1547
  workerId: owner.workerId,
1517
1548
  tabId: owner.tabId,
1518
- updatedAt: this.environment.now(),
1549
+ updatedAt: this.nowMs(),
1519
1550
  generation,
1520
1551
  ...handoffFromWorkerId ? { handoffFromWorkerId } : {}
1521
1552
  };
@@ -1527,7 +1558,7 @@ var WorkerClusterRuntime = class {
1527
1558
  if (!route || route.workerId !== this.workerId || route.confirmedAt !== void 0) return;
1528
1559
  writeJson(this.storage, this.routeStorageKey(topicKey), {
1529
1560
  ...route,
1530
- confirmedAt: this.environment.now()
1561
+ confirmedAt: this.nowMs()
1531
1562
  });
1532
1563
  const topic = this.knownTopics.get(topicKey);
1533
1564
  if (topic) this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_ACK, topic });
@@ -1535,7 +1566,7 @@ var WorkerClusterRuntime = class {
1535
1566
  /** Remove routes whose topic has no subscribers and whose TTL has expired. */
1536
1567
  cleanupOrphanedRoutes(workers) {
1537
1568
  if (!this.storage) return;
1538
- const now = this.environment.now();
1569
+ const now = this.nowMs();
1539
1570
  for (const { key, value: route } of readAllByPrefix(this.storage, this.routePrefix)) {
1540
1571
  if (now - route.updatedAt <= this.workerTtlMs) continue;
1541
1572
  if (this.readSubscriberTabIds(route.topicKey, workers).length > 0) continue;
@@ -1555,7 +1586,7 @@ var WorkerClusterRuntime = class {
1555
1586
  if (!this.storage) return;
1556
1587
  writeJson(this.storage, this.subscriberStorageKey(topicKey, this.tabId), {
1557
1588
  tabId: this.tabId,
1558
- updatedAt: this.environment.now()
1589
+ updatedAt: this.nowMs()
1559
1590
  });
1560
1591
  }
1561
1592
  /** Persist the current worker record with an updated heartbeat timestamp.
@@ -1566,7 +1597,7 @@ var WorkerClusterRuntime = class {
1566
1597
  * status, visibility and load changes pass true; the heartbeat tick and a
1567
1598
  * role change pass false. */
1568
1599
  writeRecord(notify) {
1569
- const now = this.environment.now();
1600
+ const now = this.nowMs();
1570
1601
  const sample = this.loadWeighting !== void 0 ? this.sampleThroughput(now) : void 0;
1571
1602
  this.currentRecord = {
1572
1603
  ...this.currentRecord,
@@ -2509,7 +2540,7 @@ var SDK_VERSION = (
2509
2540
  // declaration's type is `string | undefined` and the export's is `string`
2510
2541
  // (dropping the conditional is TS2322, measured). Recorded here so the leg reads
2511
2542
  // as a shape the harness cannot reach rather than as a case nobody exercised.
2512
- true ? "0.21.43" : ""
2543
+ true ? "0.21.44" : ""
2513
2544
  );
2514
2545
 
2515
2546
  // src/core/data-bus.ts
@@ -2629,7 +2660,7 @@ var CrossTabDataBus = class {
2629
2660
  assertRecoveryOptions(recovery);
2630
2661
  this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
2631
2662
  this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
2632
- this.now = dedup?.now ?? Date.now;
2663
+ this.now = holdLastFinite(dedup?.now ?? Date.now);
2633
2664
  this.transport = transport;
2634
2665
  this.initialConfig = initialConfig;
2635
2666
  this.hasInitialConfig = "initialConfig" in options;
@@ -3688,6 +3719,19 @@ function formatWorkerTrace(worker) {
3688
3719
  function formatRouteTrace(route) {
3689
3720
  return `${route.topicKey}@${route.workerId}|confirmed=${route.confirmedAt !== void 0}`;
3690
3721
  }
3722
+ function holdLastFinite(source) {
3723
+ let last = 0;
3724
+ let primed = false;
3725
+ return () => {
3726
+ const reading = source();
3727
+ if (Number.isFinite(reading)) {
3728
+ last = reading;
3729
+ primed = true;
3730
+ return reading;
3731
+ }
3732
+ return primed ? last : 0;
3733
+ };
3734
+ }
3691
3735
 
3692
3736
  // src/worker-mode.ts
3693
3737
  function selectWorkerBackend(mode, availability = {}) {
@@ -3749,4 +3793,4 @@ export {
3749
3793
  parseDataBusPublication,
3750
3794
  selectWorkerBackend
3751
3795
  };
3752
- //# sourceMappingURL=chunk-NTMMSR4W.js.map
3796
+ //# sourceMappingURL=chunk-QPM2OGBB.js.map