cross-tab-worker-databus 0.21.4 → 0.21.6

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,52 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.21.6] - 2026-09-22
4
+
5
+ ### Fixed
6
+ - A batched `PUBLISH` control frame is now dropped unless its `items` is a real, non-empty array. `handleControlMessage` gated the batch on `message.items && message.items.length > 0` and then either iterated it or handed it straight to the batch handler, and `publishBatch()` is the only producer — it builds `items` with `Array.prototype.map`. A hand-built frame could therefore carry a `length` without an iterator (`{ length: 2 }`), which threw a `TypeError: message.items is not iterable` out of the cluster's own `BroadcastChannel` listener, or an iterable non-array (`"ab"`), which reached the transport as two publications whose `data` was `undefined` — under the receiving worker's own session, on a channel the sender does not own. Presence and emptiness are checked in one place because a frame with `items` carries no `data`: previously an empty batch satisfied neither half of the old test and fell through to the single-publication tail, publishing `undefined` once instead of nothing. Legitimate traffic is untouched, including a cross-worker `publish(topic, undefined)`, whose frame simply has no `items` key. Item *content* stays unvalidated by decision, recorded in `AGENTS.md`: `{ data: anything }` is what a well-formed item looks like, so per-item shape checks stop nothing a conforming frame cannot do, and `messageId`/`timestamp` are only forwarded into `transport.publish()` where the receiving side already discards a non-string id and a non-finite timestamp. Pinned by two forged-frame tests that each fail three different ways against the old gate, and separately against a guard that keeps only the array check or only the empty check, so both halves are shown to be load-bearing on different legs.
7
+
8
+ - A `ROUTE_RELEASED` ACK can no longer complete a handoff under a channel name the recipient does not own. This is the second instance of the 0.21.5 substitution, in a handler the 0.21.5 guard never covered: the pairing check landed in `handleControlMessage`, while `handleRouteReleasedMessage` reads both `topicKey` and `topic` too — it authorizes itself against the durable route stored under the key, then writes the frame's plaintext into `assignedTopics` and subscribes the transport to it. Route records are ordinary localStorage, so a same-origin script that knows the cluster key can read the real `topicKey`, the previous owner's id and the generation, satisfy all three staleness checks, and answer an in-flight handoff with its own channel name; measured before the fix, the recipient's `onControl` fired twice for a topic nobody had subscribed. The ACK senders derive the pair (one of them reads it back out of `assignedTopics`, which only this hole could poison), so conforming peers are unaffected. Pinned by a forged-ACK test that fails without the guard, alongside the existing generation and previous-owner staleness pins that already covered this handler.
9
+
10
+ ### Coverage
11
+ - The `recoveryExhausted` one-shot is now pinned, which closes the last arm on the `data-bus.ts` ledger that a mutation had been shown to survive. `caps automatic recovery attempts…` already asserted "exactly one `exhausted` diagnostic" a second time after the cap was spent, and that second assertion had never re-entered the branch: once the last reopen has failed, the handlers installed on the transport belong to a superseded lifecycle, so `isCurrentLifecycle()` drops a subsequent `setStatus('error')` before the attempt counter is reached — measured with a spy, `updateStatus` ran zero times for such a call. Deleting the guard therefore passed the whole suite. The test now drives a second failure through the path an application actually takes after exhaustion — an explicit retry, which reopens and installs a live closure — and while that reopen fails, `recoveryExhausted` is still set because the only reset on the path sits in the success arm. With the guard made unconditional the test now fails as `expected […] to have a length of 1 but got 2`. `data-bus.ts` uncovered branch arms 13 → 12 and its branch coverage 97.01% → 97.24%, both measured on this module before and after; on the rebased tree whole-suite coverage is 98.98 / 96.69 / 99.26 / 99.69 over 879 tests. No source change.
12
+
13
+ ## [0.21.5] - 2026-09-22
14
+
15
+ ### Changed
16
+ - The last two "hunt this for a test" items on the `data-bus.ts` arm ledger turned out to be unreachable by their own call sites, and now say so. `getQueuedStartReady()`'s `if (!queued)` rejection and `queueStartAfterStop()`'s `if (this.queuedStart) return this.queuedStart` dedupe each sit behind an *identical* check performed one statement earlier by the only caller (`ready()` and `start()` respectively), synchronously, with nothing in between that could run user code. Neither was deleted, for the asymmetric-failure reason: dropping the first replaces a documented `"No queued start is in flight."` rejection with `TypeError: Cannot read properties of null` for any future second caller, and dropping the second lets one burst of operations behind a single stop queue several restarts, which is exactly the contract that line states. Zero coverage movement, measured after rebasing onto #177: `data-bus.ts` stays at 14 uncovered branch arms and 2 uncovered functions, 37 files / 874 tests.
17
+ - Two `openTransport()` conditions with permanently false arms, one of which had a comment describing a case that cannot happen. The `this.pendingStop === chainedPendingStop` check explained itself by "a stop created concurrently (e.g. by `suspendTransport()`) is a different promise", and the `!this.suspended && !this.stopping` guard below it explained nothing at all; the enumeration that rules both out is the same one. `pendingStop` is written in four places — this line, the failed-open cleanup, `performStop()`'s finally and `suspendTransport()`'s chained stop — and of those, the three that could change it out from under this continuation all run behind a `lifecycleEpoch` increment, while `suspended` has one writer and `stopping` two and each raises its flag only after bumping the epoch. An opening whose captured epoch is still current therefore cannot observe either condition false. Both stay uncovered by construction, so this moves no number; both are kept, because the asymmetric failure is the one worth guarding — an unconditional `pendingStop = null` would drop a live stop and let `stop()` issue a second `transport.stop()` the moment any transition suspends without superseding, and a dropped ready-guard would mark a hidden transport ready.
18
+ - A comment in `createStopPromise()` that asserted the opposite of the truth: it claimed `performStop()` "never rejects", which had turned the rejection arm of the teardown gate's `.then(f, g)` into prose-evidenced defensive filler. It can reject. Its `catch` reports through `reportError()` → `notifyError()` → `invokeHandlers(errorHandlers, …, ERROR_HANDLER)`, and that leg absorbs a throwing error subscriber only by writing to `console.warn` — so a subscriber that throws *and* a `console.warn` that throws escape the `catch` and reject the teardown. The comment now names that chain, and names which half of the arm is load-bearing: `resolveGate()` is the only thing that ever settles `await bus.stop()` on this path (the transport shutdown has already finished, so nothing else can wake the caller), while the `stopPromise = null` beside it is defensive for the reason `stop()`'s own comment already gives — `stopping` is authoritative and a settled gate is stale — which is measured, not argued: deleting that line changes no assertion in the file.
19
+ - One redundant rejection handler deleted from `reopenTransport()`, on a proof that needs no enumeration: the `opening.then(onFulfilled, onRejected)` a few lines above passes an `onRejected`, which registers *that* as a handler of `opening` — so `opening` can never surface as an unhandled rejection on that path whatever it rejects with, and the trailing `void opening.catch(() => undefined)` could only ever duplicate a handler that was already installed. Unlike the `0.21.2` / `0.21.3` deletions this argument rests on Promise semantics rather than on a list of assignment sites, which is also why it does not depend on `reportError` staying total.
20
+ - The two rejection swallows that remain in that method are now each labelled with what they actually are, because they are two different kinds of leg and only one of them is deletable. The early-return arm's `void opening.catch(...)` is the *only* handler that opening ever gets on that path (`resumeTransport()` calls with `void`, and the `.then(f, g)` below is unreachable from the arm), so it is a guard, not a gap. The one at the top of the method is dominated — `pending` provably resolves — but is deliberately kept, and the reason is recorded with a measurement: forcing `pending` to reject fails exactly one test whether the line is present or deleted, so nothing asserts the premise; with the absorb a superseded resume still reopens, without it the bus silently stays closed after a pageshow. A guard whose deletion turns "degraded" into "stuck" is worth its uncovered function.
21
+ - Corrects a claim this pass initially made about itself: the deleted handler was **not** one of `data-bus.ts`'s three uncovered functions — its callback ran in existing tests, so the module's uncovered-function count stays at three and its uncovered-line count is unchanged. What was removed was a handler that executed and could not matter. Coverage movement here is zero, and saying so is the point: the change is justified by the proof, not by a number. (That count of three is the state *at that change*; the `createStopPromise()` arm below is what actually moved it.)
22
+
23
+ ### Fixed
24
+ - A control frame can no longer rename the channel its recipient owns. `topicKey` is a pure function of `topic`, so every frame this library sends has the two agreeing — but `handleControlMessage` checked only `targetWorkerId` and the durable route, both keyed by `topicKey`, and then used the frame's `topic` as the plaintext to remember, assign, and hand to the transport. A `BroadcastChannel` is unauthenticated: any same-origin script can post into it, and one frame carrying a real `topicKey` next to an arbitrary `topic` would have that worker take ownership *under the substituted name*, because the route authorization passed. Incoming control frames are now rejected when `createOpaqueKey(topic) !== topicKey`. Legitimate peers are unaffected — measured on the full suite, including the coordination fuzzer, which forges frames with the two fields deliberately consistent — and the new test fails without the guard as `expected "vi.fn()" to not be called at all, but actually been called 1 times`.
25
+ - The seeded fuzzers' wall-clock fuse could not fire on exactly the runner it was written for. Both harnesses bounded depth with `if (completed >= MIN_SEEDS && elapsed > SEED_BUDGET_MS) break`, so a host too slow to clear the depth floor inside the budget never became eligible to stop: the sweep ran as long as the machine was slow and then died on the test's own 120s ceiling reporting nothing but `Test timed out`. Reproduced on `main` (1,046/5,000 seeds after 509s against a 60s fuse, no invariant violation) and twice on a PR that turned out not to be the cause. The budget is now unconditional and the floor stays an assertion, so a slow runner degrades depth and says so in ~60s instead of hanging for ~8 minutes; demonstrated locally by setting the budget to 0 — the gated form still ran 100 seeds and passed, the unconditional one stops at once and fails with `explored only 0 seeds`.
26
+ - The budget clock had a single source whose immunity was contextual rather than inherent. `realNowMs()` trusted `node:perf_hooks`' `performance`, which survives fake timers only because the global binding and that export are different objects in some Node contexts; a worker thread makes them the same object, so a fake-timer configuration that includes `performance` freezes the budget at 0 elapsed. It now takes the max of that source and `process.uptime()`, which no fake-timer toolbox replaces — measured by blocking 250ms of real time inside a fake window with `Atomics.wait`: `node:perf_hooks` 260, `process.uptime()` 260, and global `performance.now()`, `Date.now()` and `process.hrtime.bigint()` all 0.
27
+ - The clock's own pin asserted only the harmless direction. "60 simulated seconds must not cost 60 measured ones" passes trivially when the clock is frozen, so it could not have caught the bug above; the pin now also requires that real blocked time inside a fake window still measures as having passed. Verified against a mutant returning a constant: it passes the original assertion and fails only the new one.
28
+ - A truncated sweep now logs its slowest seed next to the depth reached. A fuse sampled between iterations cannot bound an iteration, so a seed that wedges on an `await` needing a timer the fake clock never advances was previously visible only as an unexplained total.
29
+
30
+ ### Coverage
31
+ - `src/core/data-bus.ts` goes from 17 uncovered branch arms to 16 by pinning the right operand of `return this.stopPromise ?? Promise.resolve()` in `start()`'s superseded-resume arm. Only a re-entered `stop()` ever assigns `stopPromise`, and the re-entry that reaches *this* line is a re-entered `start()`, which bumps the epoch with no stop in flight and leaves it null — so the fallback had never been taken in any test. The contract it protects is `start()`'s declared `Promise<void>`: replacing the line with `return this.stopPromise!` makes the new assertion fail as `expected null to be an instance of Promise`, which is the shape a caller would meet at `bus.start(c).then(...)`. The count is re-measured on this branch rather than inherited from the `0.21.4` entry, since `reopenTransport()` changed the same file in between.
32
+ - Whole-suite 98.84 / 96.46 / 99.08 / 99.57 over 37 files / 872 tests, against unchanged ceilings of 96 / 92 / 96 / 97.
33
+ - `data-bus.ts` then goes 16 uncovered branch arms → 15 and, for the first time in this ledger, three uncovered functions → 2, because the leg that closed is a handler *function* that had never been entered: `createStopPromise()`'s rejection arm. Driving it needs the double failure that its corrected comment rules out — an error subscriber that throws plus a `console.warn` that throws — and its remaining half is the fall-through of `if (this.stopPromise === stopGate)`, i.e. a stop whose gate had already been replaced by a newer one. Module totals move 97.9 / 96.32 / 97.43 / 98.86 → 98.38 / 96.55 / 98.29 / 99.24 with the whole suite at 98.94 / 96.52 / 99.26 / 99.65 over 37 files / 873 tests. Teeth were checked on the load-bearing statement, not on the arm: deleting `resolveGate()` from that arm fails the new test as `expected 'hung' to be 'settled'` in 250ms, and deleting the `stopPromise = null` next to it leaves all 184 tests in the file passing, which is why the comment calls one a contract and the other defensive.
34
+ - `data-bus.ts` goes 15 uncovered branch arms → 14 (functions stay at 2) by pinning the second conjunct of `invokeHandlers()`' logging guard. A throwing error subscriber is absorbed by writing to `console.warn`, and that write is conditional on `typeof console.warn === 'function'`; with a console that has no `warn`, an unguarded call would raise `TypeError: console.warn is not a function` from inside the handler meant to contain the failure — out through `reportError()`, out of the dispatch loop, into the transport's message callback. The new test stubs such a console and asserts the publication dispatch does not throw and the dispatch failure still lands in the unified ledger. Mutation-checked: dropping the conjunct fails it as `expected [Function] to not throw an error but 'TypeError: console.warn is not a func…' was thrown`. Whole-suite branch coverage 96.52 → 96.57, statements/functions/lines unchanged, 37 files / 874 tests.
35
+ - One arm was *not* closed, and the attempt is recorded so it is not re-attempted blind: `if (!this.recoveryExhausted)` inside the exhausted-recovery branch still has no test that takes its false arm. The first hypothesis — that the existing `caps automatic recovery attempts` test never really reached the second exhaustion because its environment clock was a constant — was measured with a scratch probe against the unmodified test and was **false**: `attempt` reaches 3 and exactly one `exhausted` event is emitted even with the frozen clock, so the assertions there are not vacuous and the speculative rewrite of that test was reverted rather than shipped with its (now disproved) comments. What the probe did establish is that a fourth `setStatus('error')` does not advance the attempt counter at all, for reasons the ledger does not yet explain, and that making the guard unconditional emits no second event — so no current assertion depends on that leg either way.
36
+
37
+ - `data-bus.ts` goes 14 uncovered branch arms → 13 by pinning the `default:` arm of the cluster's `onControl` handler, which is a live leg rather than a defensive one: the BroadcastChannel a cluster listens on is unauthenticated, and `handleControlMessage` forwards `message.action` straight through — its own `switch` has a `default: break` that *falls through to the same call*, then runs load accounting because the verb is not `PUBLISH`. So the handler's `default: break` is the only thing preventing an unknown verb from being acted upon as one of the three known ones. The new test posts a `CONTROL` frame with `action: 'DESTROY'` at the bus's own worker id over the cluster channel and asserts no publication handler ran, no transport subscribe/unsubscribe/publish call appeared, and the bus still reports healthy. Teeth verified by mutation: making the arm act on the topic fails the test as `expected [ 'topic' ] to deeply equal []`. Suite is 37 files / 875 tests.
38
+
39
+ ### Tests
40
+ - Both seeded fuzzers now announce their depth *while* sweeping, not only after: the first five seeds and then every fiftieth print `starting seed N at <elapsed>ms (depth D)`. This exists because a runner killed by the test's own 120s ceiling produces `Error: Test timed out in 120000ms` and nothing else — the truncation log the fuse writes sits after the loop, so it never runs on exactly the failure it was written to explain. With the heartbeat the same log says whether the sweep was merely slow (depth climbing, elapsed growing) or wedged inside one seed (last line `starting seed 7`, no successor), and those two have different fixes.
41
+
42
+ ### Documentation
43
+ - `AGENTS.md` corrects the claim that the budget clock "is immune wherever it is called" to what was measured, records that the depth floor must never gate the fuse, and adds the rule that a clock guard has to be pinned in both directions — with `blockRealTimeMs()` as the way to do it, since a `while (Date.now() < stop)` busy wait never terminates inside a fake window where `Date` is frozen.
44
+ - `AGENTS.md` gains the tier distinction these two legs demonstrate (a guard that is dominated, a guard that is the only handler, and a handler that runs but is redundant are three different verdicts, and only the middle one is a coverage gap), and the stale cross-reference from `reopenTransport()`'s `activeConfig` guard to `performStop()`'s absorber — deleted in `0.21.2` — now points at the live comparison in the same method.
45
+ - `AGENTS.md` gains the rule this pass was written by: a comment that a promise "never rejects" is a claim about every path through the handler it runs, not about the happy path, and when such a leg is the only thing that settles an `await`, construct the throw instead of trusting the prose. It also records the shape to prefer for that pin — race the promise against a short real-timer watchdog, so deleting the leg fails as `expected 'hung' to be 'settled'` rather than as a test timeout that names nothing.
46
+ - `AGENTS.md` adds the discipline this pass's discarded change was bought with: measure a "this test passes vacuously" theory against the *unmodified* test before rewriting it, because the rewrite brings a comment that the next reader will trust. is a claim about every path through the handler it runs, not about the happy path, and when such a leg is the only thing that settles an `await`, construct the throw instead of trusting the prose. It also records the shape to prefer for that pin — race the promise against a short real-timer watchdog, so deleting the leg fails as `expected 'hung' to be 'settled'` rather than as a test timeout that names nothing.
47
+
48
+
49
+
3
50
  ## [0.21.4] - 2026-09-22
4
51
 
5
52
  ### Fixed
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-WFVM7HPE.js";
10
+ } from "./chunk-LXAHMO7L.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -1171,6 +1171,7 @@ var WorkerClusterRuntime = class {
1171
1171
  /** Handle a point-to-point CONTROL message (SUBSCRIBE / UNSUBSCRIBE / PUBLISH). */
1172
1172
  handleControlMessage(message) {
1173
1173
  if (message.targetWorkerId !== this.workerId) return;
1174
+ if (message.topicKey !== void 0 && createOpaqueKey(message.topic) !== message.topicKey) return;
1174
1175
  this.rememberTopic(message.topic);
1175
1176
  switch (message.action) {
1176
1177
  case CONTROL_ACTION.SUBSCRIBE: {
@@ -1187,7 +1188,8 @@ var WorkerClusterRuntime = class {
1187
1188
  if (this.releaseHandoffOnUnsubscribe(message)) return;
1188
1189
  break;
1189
1190
  case CONTROL_ACTION.PUBLISH:
1190
- if (message.items && message.items.length > 0) {
1191
+ if (message.items !== void 0) {
1192
+ if (!Array.isArray(message.items) || message.items.length === 0) return;
1191
1193
  if (this.handlers.onPublishBatch) {
1192
1194
  this.handlers.onPublishBatch(message.topic, message.items);
1193
1195
  return;
@@ -1248,6 +1250,7 @@ var WorkerClusterRuntime = class {
1248
1250
  */
1249
1251
  handleRouteReleasedMessage(message) {
1250
1252
  if (message.targetWorkerId !== this.workerId) return;
1253
+ if (createOpaqueKey(message.topic) !== message.topicKey) return;
1251
1254
  const route = this.readRoute(message.topicKey);
1252
1255
  if (!route || this.isStaleRouteRelease(route, message)) return;
1253
1256
  this.assignedTopics.set(message.topicKey, message.topic);
@@ -2387,7 +2390,7 @@ var DedupManager = class {
2387
2390
  };
2388
2391
 
2389
2392
  // src/core/version.ts
2390
- var SDK_VERSION = true ? "0.21.4" : "";
2393
+ var SDK_VERSION = true ? "0.21.6" : "";
2391
2394
 
2392
2395
  // src/core/data-bus.ts
2393
2396
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -3447,7 +3450,6 @@ var CrossTabDataBus = class {
3447
3450
  }
3448
3451
  }
3449
3452
  );
3450
- void opening.catch(() => void 0);
3451
3453
  return opening;
3452
3454
  }
3453
3455
  /**
@@ -3590,4 +3592,4 @@ export {
3590
3592
  parseDataBusPublication,
3591
3593
  selectWorkerBackend
3592
3594
  };
3593
- //# sourceMappingURL=chunk-WFVM7HPE.js.map
3595
+ //# sourceMappingURL=chunk-LXAHMO7L.js.map