cross-tab-worker-databus 0.21.14 → 0.21.16
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 +39 -0
- package/dist/centrifuge.js +1 -1
- package/dist/{chunk-3XBN7FFJ.js → chunk-GQ6NYWQL.js} +21 -6
- package/dist/{chunk-3XBN7FFJ.js.map → chunk-GQ6NYWQL.js.map} +2 -2
- package/dist/cjs/centrifuge.cjs +20 -5
- package/dist/cjs/centrifuge.cjs.map +2 -2
- package/dist/cjs/index.cjs +20 -5
- package/dist/cjs/index.cjs.map +2 -2
- package/dist/core/data-bus.d.ts +7 -0
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/docs/api.md +1 -1
- package/docs/architecture.md +5 -4
- package/docs/release-checklist.md +1 -1
- package/docs/roadmap.md +57 -1
- package/docs/zh/api.md +1 -1
- package/docs/zh/architecture.md +5 -4
- package/docs/zh/release-checklist.md +1 -1
- package/docs/zh/roadmap.md +49 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.21.16] - 2026-09-24
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **A transport subscribe deferred behind an opening no longer flushes after the unsubscribe that cancelled it.** `subscribeTransport()` and `unsubscribeTransport()` mutate `transportSubscribedTopics` and then hand the work to `runTransport()` as a captured closure. When the subscribe was parked behind an opening and the release reached the transport immediately — the window between the opening resolving and its own parked continuation running — the connection was left holding a channel that no local handler, cluster assignment, or route record owns. Nothing recovers from that: `subscribeTransport`'s dedupe returns early for a topic already in the set, and the set here says the channel is held, so the tab stays subscribed to a topic it has released and deaf to one it re-takes. `runTransport()` now also takes the desired state as a `stillWanted()` predicate and re-reads it at each place that resumes on a later task (the recovery-gate waiter and the opening flush). The immediate branch keeps no check because every path into it evaluated the set in the same task.
|
|
8
|
+
- Reproduced with one bus and a gated `transport.start()`: subscribe while the open is pending, resolve the open, release one or two microtasks later. At those offsets the transport's *own* subscription set ends as `["topic"]` while both `subscribeCalls` and `unsubscribeCalls` hold one entry — an add and a delete that leave a `Set` non-empty is only reachable in unsubscribe-then-subscribe order, which is what names the defect. Offsets 0, 3 and 4 were clean, so the test asserts the whole window instead of the two values that failed here, and it reads `FakeTransport.subscribed` rather than the bus's bookkeeping, which is the thing that lies.
|
|
9
|
+
- The same cancellation now covers a release whose topic was re-subscribed before its flush, and the measured consequence there is narrower: the old code put `sub, uns, sub` on the wire where the new one puts `sub, sub`. The end state was already correct; a real transport answers that middle `UNSUBSCRIBE` by tearing the channel down and re-establishing it, with a delivery gap in between. The comment at the call site says which half is measured.
|
|
10
|
+
- Each of the two checks earns its keep separately, and that was measured rather than assumed, because the two resume points flush independently: deleting the recovery-gate check fails **1** of 191 tests (the gate case), deleting the opening-flush check fails exactly the **other two**, and reverting the whole change fails exactly those 3 with 188 pre-existing green — so no test in the repository pinned this before. Inverting both guards fails 20, including the pre-existing "starts automatically and queues subscriptions until the transport is ready", which is the assertion that the guard cannot silently swallow a wanted subscribe.
|
|
11
|
+
- `FakeTransport` gained `channelCalls`, one entry per channel call in the order the transport received them. `subscribeCalls` and `unsubscribeCalls` are per-direction and cannot express ordering, and the contract fixed here *is* ordering.
|
|
12
|
+
- **The question 0.21.15 closed on is now answered: the residue was this defect.** That entry recorded six sweep seeds that fail once the test channel delivers asynchronously, as an open product question the experiment "surfaced but did not settle". With the fix in, the same sweep is green; without it, the same six seeds fail again with the same messages. Measured both ways on one branch: `origin/main`'s `data-bus.ts` plus an asynchronous hub fails **seeds 100, 727, 1129, 1487, 1756** (`no subscriber left but owners=[] transportSubscriptions=[a]`) **and 1853** (`1 subscriber(s) need one owner that holds the transport, got owners=[b] transportSubscriptions=[a b]`); the fixed code with the same hub passes all 5,000 seeds; and the *synchronous* hub passes on the pre-fix code, which is exactly why a shipped defect stayed invisible to a harness that exercises it constantly.
|
|
13
|
+
|
|
14
|
+
### Tests
|
|
15
|
+
|
|
16
|
+
- `ChannelHub` gained `setAsyncDelivery(true)`, which defers every post by one microtask. A real `BroadcastChannel` delivers in a later task, so no runtime ever reacts to its own post inside one call stack — the hub's synchronous default lets exactly that happen, and the two cases above are what it can hide. The option is opt-in rather than the default because 15 tests in `cluster`, `data-bus` and `stability` post a frame and assert without awaiting: flipping the default was tried and reddens exactly **17** cases, those 15 plus this hub's own two delivery pins. The coordination sweep's per-seed hub is the only consumer that opts in, and its cost is measured as no cost: 8.0–9.5 s for the full sweep against ~10.5 s synchronous on the same machine.
|
|
17
|
+
- The option is pinned directly, on both sides: the default hub must still deliver inside the posting stack, an opted-in hub must deliver nothing until a microtask, a peer that closes before delivery receives nothing, and a sender that closes does not cancel its own frame — which is what the sweep's `forgeSubscribe()` depends on. Which half is load-bearing was measured: forcing the option to a no-op kills the second assertion, while removing the sweep's own opt-in leaves all five cases green on the fixed code. So the fidelity is justified by the six seeds it caught, not by a standing kill, and the pin's comment says so — while the *first* assertion is what makes the 17-case default flip impossible to land quietly.
|
|
18
|
+
- One mechanism in there is recorded as defensive rather than as covered: reading the channel's member set inside the microtask instead of snapshotting it when the frame is posted is **indistinguishable through this fake**, because `FakeChannel.close()` clears the channel's listeners. That mutant was written, run, and survived the whole file; the comment at the site carries the measurement so the next reader does not mistake the assertion for a pin on the mechanism.
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
- `docs/architecture.md` and its Chinese mirror described both subscription chains in the wrong order and omitted the deferral altogether. The published text read `→ transport.subscribe(topic) → transportSubscribedTopics.add(topic)`; the code adds to the set **first** and then hands the call to `runTransport()`, which may park it. Both chains now put the set write before the call, and a new bullet in the disconnect/reconnect section states the rule #241 implements: a parked operation is re-checked against that set before it runs, and the alternative — what shipped until now — is a subscribe flushing after its own unsubscribe, leaving the connection holding a topic that none of the four tracked sets owns.
|
|
23
|
+
- The same claim had three more copies, each corrected in both languages: the "Recovery waiter invalidation" bullet (which explained only the cancellation-generation guard, and now says plainly that the generation check alone cannot cancel a released subscribe because nothing about the recovery cycle changed), the "Transport recovery gate" paragraph's "parked operations flush behind that success" (now qualified, with a pointer), and `docs/api.md`'s `subscribe()` behavior list (which promised queuing but not cancellation). `docs/api.md`'s publication row was checked and left alone: a parked `publish()` carries no desired state to re-read, so its "sent once the transport is ready" is still exact. `docs/*.md` is in `package.json` `files`, so this is consumer-visible text and ships with the patch.
|
|
24
|
+
|
|
25
|
+
## [0.21.15] - 2026-09-24
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
|
|
29
|
+
- `handleControlMessage`'s addressee check is now pinned. `handleMessage` dispatches on `type` alone and the cluster channel is broadcast, so that first line is the only thing keeping a runtime from acting on a CONTROL frame addressed to someone else — and nothing asserted it. Measured by deleting it: 369 tests across `cluster`/`stability`/`data-bus`/`centrifuge`/`worker-mode` stayed green while the three-tab coordination fuzz churned 16+ minutes of CPU against a ~1-minute baseline. The fuzz reaches the leg constantly (every point-to-point frame is a non-target for the other tabs) and cannot assert it, because its invariants are end-state checks and a slower convergence still passes.
|
|
30
|
+
- The pin asserts the non-target's own behavior — no `onControl` dispatch, no assignment, and no `knownTopics` entry for a plaintext it was never meant to serve — plus the addressee's, which is what shows the frame was deliverable rather than malformed. That distinction was earned: the first version died against the mutant too, but at the *addressee's* assertion, which pinned something other than the guard. Why the mutant also suppresses the addressee's claim is not yet explained, and is recorded as an open question rather than as a passing test.
|
|
31
|
+
- `handleRouteReleasedMessage`'s addressee check is now pinned on the one input where nothing else can reject it. Its staleness test compares the frame against the *durable route*, so a non-target that does not own the topic is already turned away by that — which is why the existing forged-`topicKey` case never reached this line. The pin's frame is addressed to a third worker while the route genuinely names this runtime, with a matching `generation` and `handoffFromWorkerId`, so every staleness term passes and only `targetWorkerId` stands. Measured by deleting it: 358 tests across `cluster`/`stability`/`data-bus`/`centrifuge` stayed green, and the failure was attributed to the new case alone. `tests/coordination-invariants.test.ts` cannot reach it at all — `forgeSubscribe()` is the only frame that harness sends, so no seed ever posts a `ROUTE_RELEASED` to the wrong worker.
|
|
32
|
+
- One assertion inside that pin was wrong before it shipped and is recorded so it is not re-introduced: `isAssigned()` reads true from the route record the scenario itself writes, because `isAssigned` falls through to `readRoute(topicKey)?.workerId === this.workerId`. It asserted nothing about the frame. The `assignedTopics` map is what the handler actually writes, and that is what the test now checks.
|
|
33
|
+
- The coordination fuzz gained a third limit, because the two it had were both made of time and the failure that has been turning `verify` red is not a waiting failure. `tests/coordination-invariants.test.ts` now arms a **channel delivery budget** on `ChannelHub`: past 50,000 posts in one seed the hub stops delivering, so a coordination loop that never converges is cut at the one place both halves of it are visible. The await cap reported `0 cut short` while one seed spent 463s of a CI run (and 151s in an earlier one) because that time is CPU spent inside microtasks — nothing on the same thread can preempt it, so no deadline can bound it. The threshold is taken from a measured distribution rather than taste: instrumenting `ChannelHub.send` across a full 5,000-seed sweep on an idle desktop gave p50 = 28 posts, p99 = 90, p99.9 = 5,429, maximum 16,763, with 18 seeds over 500 — so 50,000 is ~3x the worst interleaving this machine produces and cannot fire on a healthy seed. A cut seed prints `[CHURN] seed=N deliveries=… ops=…`, is excluded from the depth floor (its end state was never reached, so asserting on it would be a false failure), and the sweep summary now prints the churn count even when the sweep completed — which is the case in which the number is otherwise invisible.
|
|
34
|
+
- The budget's own teeth are pinned in both directions, like the await cap: a hub must deliver everything under the limit and nothing over it. Measured against two mutants — dropping the `return` (never cut) fails at "posts past the budget must not be delivered: expected 6 to be 3", and comparing with `>=` (cut one post early) fails at "a budget must not drop anything under the limit: expected 2 to be 3". A guard that can never fire would let the wedge through looking like a working limit.
|
|
35
|
+
- `docs/progress.md`'s phase log has a structural gate. Phase entries are appended at one shared anchor, and the two PRs for the same phase (#233 closed in favor of #234) landed as **two `## Phase 142` blocks** — one of them pushed below the `## Next candidates` heading the log is meant to sit above — so the record of a single change was duplicated and out of order, and read that way until a `grep -c "^## Phase 142"` returned 2. `tests/documentation.test.ts` now requires every `## Phase N / …` heading to number strictly above the previous entry and to appear above that heading. The scope is the current convention on purpose: 30 phase numbers repeat in the older part of the log (numbers 2–51, where an entry pairs a `## Phase N (in progress …)` heading with a `## Phase N result …` line), and none of those headings uses the `## Phase N / Title` form the gate covers — that form begins at Phase 84 and has never been duplicated. Measured on two scratch trees, one per leg: a second `## Phase 142` inserted above the anchor fails the numbering assertion alone, and a correctly numbered `## Phase 145` inserted below `## Next candidates` fails the placement assertion alone.
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
- Both release checklists now record that `pnpm bench:compare` can be closed by the act of investigating it. Its baseline — the median *and* the maximum leg — is drawn from the same rolling archive every `pnpm bench:browser` run appends to, so re-runs taken to "check the spread" become the baseline that excuses the failure. Measured while preparing 0.21.14: two runs at host load 7.7–15.1 failed the ceiling on `publish/dedicated` (77.3 ms against a ~38 ms fast mode), five further re-runs reported OK with the baseline median slid to 70.6 ms, and one clean run after removing those samples showed every metric up together — the contention signature, not a regression. The instruction is now: read `uptime` before re-running, record a suspect failure as deferred with its load, keep off-mode samples out of the archive, and do not regenerate `docs/benchmarks.md` from them.
|
|
40
|
+
- **The churn loop is the harness, not the library — and the earlier diagnosis of it is retracted.** The bullet above described the loop the budget cuts as "a real behavior and not fixed", and named the open question as *which read answers stale between the re-election's `writeRoute` and the next pass*. Neither the framing nor the implied mechanism survives measurement. Replaying seed 1046 with counters at the decision points in the two handlers and the route writer gave: **11,542 `handleControlMessage` accepts, 11,541 `reconcileSubscriptions` re-sends, 11,538 `confirmRoute` skips, 6 `confirmRoute` writes**, and at the storage boundary **27,571 reads of route keys against 6 writes that reached the store** — so there is no re-election loop at all (the re-send leg writes nothing, and the durable record was rewritten a handful of times across the whole seed). What actually happens is a *visibility* disagreement of one step: the route owner confirms the assignment, its confirmation sits in its own `BatchingStorageWriter.pending`, and a peer reading the flushed record still sees `confirmedAt === undefined`, so it re-sends; the owner's `confirmRoute` then returns early because *its own* read sees the confirmation. The one event that ends that cycle is the microtask flush — and the harness never yields to one, because `ChannelHub.send()` delivers every frame **synchronously**, so a whole `reconcile → post → reconcile` cascade runs inside a single call stack. Measured directly: deferring delivery by one `queueMicrotask` in the hub takes the sweep from ~18 seeds over 500 posts (in 5,000) to **zero seeds over 500 in 1,050**, with no `[CHURN]` and no budget trip. A browser cannot produce this loop: `BroadcastChannel` delivers in a later task, so the flush always lands before the peer reacts, and the re-send is bounded by one heartbeat. The delivery budget therefore stays exactly where it belongs — it is immunity against a wedge the *harness* can create — and the number it reports is no longer a goal for a product fix, because there is none to make. What the experiment did open, and did not answer: with asynchronous delivery the same sweep fails six invariant checks — seeds 100, 727, 1129, 1487 and 1756 report `no subscriber left but owners=[] transportSubscriptions=[a]`, and 1853 reports `1 subscriber(s) need one owner that holds the transport, got owners=[b] transportSubscriptions=[a b]` — and every one of them is a seed that opens with an injected `a:forge:<topic>` frame. So raising the harness's fidelity is its own change, not a one-line edit (making *every* hub asynchronous breaks ~16 tests in `cluster`/`data-bus`/`stability` that post a frame and assert without awaiting, so the deferral has to be opt-in and scoped to the fuzz), and whether a route-less `CONTROL/SUBSCRIBE` accepted under the documented missing-route tolerance can leave a transport subscription held after the assignment sweeps it is a product question this run surfaced but did not settle.
|
|
41
|
+
|
|
3
42
|
|
|
4
43
|
## [0.21.14] - 2026-09-24
|
|
5
44
|
|
package/dist/centrifuge.js
CHANGED
|
@@ -2410,7 +2410,7 @@ var DedupManager = class {
|
|
|
2410
2410
|
};
|
|
2411
2411
|
|
|
2412
2412
|
// src/core/version.ts
|
|
2413
|
-
var SDK_VERSION = true ? "0.21.
|
|
2413
|
+
var SDK_VERSION = true ? "0.21.16" : "";
|
|
2414
2414
|
|
|
2415
2415
|
// src/core/data-bus.ts
|
|
2416
2416
|
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
@@ -3361,12 +3361,18 @@ var CrossTabDataBus = class {
|
|
|
3361
3361
|
subscribeTransport(topic) {
|
|
3362
3362
|
if (this.transportSubscribedTopics.has(topic)) return false;
|
|
3363
3363
|
this.transportSubscribedTopics.add(topic);
|
|
3364
|
-
this.runTransport(
|
|
3364
|
+
this.runTransport(
|
|
3365
|
+
() => this.transport.subscribe(topic),
|
|
3366
|
+
() => this.transportSubscribedTopics.has(topic)
|
|
3367
|
+
);
|
|
3365
3368
|
return true;
|
|
3366
3369
|
}
|
|
3367
3370
|
unsubscribeTransport(topic) {
|
|
3368
3371
|
if (!this.transportSubscribedTopics.delete(topic)) return false;
|
|
3369
|
-
this.runTransport(
|
|
3372
|
+
this.runTransport(
|
|
3373
|
+
() => this.transport.unsubscribe(topic),
|
|
3374
|
+
() => !this.transportSubscribedTopics.has(topic)
|
|
3375
|
+
);
|
|
3370
3376
|
return true;
|
|
3371
3377
|
}
|
|
3372
3378
|
/** Invoke `callback` for each item in `handlers`, isolating a throwing
|
|
@@ -3484,8 +3490,15 @@ var CrossTabDataBus = class {
|
|
|
3484
3490
|
* Run a transport operation now if the transport is ready, otherwise queue
|
|
3485
3491
|
* it behind the start promise. This ensures subscribe/unsubscribe calls made
|
|
3486
3492
|
* during startup are not lost.
|
|
3493
|
+
*
|
|
3494
|
+
* `stillWanted` expresses the deferred work as the *current* desired state
|
|
3495
|
+
* rather than a captured call, and is checked at each place that resumes on a
|
|
3496
|
+
* later task — the recovery gate and the opening flush. The immediate branch
|
|
3497
|
+
* carries no check because every path into it evaluates the desired state in
|
|
3498
|
+
* the same task: a caller that just mutated `transportSubscribedTopics`, or
|
|
3499
|
+
* the gate re-entry one line above.
|
|
3487
3500
|
*/
|
|
3488
|
-
runTransport(operation) {
|
|
3501
|
+
runTransport(operation, stillWanted) {
|
|
3489
3502
|
if (this.suspended) return;
|
|
3490
3503
|
if (this.recoveryGate && !this.stopping) {
|
|
3491
3504
|
const gate = this.recoveryGate;
|
|
@@ -3495,7 +3508,8 @@ var CrossTabDataBus = class {
|
|
|
3495
3508
|
void gate.then(() => {
|
|
3496
3509
|
this.recoveryWaiters -= 1;
|
|
3497
3510
|
if (this.stopping || this.suspended || cancellationToken !== this.recoveryCancellationToken) return;
|
|
3498
|
-
|
|
3511
|
+
if (stillWanted && !stillWanted()) return;
|
|
3512
|
+
this.runTransport(operation, stillWanted);
|
|
3499
3513
|
});
|
|
3500
3514
|
return;
|
|
3501
3515
|
}
|
|
@@ -3516,6 +3530,7 @@ var CrossTabDataBus = class {
|
|
|
3516
3530
|
void ready.then(
|
|
3517
3531
|
() => {
|
|
3518
3532
|
if (!this.started || this.stopping || this.suspended) return;
|
|
3533
|
+
if (stillWanted && !stillWanted()) return;
|
|
3519
3534
|
return operation();
|
|
3520
3535
|
},
|
|
3521
3536
|
// The opening promise reports its own lifecycle failure through
|
|
@@ -3626,4 +3641,4 @@ export {
|
|
|
3626
3641
|
parseDataBusPublication,
|
|
3627
3642
|
selectWorkerBackend
|
|
3628
3643
|
};
|
|
3629
|
-
//# sourceMappingURL=chunk-
|
|
3644
|
+
//# sourceMappingURL=chunk-GQ6NYWQL.js.map
|