cross-tab-worker-databus 0.21.15 → 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 CHANGED
@@ -1,5 +1,27 @@
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
+
3
25
  ## [0.21.15] - 2026-09-24
4
26
 
5
27
  ### Tests
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-VQJIMGJN.js";
10
+ } from "./chunk-GQ6NYWQL.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -2410,7 +2410,7 @@ var DedupManager = class {
2410
2410
  };
2411
2411
 
2412
2412
  // src/core/version.ts
2413
- var SDK_VERSION = true ? "0.21.15" : "";
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(() => this.transport.subscribe(topic));
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(() => this.transport.unsubscribe(topic));
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
- this.runTransport(operation);
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-VQJIMGJN.js.map
3644
+ //# sourceMappingURL=chunk-GQ6NYWQL.js.map