cross-tab-worker-databus 0.21.43 → 0.21.45
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 +35 -0
- package/dist/centrifuge.js +1 -1
- package/dist/{chunk-NTMMSR4W.js → chunk-36TEEZQ5.js} +57 -13
- package/dist/chunk-36TEEZQ5.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +56 -12
- package/dist/cjs/centrifuge.cjs.map +2 -2
- package/dist/cjs/index.cjs +56 -12
- package/dist/cjs/index.cjs.map +2 -2
- package/dist/core/cluster.d.ts +24 -0
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/docs/release-checklist.md +1 -1
- package/docs/roadmap.md +6 -1
- package/docs/zh/release-checklist.md +1 -1
- package/docs/zh/roadmap.md +6 -1
- package/package.json +1 -1
- package/dist/chunk-NTMMSR4W.js.map +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
## [0.21.45] - 2026-09-30
|
|
2
|
+
|
|
3
|
+
A release ten cycles in the making, and the reason it was late is now part of the tooling: the browser benchmark's deferral rule was enforced by a human remembering it, so it slipped. No library behavior, export, option, default, frame or storage key changed; the coverage floors and the zero-count arm ledger are `0.21.44`'s.
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **The browser benchmark now records the host it ran on, and `bench:compare` refuses to trust a sample the host cannot support.** Every archived report now carries `host: { loadavg1m, cpus, platform, release }`, or `null` where the platform publishes no finite reading. On the baseline path, a newest sample whose load exceeds four times the baseline's own median is refused before any metric is compared: the tool prints both figures and exits non-zero. This closes a hole with a specific shape — all 91 archived reports recorded `userAgent` and timings and **no host state at all**, while the gate judges a report only against the previous five, so a *fast* sample taken on a loaded host entered the baseline and became the reference for the next five readings, with nothing recording that the host was busy. The threshold is a ratio rather than an absolute ceiling because the archive spans machines and a load average means nothing without its core count; it is a refusal to *trust* rather than a claim that the sample is invalid, so the boundary is inclusive and a false positive costs one deferred run. Reports predating the field have nothing to judge and pass through silently, which is every report currently archived — the guard is inert on the existing series, and taking a sample on a busy host is now refused rather than archived.
|
|
8
|
+
- **The shipped release checklist documents it, in both languages.** `docs/release-checklist.md` step 4 already said to defer the sample with its load numbers; it now also says the rule is enforced rather than remembered, and what the refusal looks like.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Nine documented defaults were pinned by tests that did not exist before, and the audit that found them is now the reason to trust the option table.** `connectTimeoutMs` (30 000), `createWebSocketDataBus`'s and `createCentrifugeDataBus`'s `clusterKey` default, `heartbeatIntervalMs` (3 000) and `workerTtlMs` (10 000) — the pair the architecture guide summarises as the worst-case dead-owner detection time — plus `recovery.maxAttempts` (`Infinity`), the `dedup` pair (60 000 / 1 000), `replay.maxPerTopic` (100), `trace.metricsIntervalMs` (5 000) and `trace.enabled` (`false`). Each is documented in `docs/configuration.md` or `docs/transports.md`; none was named by a test, and the three tiers are worth distinguishing. `maxActiveWorkers` was already pinned. `transferable`, `recovery.cooldownMs`, `pruneStrategy` and `trace.mode` were checked and found already covered, and are recorded alongside the hits because a ledger that logs only hits overstates how much of a surface it has walked.
|
|
13
|
+
- **The method that found them is the transferable part, and it is a kill set rather than a reading.** For each documented default, one `??`-site mutant run against its own file, because *pinned* (a test names the value), *sensitive but unnamed* (a mutant fails cases asserting something else — 2 cases for `heartbeatIntervalMs`, 6 for `workerTtlMs`, 1 for `recovery.maxAttempts`) and *unobserved* (the whole suite stays green — both `dedup` defaults, `maxPerTopic`, `trace.enabled`) are indistinguishable in the source. Twenty-four mutants were run across these changes, each restored, each with its own kill set recorded in `docs/progress.md`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **The demo counted a publication before it knew the frame was delivered.** `publish()` and `publishBatch()` return `void`: a frame that is not delivered is reported **only** through `onError`. The demo counted first and reported after, so a refused frame produced a 发布 row *and* an 错误 row for one click and advanced 已发布 anyway, and `publishBatch()` overstated the counter by ten per refused batch. The window is the demo's own — 应用连接 suspends at `await state.bus.stop()` while auto-publish keeps running — and it now labels refusals 未投递, counts only frames the bus took, and does not animate a hop that never happened.
|
|
18
|
+
|
|
19
|
+
### Verification
|
|
20
|
+
|
|
21
|
+
- `pnpm check` — 39 test files, **999 tests**, typecheck, build, 5 perf gates; `pnpm lint`; the documentation gate at 25.
|
|
22
|
+
- Twenty-four mutants across the changes above, each applied alone and restored, each with its own named failure recorded in `docs/progress.md`.
|
|
23
|
+
- `pnpm audit --registry=https://registry.npmjs.org` — no known vulnerabilities.
|
|
24
|
+
- `docs/benchmarks.md` and its zh mirror regenerate byte-identical from the 91-report archive, so the generated tables and their committed copies have not drifted; the archive itself is untracked, which is why that is a run rather than a gate.
|
|
25
|
+
- **The browser benchmark was deferred again, with its load numbers, as the checklist's standing decision requires** — 79.78 / 120.85 / 129.80 / 145.31 across six readings on 8 cores at the end of this cycle. The archive's newest report is still `browser-2026-09-24T13-40-52-413Z.json`, and 24 tags have now shipped without a sample. What this release changes is that a loaded host can no longer *silently* produce the sample that ends that streak: the next run either produces a clean sample or is refused with its numbers.
|
|
26
|
+
|
|
27
|
+
## [0.21.44] - 2026-09-26
|
|
28
|
+
|
|
29
|
+
`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.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **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.
|
|
34
|
+
- **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`.
|
|
35
|
+
|
|
1
36
|
## [0.21.43] - 2026-09-26
|
|
2
37
|
|
|
3
38
|
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.
|
package/dist/centrifuge.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
2543
|
+
true ? "0.21.45" : ""
|
|
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-
|
|
3796
|
+
//# sourceMappingURL=chunk-36TEEZQ5.js.map
|