cross-tab-worker-databus 0.21.15 → 0.21.17

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,43 @@
1
- ## [Unreleased]
1
+ ## [0.21.17] - 2026-09-24
2
+
3
+ ### Fixed
4
+
5
+ - **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.
6
+ - 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.
7
+ - 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.
8
+ - 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.
9
+
10
+ ### Tests
11
+
12
+ - `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.
13
+ - 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.
14
+ - 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.
15
+
16
+ ### Documentation
17
+
18
+ - `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).
19
+
20
+ ## [0.21.16] - 2026-09-24
21
+
22
+ ### Fixed
23
+
24
+ - **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.
25
+ - 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.
26
+ - 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.
27
+ - 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.
28
+ - `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.
29
+ - **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.
30
+
31
+ ### Tests
32
+
33
+ - `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.
34
+ - 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.
35
+ - 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.
36
+
37
+ ### Documentation
38
+
39
+ - `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.
40
+ - 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.
2
41
 
3
42
  ## [0.21.15] - 2026-09-24
4
43
 
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-VQJIMGJN.js";
10
+ } from "./chunk-EZYCSJRS.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.15" : "";
2460
+ var SDK_VERSION = true ? "0.21.17" : "";
2414
2461
 
2415
2462
  // src/core/data-bus.ts
2416
2463
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -3361,12 +3408,18 @@ var CrossTabDataBus = class {
3361
3408
  subscribeTransport(topic) {
3362
3409
  if (this.transportSubscribedTopics.has(topic)) return false;
3363
3410
  this.transportSubscribedTopics.add(topic);
3364
- this.runTransport(() => this.transport.subscribe(topic));
3411
+ this.runTransport(
3412
+ () => this.transport.subscribe(topic),
3413
+ () => this.transportSubscribedTopics.has(topic)
3414
+ );
3365
3415
  return true;
3366
3416
  }
3367
3417
  unsubscribeTransport(topic) {
3368
3418
  if (!this.transportSubscribedTopics.delete(topic)) return false;
3369
- this.runTransport(() => this.transport.unsubscribe(topic));
3419
+ this.runTransport(
3420
+ () => this.transport.unsubscribe(topic),
3421
+ () => !this.transportSubscribedTopics.has(topic)
3422
+ );
3370
3423
  return true;
3371
3424
  }
3372
3425
  /** Invoke `callback` for each item in `handlers`, isolating a throwing
@@ -3484,8 +3537,15 @@ var CrossTabDataBus = class {
3484
3537
  * Run a transport operation now if the transport is ready, otherwise queue
3485
3538
  * it behind the start promise. This ensures subscribe/unsubscribe calls made
3486
3539
  * during startup are not lost.
3540
+ *
3541
+ * `stillWanted` expresses the deferred work as the *current* desired state
3542
+ * rather than a captured call, and is checked at each place that resumes on a
3543
+ * later task — the recovery gate and the opening flush. The immediate branch
3544
+ * carries no check because every path into it evaluates the desired state in
3545
+ * the same task: a caller that just mutated `transportSubscribedTopics`, or
3546
+ * the gate re-entry one line above.
3487
3547
  */
3488
- runTransport(operation) {
3548
+ runTransport(operation, stillWanted) {
3489
3549
  if (this.suspended) return;
3490
3550
  if (this.recoveryGate && !this.stopping) {
3491
3551
  const gate = this.recoveryGate;
@@ -3495,7 +3555,8 @@ var CrossTabDataBus = class {
3495
3555
  void gate.then(() => {
3496
3556
  this.recoveryWaiters -= 1;
3497
3557
  if (this.stopping || this.suspended || cancellationToken !== this.recoveryCancellationToken) return;
3498
- this.runTransport(operation);
3558
+ if (stillWanted && !stillWanted()) return;
3559
+ this.runTransport(operation, stillWanted);
3499
3560
  });
3500
3561
  return;
3501
3562
  }
@@ -3516,6 +3577,7 @@ var CrossTabDataBus = class {
3516
3577
  void ready.then(
3517
3578
  () => {
3518
3579
  if (!this.started || this.stopping || this.suspended) return;
3580
+ if (stillWanted && !stillWanted()) return;
3519
3581
  return operation();
3520
3582
  },
3521
3583
  // The opening promise reports its own lifecycle failure through
@@ -3626,4 +3688,4 @@ export {
3626
3688
  parseDataBusPublication,
3627
3689
  selectWorkerBackend
3628
3690
  };
3629
- //# sourceMappingURL=chunk-VQJIMGJN.js.map
3691
+ //# sourceMappingURL=chunk-EZYCSJRS.js.map