cross-tab-worker-databus 0.21.23 → 0.21.25

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,49 @@
1
+ ## [0.21.25] - 2026-09-25
2
+
3
+ A publication's fan-out set is now fixed when its delivery begins, which closes a shape that did not terminate: a handler that registers a fresh closure for its own topic made the message that triggered the registration keep delivering to the handlers it created.
4
+
5
+ ### Fixed
6
+
7
+ - **`dispatch()` collects before it invokes.** Handler lists are held in a `Set` per topic, and `Set` iteration visits entries appended after the cursor while skipping entries deleted before it, so both directions of mutation were observable inside one delivery: a handler that subscribed another one for the same topic received the message that caused the subscription, and a handler that unsubscribed a later one cut it out of a delivery already in flight (measured: a two-handler topic delivered `['first']`). The first half does not terminate and did not need a hostile application to reach — the ordinary "re-arm this one-shot handler on every message" pattern, which registers a *new* closure each time, ran **500 handlers for a single publication** (that number is the probe's own ceiling, not a bound the library imposed) and the next publication delivered to **999**, because the registrations persist. Re-subscribing the *same* function reference is a no-op by `Set` identity, which is why the shape survived every prior pass over this file: the first probe written for it used a named function and measured one invocation.
8
+ - **What the contract is now.** Both passes — the exact-topic handlers and the handlers of every matching wildcard pattern — are collected before the first handler runs, so a message is delivered to the subscribers that existed when its delivery began, and a late `unsubscribe()` takes effect on the next publication instead of mid-message.
9
+
10
+ ### Tests
11
+
12
+ - **Three cases, and each one's failure message is the measurement.** `delivers a publication to the handlers that existed when its delivery began` fails as `expected 500 to be 1` against the previous code, `keeps a handler in a delivery that another handler already started` as `expected [ 'first' ] to deeply equal [ 'first', 'second' ]`, and `does not deliver a publication to a wildcard subscription made during it` as `expected [ 'exact', 'pattern' ] to deeply equal [ 'exact' ]`. The first is written with an explicit ceiling so a regression reports a count rather than hanging the runner. All three pass against the fix, and the 933 tests that already existed are unchanged by it — no prior test depended on mutating the fan-out mid-delivery.
13
+ - **The hot path was sized, not assumed.** The five `tests/perf-gate.test.ts` gates cover hashing, owner selection and pattern matching, not dispatch, so the snapshot was measured directly: best-of-seven over 200k iterations, **9-23 ns per dispatch** for 1-20 handlers and **+15 ns** for a matching wildcard pattern holding two handlers, against a shipped per-message budget in the tens of microseconds. Coverage did not move — 46 zero-count branch arms of 1963, aggregate 99.02 / 97.65 / 99.27 / 99.69 — because both arms of every branch added here were already exercised. Tests 933 → 936.
14
+
15
+ ### Documentation
16
+
17
+ - **`docs/api.md` and its Chinese mirror each gained one bullet** stating the delivery-set contract in the `subscribe()` list, so the rule is visible where a consumer reads it rather than only in the source.
18
+
19
+ ### Compatibility
20
+
21
+ Delivery to a handler registered *during* a dispatch, and cancellation of a handler unsubscribed *during* one, both change: those handlers no longer see the in-flight message, and that handler does. A consumer relying on either needed the pattern this fix stops (a self-growing fan-out), and no shipped default, frame, storage key, export or option moved. `verify:compat`, `verify:types` and `verify:pack` pass against `v0.21.24`.
22
+
23
+ ## [0.21.24] - 2026-09-25
24
+
25
+ Two lifecycle defects that the coverage ledger handed over as a by-product: a cluster runtime stopped during its own activation rebuilt everything its teardown had removed, and a `stop()` issued from one of its callbacks was dropped on the floor. A verdict now sits at every zero-count site the ledger had left bare.
26
+
27
+ ### Fixed
28
+
29
+ - **A teardown arriving during activation abandons the rest of it.** `activate()` sets `started = true` and only then reaches consumer code — the environment adapter's `createChannel`, and `handlers.onControl` for a self-addressed SUBSCRIBE when storage is unavailable — before it arms its heartbeat. A `stop()` or pagehide landing in that span had already run `pause()`, so the remaining lines rebuilt exactly what it removed: the runtime kept a live channel with its listener attached, was written back into storage as a worker record no peer would ever TTL-prune, armed an interval no later `stop()` could clear (every leaked tick re-registered the departed tab), and accepted a `CONTROL/SUBSCRIBE` frame addressed to it after the stop. All four were measured on the unmodified tree, and each half of the rollback now dies to its own named assertion.
30
+ - **A `stop()` issued from `handlers.onResume` takes effect.** `handlePageShow()` clears `suspended` on the statement before it calls that callback, and `started` is set only by the `activate()` its own generation bump then skips, so both of `stop()`'s entry flags read false while consumer code was running: the teardown was dropped, the lifecycle listeners stayed attached, and the next hide/show pair re-fired `onResume` and put `coordinated` back to true. The guard's third term is `lifecycleListening`, which is what "something is still attached" actually means here.
31
+
32
+ ### Tests
33
+
34
+ - **Three cases, one per symptom, and a census to tell the rest from unreachability.** Two new `cluster.test.ts` tests construct the activation window from the two seams that open it (a channel adapter that stops the runtime, a control handler that stops the runtime) and assert the abandoned activation leaves no registration, no heartbeat and no live listener; the third stops the runtime from inside `onResume` and asserts a later visibility toggle fires nothing. For the arms that stayed zero-count the method from `0.21.23` was reused: a flag-vector census over one full suite run (four distinct `pause()` vectors, one each for `activate()`'s entry and the listener removal, two for `reconcile()` and `writeRoute()` — every one of them reading the flag false *somewhere*, which is the control on the rows that never do), plus a `tsc --noEmit` measurement for each leg whose deadness is a type question rather than a reach question: one error apiece for `resolvePublishTarget`'s `?? this.workerId`, for `writeRoute`'s storage guard (reported on the very next statement), and for each of the four projected-load reads, and five for the handoff's `if (!owner) continue` — the count the comment at that site has been claiming, re-measured against this tree rather than inherited.
35
+ - **The ledger moved for the first time in six phases: 47 \u2192 46 zero-count branch arms**, denominator 1958 \u2192 1963, aggregate 99.02 / 97.59 \u2192 **97.65** / 99.27 / 99.69, tests 930 \u2192 933. One arm (`pause()`'s clear-the-interval false leg) became reachable-and-covered because of the fix, and every new branch the fix introduced is exercised on both sides.
36
+ - **Two terms of `stop()`'s guard are dominated by the new one, and stay.** Deleting `!started` alone or `!suspended` alone leaves the suite green at its full count, because each implies `lifecycleListening`. They are kept on an asymmetry: a redundant term in a disjunction costs one boolean test, while a missing one drops a teardown silently — which is the bug this release fixes.
37
+
38
+ ### Documentation
39
+
40
+ - **Thirteen verdicts written at their sites, and one shipped enumeration corrected.** Every remaining zero-count arm in `cluster.ts` now carries what decides it: the caller enumeration for `writeRoute()`'s storage guard (with the contrast that makes its twin `writeSubscriber()` covered — one unconditional call site), `routeOwnerIsLive()`'s own conjunct for the publish-target fallback, the Map seeding for the two handoff projected-load reads (the only difference from their covered twins in `reconcileSubscriptions()`), and the three-flag invariant stated once, at `stop()`, with the other two sites pointing at it. One of those sites described a `?? this.currentRecord` fallback in the method whose comment it was — that election has no such fallback; the three are `subscribe()`'s and the two in `reconcileSubscriptions()` — and the list is now named by owning method.
41
+ - **A consumer-visible contract was added to `docs/api.md` and its Chinese mirror**: `stop()` is effective from inside any synchronous callback of `WorkerClusterRuntime`, and an abandoned activation leaves nothing registered. One repository rule went into `AGENTS.md`: a verdict of "no caller can bring the premise" must be re-checked against the consumer code the enclosing method itself calls, because in both defects found this way the caller list was correct and the re-entry was inside the audited method.
42
+
43
+ ### Compatibility
44
+
45
+ No frame, storage key, default, export or option moved. The behavior change is only observable to a consumer that stops a cluster runtime from inside one of its own callbacks or its adapter's `createChannel`, and it changes that case from "the stop is dropped and the tab revives" to "the stop takes effect" — mixed-version peers see a well-behaved peer either way. `verify:compat`, `verify:types` and `verify:pack` pass against `v0.21.23`.
46
+
1
47
  ## [0.21.23] - 2026-09-24
2
48
 
3
49
  A sixteen-mutant sweep of the last multi-term guards in the repository, one behavior that no test had ever named, and a comment that existed to justify an otherwise dead operand replaced by a lint gate that refuses the edit for real.
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-UT4ZYUW4.js";
10
+ } from "./chunk-ORGPJ2CX.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -742,7 +742,7 @@ var WorkerClusterRuntime = class {
742
742
  */
743
743
  stop() {
744
744
  this.lifecycleGeneration += 1;
745
- if (!this.started && !this.suspended) return;
745
+ if (!this.started && !this.suspended && !this.lifecycleListening) return;
746
746
  this.pause();
747
747
  this.flushStorage();
748
748
  this.removeLifecycleListeners();
@@ -770,6 +770,12 @@ var WorkerClusterRuntime = class {
770
770
  this.channel = null;
771
771
  }
772
772
  if (!this.channel) this.storage = null;
773
+ if (!this.started) {
774
+ const channel = this.channel;
775
+ this.channel = null;
776
+ channel?.close();
777
+ return;
778
+ }
773
779
  this.channel?.addEventListener("message", this.handleMessage);
774
780
  const now = this.environment.now();
775
781
  this.currentRecord = {
@@ -785,6 +791,7 @@ var WorkerClusterRuntime = class {
785
791
  if (!this.storage) this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
786
792
  else this.writeSubscriber(topicKey);
787
793
  }
794
+ if (!this.started) return;
788
795
  this.reconcile();
789
796
  this.heartbeatHandle = this.environment.setInterval(() => {
790
797
  this.writeRecord(false);
@@ -2484,7 +2491,7 @@ var SDK_VERSION = (
2484
2491
  // declaration's type is `string | undefined` and the export's is `string`
2485
2492
  // (dropping the conditional is TS2322, measured). Recorded here so the leg reads
2486
2493
  // as a shape the harness cannot reach rather than as a case nobody exercised.
2487
- true ? "0.21.23" : ""
2494
+ true ? "0.21.25" : ""
2488
2495
  );
2489
2496
 
2490
2497
  // src/core/data-bus.ts
@@ -3298,15 +3305,21 @@ var CrossTabDataBus = class {
3298
3305
  }
3299
3306
  /** Deliver a message to every local handler registered for its topic,
3300
3307
  * plus every handler registered with a wildcard subscription that matches
3301
- * (e.g. a handler subscribed to "chat.*" receives "chat.room.1"). */
3308
+ * (e.g. a handler subscribed to "chat.*" receives "chat.room.1"). Both sets are
3309
+ * collected before the first handler runs, so a publication is delivered to the
3310
+ * subscribers that existed when its delivery began — see the note in the body. */
3302
3311
  dispatch(message) {
3303
3312
  this.trace.recordDispatched(message.topic);
3304
- this.invokeHandlers(this.topicHandlers.get(message.topic) ?? [], (handler) => handler(message));
3313
+ const exactHandlers = this.topicHandlers.get(message.topic);
3314
+ const delivered = exactHandlers ? Array.from(exactHandlers) : [];
3315
+ const wildcards = [];
3305
3316
  for (const [pattern, handlers] of this.topicHandlers) {
3306
3317
  if (pattern !== message.topic && topicMatchesPattern(pattern, message.topic)) {
3307
- this.invokeHandlers(handlers, (handler) => handler(message));
3318
+ for (const handler of handlers) wildcards.push(handler);
3308
3319
  }
3309
3320
  }
3321
+ this.invokeHandlers(delivered, (handler) => handler(message));
3322
+ this.invokeHandlers(wildcards, (handler) => handler(message));
3310
3323
  this.replayManager.record(message);
3311
3324
  }
3312
3325
  /**
@@ -3718,4 +3731,4 @@ export {
3718
3731
  parseDataBusPublication,
3719
3732
  selectWorkerBackend
3720
3733
  };
3721
- //# sourceMappingURL=chunk-UT4ZYUW4.js.map
3734
+ //# sourceMappingURL=chunk-ORGPJ2CX.js.map