cross-tab-worker-databus 0.21.16 → 0.21.18

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,4 +1,44 @@
1
- ## [Unreleased]
1
+ ## [0.21.18] - 2026-09-24
2
+
3
+ No behavior, public API, protocol, or storage change. This release carries five test pins that each close a leg no test named, one rewritten source comment that ships through the bundles, and two repository workflow rules.
4
+
5
+ ### Tests
6
+
7
+ - **A non-string frame `type` is normalized to `null` before it is reported.** `getDiagnostics().protocol.lastUnknownMessageType` is declared `string | null` and is filled from a BroadcastChannel any same-origin script can post into. The `null` side of that normalization had never executed, and `getUnknownMessageStats()` had no reader at all — so the `unknownMessages` count published beside it had none either. Four forged frames now arrive through a channel joined *by name* rather than by calling the handler. The prototype-less-object frame is posted **first** on purpose: the failure it exists to catch is a throw (`String(Object.create(null))` raises `TypeError: Cannot convert object to primitive value`), and in any other order an earlier value mismatch hides it.
8
+ - **A `replay.persistenceRetry` block that names only one field applies the documented default to the other.** Each field is validated only when present and each default is supplied independently, so `{ maxAttempts: 2 }` must still wait the 50 ms backoff and `{ backoffMs: 5 }` must still make exactly one attempt. Previously every partial object the suite built threw at the field it named, so the validator's accept path for a partial policy had never completed. This gap was invisible from the defaulting sites' own counters — both `??` legs read hot, because a bus with no policy takes the right one and a bus with both takes the left; the untested shape was between them.
9
+ - **A failed handshake leaves no connect timer armed.** `failConnect()` cancels the attempt's connect timer before it rejects, and that cancel — not the callback's staleness guard — is what makes the guard unreachable. It had no test. Without it, the dead attempt's own timeout later reports `onStatus('error')` plus an `onError('WebSocket did not open within Nms.')` for a connection the application was already told had failed.
10
+ - **A `channelToken` credential request that names no channel is answered with an empty one.** `channel` is optional on the `TOKEN_REQUEST` wire shape and the session omits it when it is `undefined`, which is reachable from an older worker on the port or from a dependency handing the callback a non-string. The `?? ''` is the only thing keeping `undefined` from crossing into an application `getChannelToken(channel: string)`.
11
+ - **A `Blob` frame whose conversion fails after its connection was replaced is not reported to the replacement.** That catch reports through `this.handlers` — whoever holds the connection at that moment — so the guard comparing the captured socket/handler pair is the only thing between a dead socket's conversion error and the live connection's `onError`, which an application would read as the new connection being broken.
12
+
13
+ Each case was mutation-checked, and the attribution is recorded in `docs/progress.md` (Phases 155–159): which single assertion fails, which mutant dies at which leg, and the three legs found to have **no mutant of their own**. Four zero-count branch slots closed; `src/` now reads 56 of 1960, and `validation.ts` reports 100% branches for the first time.
14
+
15
+ ### Documentation
16
+
17
+ - `src/websocket.ts` — the connect-timeout callback's guard comment now names the enumeration that makes **all three** of its terms unreachable (`socket` and the handler set are reassigned only in `start()` and `stop()`; reaching either needs `socketActive` already false, and every path that lowers it has already cancelled the armed timer), says which test holds which leg, and **drops** its "all 880 tests green" count instead of refreshing a number that rots in place. This is shipped text: it reaches consumers through both `dist/` bundles and the declarations.
18
+ - `AGENTS.md` — two rules taken from measurements in this batch. (1) Vitest transpiles `tests/**` with no type checking, so a test that constructs a deliberately illegal value is red only under `pnpm check`; the widen belongs on the post helper, not on each frame, because `Object.create(null)` is `any`. (2) Do not rebase a branch whose PR is already open: the progress log has a single append anchor, so two open PRs touching it leave the older unmergeable, and the repair cannot be pushed without a force-push, which this repository forbids.
19
+
20
+ ### Compatibility
21
+
22
+ Mixed-version peers are unaffected: no frame, default, storage key, or export moved. `verify:compat`, `verify:types` and `verify:pack` pass against `v0.21.17`, and the published-consumer gate re-runs on the tag.
23
+
24
+ ## [0.21.17] - 2026-09-24
25
+
26
+ ### Fixed
27
+
28
+ - **A durable route confirmed to a Worker that no longer holds the assignment no longer orphans its Topic permanently.** `pause()` clears `assignedTopics` unconditionally but rewrites only the routes it can hand off, and a handoff whose final storage flush is cut short leaves the queued rest to be discarded when the teardown ends — so the record on disk still names the departing owner, confirmed, with the generation it had before. When that worker id registers again (a BFCache restore, or a runtime rebuilt with an explicit `workerId`), the route names a live Worker that holds nothing, and both reconcile passes miss the combination by construction: `reconcileSubscriptions()` walks `subscribedTopics` and stands down for a live owner, `reconcileAssignedTopics()` walks `assignedTopics`, and the phantom is in neither. The result is a topic nobody's transport subscribes — inbound messages stop arriving in **every** tab while outbound publishing keeps working, and `isAssigned()` still answers `true` for the phantom owner through its durable-route fallback, so nothing reports it.
29
+ - The named Worker now repairs it, and only it can: reclaim through the same self-assignment `subscribe()` performs when it still has a local handler for the topic (the record and its generation stay byte-identical, so peers see no churn on a route that already named it), or release the record and let a remaining subscriber elect an owner it can act on. Two gates make this safe to run every heartbeat. `confirmedAt` must be present, because that is what distinguishes a settled record from an election or handoff in flight — `confirmRoute()` has exactly three call sites and each sits in the same task as the `assignedTopics.set` before it, so a confirmed route with no assignment can only mean the assignment was taken away afterwards; and the assignment map is consulted first, which is what leaves the *legitimate* case alone: a Worker elected on a peer's behalf owns a route for a topic it never subscribed locally, and a sweep keyed on `subscribedTopics` would delete that record on every pass.
30
+ - The release **flushes the deletion before broadcasting `REGISTRY`**. A peer reconciling on the nudge reads storage through its own batching writer, so without the flush it reads the route that is still queued for deletion in the releasing Worker, stands down again, and the repair waits a full heartbeat. This is the rule `pause()` and `handoffAssignedTopics()` already follow, and the pin is a synchronous post-pass read that fails when the flush is removed.
31
+ - Corrected the premise of one pre-existing test, which hand-wrote a takeover route by copying the real one and changing `workerId`/`tabId`/`generation` — leaving the *previous* owner's `confirmedAt` on a record that names a different worker. No Worker writes that state, and it is exactly what the new sweep releases, so the test's assertion would have gone green by way of a different mechanism than the one it names. It now stages an unconfirmed takeover, which is what a real one looks like from a peer's seat.
32
+
33
+ ### Tests
34
+
35
+ - `MemoryStorage` gained `failNextWrites(n)`, which makes the next `n` `setItem` calls throw the error a quota-full `localStorage` throws. Only writes fail — `removeItem` cannot — which is what makes the knob usable for its one purpose: the state above is produced by a lost **write**, and a test that asserted "storage failed" through a lost delete would be asserting a mechanism the browser does not have.
36
+ - Three pins, one per leg, and each dies separately: reclaiming a stranded self-route (`generation` and owner asserted unchanged alongside the restored assignment), releasing one for a peer to re-elect, and leaving a route alone while its holder still has the assignment. Mutating the new code leg by leg, with the whole suite run each time: dropping the confirmation gate fails **6**, dropping the addressee gate fails **28**, dropping the assignment gate fails **7** (including the sticky-route pin above), dropping the reclaim fails exactly **1**, dropping the route delete exactly **1**, dropping the nudge exactly **1**, dropping the flush exactly **1** — the last three are disjoint, so none of the three legs is decoration.
37
+ - The `storage-fail` interleaving that found this is **not** shipped in the coordination sweep, and the reason is measured rather than assumed. Adding it as a band in the seed's roll (2.5% of steps) makes the sweep fail on the pre-fix code with five violating seeds — `414`, `467`, `1557`, `2074`, `2969`, every one opening with a `storage-fail` and ending in `1 subscriber(s) need one owner that holds the transport, got owners=[] transportSubscriptions=[]` — and pass with the fix (`0` violations over the `4,701` seeds that run before the fuse). But the first kill sits at seed **414**, four times above the sweep's 100-seed floor, so on a starved runner the band would not catch a regression of this class, while the unit pins catch it deterministically. It stays a finder: the recipe is in `docs/progress.md`, and the `failNextWrites` knob it needs is now committed.
38
+
39
+ ### Documentation
40
+
41
+ - `docs/architecture.md` and its Chinese mirror gained a "Stranded-self-route repair" invariant bullet next to the "Assignment drift repair" one it is the inverse of, and the heartbeat/reconcile summary now lists the pass in the order it runs (after the orphan prune, before the subscription pass, so a released route is re-elected inside the same cycle rather than one heartbeat later).
2
42
 
3
43
  ## [0.21.16] - 2026-09-24
4
44
 
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-GQ6NYWQL.js";
10
+ } from "./chunk-KRSD6KHE.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -1284,6 +1284,7 @@ var WorkerClusterRuntime = class {
1284
1284
  reconcile() {
1285
1285
  if (!this.started) return;
1286
1286
  const workers = this.reconcileWorkers();
1287
+ this.reconcileStrandedSelfRoutes();
1287
1288
  const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
1288
1289
  this.reconcileSubscriptions(workers, activeWorkers);
1289
1290
  this.reconcileAssignedTopics();
@@ -1348,6 +1349,52 @@ var WorkerClusterRuntime = class {
1348
1349
  }
1349
1350
  }
1350
1351
  }
1352
+ /**
1353
+ * Repair a confirmed route that names this Worker while its assignment is gone.
1354
+ *
1355
+ * Every write that settles ownership stamps the new owner's assignment in the
1356
+ * same task as the confirmation (`confirmRoute()` has exactly three call sites,
1357
+ * each directly after an `assignedTopics.set`), so a route carrying
1358
+ * `confirmedAt` while this map has no entry can only mean the assignment was
1359
+ * taken *after* the route settled. That is what `pause()` does: it clears the
1360
+ * map unconditionally but rewrites only the routes it can hand off, so a
1361
+ * handoff whose final flush is cut short by a storage failure — and the
1362
+ * `discardPending()` that ends the teardown throws the rest away — leaves a
1363
+ * durable route naming this worker. If the same worker id registers again (a
1364
+ * BFCache restore, or a runtime rebuilt with an explicit `workerId`), every
1365
+ * peer reads that route as a live, confirmed owner and stands down, while no
1366
+ * worker holds a transport subscription for the topic any more. Both reconcile
1367
+ * passes miss it by construction: one walks `subscribedTopics`, the other
1368
+ * `assignedTopics`, and the phantom is in neither.
1369
+ *
1370
+ * Acting here is this worker's alone — a peer that elected it is still waiting
1371
+ * for its own CONTROL/SUBSCRIBE to be accepted, and the single-writer rule
1372
+ * recorded in `reconcileSubscriptions()` forbids anyone else rewriting the
1373
+ * record. Reclaim when the local subscription is still live, which restores the
1374
+ * assignment and the transport subscription without touching the route, so the
1375
+ * owner and its generation stay exactly as peers recorded them; release
1376
+ * otherwise, since an owner that has no handler for the topic and never will is
1377
+ * a record nothing can act on.
1378
+ */
1379
+ reconcileStrandedSelfRoutes() {
1380
+ if (!this.storage) return;
1381
+ let released = false;
1382
+ for (const { key, value: route } of readAllByPrefix(this.storage, this.routePrefix)) {
1383
+ if (route.confirmedAt === void 0 || route.workerId !== this.workerId) continue;
1384
+ if (this.assignedTopics.has(route.topicKey)) continue;
1385
+ const topic = this.knownTopics.get(route.topicKey);
1386
+ if (topic !== void 0 && this.subscribedTopics.has(topic)) {
1387
+ this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, route.topicKey);
1388
+ continue;
1389
+ }
1390
+ this.removeStorage(key);
1391
+ released = true;
1392
+ }
1393
+ if (released) {
1394
+ this.flushStorage();
1395
+ this.notifyRegistry();
1396
+ }
1397
+ }
1351
1398
  /** Drop assignments where the route no longer points to this worker. */
1352
1399
  reconcileAssignedTopics() {
1353
1400
  for (const [topicKey, topic] of [...this.assignedTopics]) {
@@ -2410,7 +2457,7 @@ var DedupManager = class {
2410
2457
  };
2411
2458
 
2412
2459
  // src/core/version.ts
2413
- var SDK_VERSION = true ? "0.21.16" : "";
2460
+ var SDK_VERSION = true ? "0.21.18" : "";
2414
2461
 
2415
2462
  // src/core/data-bus.ts
2416
2463
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -3641,4 +3688,4 @@ export {
3641
3688
  parseDataBusPublication,
3642
3689
  selectWorkerBackend
3643
3690
  };
3644
- //# sourceMappingURL=chunk-GQ6NYWQL.js.map
3691
+ //# sourceMappingURL=chunk-KRSD6KHE.js.map