cross-tab-worker-databus 0.20.90 → 0.20.92

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.
@@ -96,7 +96,7 @@ Terms are explained in plain language; the code and the rest of this document us
96
96
  | **Sticky** | — | Existing routes keep their owner while that owner is alive; load and visibility only affect placement of brand-new routes. |
97
97
  | **Heartbeat** | `heartbeatAt` | A Worker's periodic liveness write to storage. Workers past `workerTtlMs` without refreshing are considered dead. |
98
98
  | **Handoff** | `handoffFromWorkerId` | The graceful passing of a Topic from an old owner to a new one (e.g. on `pagehide`), with a strict release-ACK protocol so no Topic is ever owned twice simultaneously. |
99
- | **Generation** | `generation` | A monotonic counter on each route. Handoff ACKs must reference a generation at least as new as the route's, so stale ACKs are ignored. |
99
+ | **Generation** | `generation` | A monotonic counter on each route. Handoff ACKs must reference the route's exact generation, so delayed ACKs from any other handoff round are ignored. |
100
100
  | **Local mode** | `coordinated: false` | Degraded operation when storage or BroadcastChannel is unavailable: no cross-Tab routing, the Tab only uses its own transport. |
101
101
 
102
102
  ## Storage Structure
@@ -146,7 +146,7 @@ interface WorkerRoute {
146
146
 
147
147
  `generation` increments on every re-assignment and must match across the handoff handshake; `handoffFromWorkerId` records the previous owner during a graceful handoff. The interface above matches the current protocol — see [Failover](#failover) for how these two fields drive takeover.
148
148
 
149
- Routes do not store the original topic string or payload. When the actual owner receives `CONTROL/SUBSCRIBE`, the original topic string is only passed through the BroadcastChannel in-memory message. `confirmedAt` is written after the owner processes the control message; before the route is confirmed, the subscriber Runtime holding the original topic string will resend `SUBSCRIBE` to recover from BroadcastChannel message loss that results in "a route without a real subscription".
149
+ Routes do not store the original topic string or payload. When the actual owner receives `CONTROL/SUBSCRIBE`, the original topic string is only passed through the BroadcastChannel in-memory message. The receiver accepts that control frame only when the durable route currently names it; a delayed frame from an earlier assignment round is dropped, and a route awaiting `ROUTE_RELEASED` can be confirmed only by the matching handoff ACK. `confirmedAt` is written after the owner processes the control message; before the route is confirmed, the subscriber Runtime holding the original topic string will resend `SUBSCRIBE` to recover from BroadcastChannel message loss that results in "a route without a real subscription".
150
150
 
151
151
  ### How `topic`, `topicKey`, `tabId`, `workerId`, and BroadcastChannel relate
152
152
 
@@ -316,7 +316,7 @@ All real-time coordination flows through one BroadcastChannel per cluster, whose
316
316
 
317
317
  | Type | Direction | Purpose |
318
318
  |---|---|---|
319
- | `CONTROL` | point-to-point (A → B) | Ask the target Worker to `SUBSCRIBE`, `UNSUBSCRIBE`, or `PUBLISH` a topic. Carries `action`, `topic`, `topicKey`, `targetWorkerId`, and an optional `data` payload. |
319
+ | `CONTROL` | point-to-point (A → B) | Ask the target Worker to `SUBSCRIBE`, `UNSUBSCRIBE`, or `PUBLISH` a topic. Carries `action`, `topic`, `topicKey`, `targetWorkerId`, and an optional `data` payload. A `SUBSCRIBE` is honored only when the durable route currently names the target and is not awaiting `ROUTE_RELEASED`. |
320
320
  | `EVENT` | broadcast (owner → all Tabs) | Fan out a publication that the transport delivered to the owning Worker. Carries `eventType` and `payload`. |
321
321
  | `REGISTRY` | broadcast | Nudge every Tab to reconcile immediately after a registry or route write, instead of waiting for the next heartbeat. |
322
322
  | `ROUTE_RELEASED` | point-to-point (old owner → new owner) | Acknowledge a graceful handoff; only the new owner whose route `generation` matches may `SUBSCRIBE` (see Failover). |
@@ -452,6 +452,8 @@ A publication travels publisher → current Topic owner → transport/server →
452
452
 
453
453
  Optional publication metadata (`messageId` and `timestamp`) follows the same path as the payload: `CONTROL/PUBLISH` → transport/server → `DataBusMessage` → `EVENT` fan-out. It is never written to coordination storage. Transports normalize legacy payloads and the canonical `DataBusPublicationEnvelope` before the three dispatch gates.
454
454
 
455
+ At the `EVENT` boundary, frames carrying an unknown `eventType` or a publication payload without a string `topic` are ignored. Legacy publication payloads without `originTabId` inherit the frame-level value, while an explicit payload-level `originTabId` wins. These rules keep the channel forward-compatible with older/newer SDK peers and prevent one malformed frame from breaking later delivery.
456
+
455
457
  At the transport layer, a Centrifuge client can emit a publication both on the `client` object and on the matching `Subscription` object. To avoid dispatching the same server publication twice, the Centrifuge session only handles the client-level `publication` for topics that have **no active client-side subscription** (server-side subscriptions); topics with an active subscription are delivered solely through the subscription-level listener.
456
458
 
457
459
  ```mermaid
@@ -537,11 +539,14 @@ This process prevents overlap during graceful owner handoff while retaining avai
537
539
 
538
540
  These invariants are pinned by regression tests (see `tests/stability.test.ts` and `tests/replay-persistence.test.ts`) and must hold through future refactors:
539
541
 
540
- - **Handoff ACK validity.** A `ROUTE_RELEASED` is accepted only when the route still points at the receiver, the release comes from the recorded `handoffFromWorkerId`, and the ACK generation is at least as new as the stored route generation. Replayed ACKs from an earlier handoff round (e.g. an a↔b ping-pong) carry an older generation and are dropped.
542
+ - **SUBSCRIBE route binding.** An inbound `CONTROL/SUBSCRIBE` is accepted only when the durable route currently names the receiver and is not an unconfirmed graceful handoff. A delayed frame from an earlier assignment round cannot add ownership, subscribe the transport, or confirm the route; only the matching `ROUTE_RELEASED` can authorize a pending handoff. Ownership follows the current route record, not the order in which control frames arrive.
543
+ - **Handoff ACK validity.** A `ROUTE_RELEASED` is accepted only when the route still points at the receiver, the release comes from the recorded `handoffFromWorkerId`, and the ACK generation exactly matches the stored route generation. Replayed ACKs from any other handoff round (e.g. an a↔b ping-pong) are dropped instead of confirming the current route.
541
544
  - **Replay persistence cleanup ordering.** A batched persistence flush queued behind the current task is filtered against the cleanup that wins the race: `unsubscribe` and `clearReplayTopic` drop the topic's pending entries, `clearReplayBefore` drops entries older than the cutoff, and `suspend()`/`stop()` discard the whole queued batch before it can start under the next lifecycle generation. Cleared or stopped-session history is never re-appended by an in-flight flush.
542
545
  - **Storage write recovery.** Coalesced writes retry with exponential backoff (50 ms → 1.6 s cap). A structurally failing key is dropped after 5 attempts (with a `console.warn`) without permanently blocking other queued keys, and the backoff delay resets once the queue fully drains or `clear()` cancels the retries.
543
546
  - **Transport recovery budget.** Automatic recovery is paced by a cooldown, bounded by `recovery.maxAttempts`, and reports `exhausted` when the budget is spent. A successful reopen resets the attempt counter and the exhausted flag; explicit `subscribe` on a down transport can still recover manually. Scheduling alone does not reopen a connection: the backend must release the invalid connection before a retry can create or re-open one. `WebSocketTransport` resolves `start()` only after the socket opens, rejects a pre-open `error`/`close` or a `connectTimeoutMs` expiry, marks the socket active only until error/close, and drops the stale reference before the next `start()` invokes the factory, so late callbacks from the superseded socket are ignored.
544
547
  - **BFCache suspension.** Hiding the tab stops the transport, bumps the persistence-retry generation (cancelling in-flight persistence retries without surfacing errors), pauses trace metrics and periodic dedup/replay cleanup, and gates dispatch; `pageshow` or an explicit `start()` reopens the transport, resumes those periodic resources, and re-establishes subscriptions exactly once per cycle.
548
+ - **Recovery waiter invalidation.** Each operation parked behind the recovery gate captures the recovery cancellation generation. `stop()` and `suspendTransport()` invalidate that generation when they supersede the recovery cycle, so a waiter whose microtask runs after an immediate explicit `start()` cannot replay into the replacement transport. The restarted cluster owns exactly one resubscription per assigned topic, and the stale operation cannot issue a duplicate subscription.
549
+ - **Hidden-document activation guard.** `WorkerClusterRuntime.start()` installs lifecycle listeners before it checks visibility. A start that reaches a hidden document records suspension without registering a worker, opening a channel, or starting heartbeats, so a restart queued behind an asynchronous stop cannot miss a `pagehide` during cleanup and reconnect in the background; the installed listener handles the later `pageshow`.
545
550
  - **Handoff channel close ordering.** `pause()` defers the physical `channel.close()` by one task. Closing synchronously would discard messages still queued for delivery — including the handoff's `ROUTE_RELEASED` — stranding the handoff target with an unconfirmed route.
546
551
  - **Stranded-handoff recovery.** If the previous owner is gone and its `ROUTE_RELEASED` never arrives (dropped channel message under load, or a crash between the route write and the ACK), the reconcile loop re-elects a live owner once the unconfirmed handoff has been stuck longer than a worker TTL (10 s default): the route is rewritten with a fresh generation and the handoff marker cleared, so the normal confirmation path completes (pinned by regression). The age gate matters — a fresh unconfirmed route may simply be waiting out its confirmation flush — and while the previous owner is still alive the new owner keeps waiting, so the strict handoff keeps its no-overlap guarantee.
547
552
  - **Loss and recovery matrix.** Each coordination message has a bounded recovery path: a lost `CONTROL/SUBSCRIBE` is re-sent by the heartbeat reconcile for any route still lacking `confirmedAt`; a lost `REGISTRY` nudge costs at most one heartbeat interval (3 s default) because every tick reconciles anyway; a lost `ROUTE_RELEASED` is recovered by reconcile re-election once the previous owner is gone and the handoff has been stuck longer than a worker TTL (see the stranded-handoff invariant above, pinned by regression); publications dropped during a transport disconnect window are the one documented unrecoverable loss (transport contract). The storage-event fallback channel guarantees value-change delivery via a monotonic sequence in the envelope, and a dropped dispatch recovers through the same reconcile loop.
@@ -553,7 +558,7 @@ DataBus separates "business subscription intent" from "transport current subscri
553
558
 
554
559
  The built-in Centrifuge transport also retains its own Subscriptions and performs protocol-level reconnection. Both layers of recovery require `subscribe` / `unsubscribe` to be idempotent.
555
560
 
556
- A runtime `error` intentionally keeps `transportReady` true: the flag records that the installed transport opened for this session, so `ready()` keeps tracking the transport instead of flapping with the protocol connection. Transport *operations* are gated separately by the recovery gate. While an automatic or demand-driven reopen is pending, `runTransport()` parks new `subscribe` / `publish` calls behind that gate rather than writing them to the connection that just reported `error`; the gate is released only once a reopen succeeds (or the transport self-heals to `connected`), and every parked operation then runs against the live transport. A failed automatic attempt keeps the gate closed but lets the next explicit operation drive an immediate on-demand reopen instead of waiting out another cooldown; once the recovery budget is exhausted, or the wait is superseded by `stop()` / page-hide, the gate is released so the documented explicit-retry path stays reachable. `disconnected` is a clean close rather than a recoverable failure: it never schedules a background DataBus reopen, and only an explicit `start()`, a page restore, the transport's own reconnection, or a later transport operation returns it to `connected`. That last path matters because the ready fast path is refused once the transport has actually reached `connected` and then reports `disconnected`; a `subscribe()` / `publish()` arriving after such a clean close is parked behind the same recovery gate and drives exactly one on-demand reopen, then flushes against the replacement connection instead of being written to the closed one. A transport that resolved `start()` before its first `connected` (worker-style backends report the connection asynchronously) is still handed operations directly, because its `disconnected` status means "not connected yet" rather than "a working connection was lost".
561
+ A runtime `error` intentionally keeps `transportReady` true: the flag records that the installed transport opened for this session, so `ready()` keeps tracking the transport instead of flapping with the protocol connection. Transport *operations* are gated separately by the recovery gate. While an automatic or demand-driven reopen is pending, `runTransport()` parks new `subscribe` / `publish` calls behind that gate rather than writing them to the connection that just reported `error`; the gate is released only once a reopen succeeds (or the transport self-heals to `connected`), and every parked operation then runs against the live transport. A failed automatic attempt keeps the gate closed but lets the next explicit operation drive an immediate on-demand reopen instead of waiting out another cooldown; an exhausted recovery budget releases the gate so parked operations can follow the documented explicit-retry path. A `stop()` / page-hide instead invalidates the parked recovery generation before releasing the gate, so an immediate `start()` re-establishes subscriptions on the replacement transport without a stale waiter replaying its operation afterward. `disconnected` is a clean close rather than a recoverable failure: it never schedules a background DataBus reopen, and only an explicit `start()`, a page restore, the transport's own reconnection, or a later transport operation returns it to `connected`. That last path matters because the ready fast path is refused once the transport has actually reached `connected` and then reports `disconnected`; a `subscribe()` / `publish()` arriving after such a clean close is parked behind the same recovery gate and drives exactly one on-demand reopen, then flushes against the replacement connection instead of being written to the closed one. A transport that resolved `start()` before its first `connected` (worker-style backends report the connection asynchronously) is still handed operations directly, because its `disconnected` status means "not connected yet" rather than "a working connection was lost".
557
562
 
558
563
  ## Lifecycle State Machine
559
564
 
@@ -615,7 +620,7 @@ A runtime `error` intentionally keeps `transportReady` true: the flag records th
615
620
  - **Readiness during suspend**: A suspended bus sets `suspended = true` and reuses `startPromise`/`pendingStop` for the chained `transport.stop()`, so that promise proves cleanup completed rather than readiness. `ready()` checks `suspended` after the `stopping` gate and rejects with a suspended-state error instead of returning the stop gate. `pageshow`/`reopenTransport()` and an explicit `start()` clear the flag and install a real reopen promise, so `ready()` follows the newest lifecycle intent. Because `pagehide` pauses the *cluster* independently of the transport, an explicit `start()` that leaves suspension resumes the cluster too; without that, the bus would report a healthy transport while its channel listener, heartbeat, and route assignments stayed dormant until the next `pageshow`, and every incoming publication would be discarded by `isAssigned()` against the cleared assignment map. `getHealthSummary()` already reported `{ healthy: false, state: 'suspended' }`; rejection keeps that verdict consistent with `ready()`.
616
621
  - **Superseded open invalidation**: Every fresh start, reopen, suspend, and stop advances `lifecycleEpoch`. An open captures its epoch, ignores stale status/message/error callbacks, and neither marks the transport ready nor performs failure cleanup after a newer transition owns the lifecycle. `stop()` therefore waits for pending opens/reopens and prevents a superseded open from becoming ready after the stop completes.
617
622
  - **Recovery cooldown**: When the transport reports `error` while `started` is true and `stopping` is false, `updateStatus` schedules an automatic `reopenTransport()` after `RECOVERY_COOLDOWN_MS` (1000 ms). A second error within the cooldown window is suppressed to prevent a tight retry loop.
618
- - **Transport recovery gate**: Scheduling a reopen also arms a recovery gate, so `subscribe` / `publish` issued during the cooldown cannot reach the failed connection; they are released after the reopen succeeds. The gate deliberately survives a failed automatic attempt: the next explicit operation starts an immediate on-demand reopen instead of waiting for the next paced attempt, and parked operations flush behind that success. Exhausting `recovery.maxAttempts`, or superseding the wait with `stop()` / `suspendTransport()`, releases the gate so the explicit-retry path and the documented suspend-drop semantics are preserved. A runtime `error` does not clear `transportReady`, because clearing it would let caller traffic reopen the transport outside the cooldown and report readiness against a connection that is not carrying data.
623
+ - **Transport recovery gate**: Scheduling a reopen also arms a recovery gate, so `subscribe` / `publish` issued during the cooldown cannot reach the failed connection; they are released after the reopen succeeds. The gate deliberately survives a failed automatic attempt: the next explicit operation starts an immediate on-demand reopen instead of waiting for the next paced attempt, and parked operations flush behind that success. An explicit `start()` that supersedes the automatic timer cancels that attempt and resets the failure ledger but leaves the gate closed; if the manual open also fails, its parked operations remain queued for a later automatic or demand-driven reopen instead of being discarded with the superseded opening. Exhausting `recovery.maxAttempts` releases the gate so the explicit-retry path remains reachable. Superseding the wait with `stop()` / `suspendTransport()` also invalidates every parked waiter before releasing the gate, so the explicit restart and its cluster resubscription cannot be followed by a duplicate stale operation. A runtime `error` does not clear `transportReady`, because clearing it would let caller traffic reopen the transport outside the cooldown and report readiness against a connection that is not carrying data.
619
624
  - **Stop during suspend**: `stop()` sets `stopping = true`, which prevents `suspendTransport()` from running. The cleanup awaits `startPromise` and `pendingStop` to ensure any in-flight open or stop completes before the final `transport.stop()`.
620
625
 
621
626
  ## Degradation
@@ -2,20 +2,20 @@
2
2
 
3
3
  # Browser Benchmark Trend
4
4
 
5
- > Data through 2026-09-15, from the 23 archived `bench-results/browser-*.json` reports (run `pnpm bench:browser` to add one; regenerate this doc with `node scripts/bench-trend.mjs`).
5
+ > Data through 2026-09-17, from the 27 archived `bench-results/browser-*.json` reports (run `pnpm bench:browser` to add one; regenerate this doc with `node scripts/bench-trend.mjs`).
6
6
 
7
7
  The comparison baseline for release gating is `pnpm bench:compare --fail-above-pct 50` between the two most recent reports (50% ceiling absorbs shared-runner noise). This doc records the long-run picture: values are per-metric latencies where lower is better, and the all-time best marks the healthiest observed run on this machine.
8
8
 
9
9
  <!-- BENCH-TREND:BEGIN (machine-generated table) -->
10
10
  | Metric | Previous (ms) | Latest (ms) | Δ | All-time best (ms) |
11
11
  |---|---|---|---|---|
12
- | publish per-message (ms, lower is better) — dedicated | 53.7635 | 42.335 | -11.43 | 35.3543 |
13
- | publish per-message (ms, lower is better) — shared | 39.2276 | 35.806 | -3.42 | 33.6784 |
14
- | wildcard dispatch ×1000 (ms, lower is better) | 7.4 | 7.2 | -0.20 | 0.1 |
15
- | publishBatch ×1000 (ms, lower is better) | 4.3 | 4.9 | +0.60 | 0.4 |
16
- | dedup ×1000 (ms, lower is better) | 17.4 | 21.5 | +4.10 | 0 |
17
- | trace + publish ×1000 (ms, lower is better) | 6.7 | 5.3 | -1.40 | 4.8 |
18
- | first-packet cold dispatch (ms, lower is better) | 0 | 0.1 | +0.10 | 0 |
12
+ | publish per-message (ms, lower is better) — dedicated | 52.4371 | 50.838 | -1.60 | 35.3543 |
13
+ | publish per-message (ms, lower is better) — shared | 34.2414 | 34.4971 | +0.26 | 33.6784 |
14
+ | wildcard dispatch ×1000 (ms, lower is better) | 5.7 | 6.4 | +0.70 | 0.1 |
15
+ | publishBatch ×1000 (ms, lower is better) | 4.2 | 4.3 | +0.10 | 0.4 |
16
+ | dedup ×1000 (ms, lower is better) | 23.2 | 13.3 | -9.90 | 0 |
17
+ | trace + publish ×1000 (ms, lower is better) | 5 | 5 | +0.00 | 4.5 |
18
+ | first-packet cold dispatch (ms, lower is better) | 0.1 | 0.1 | +0.00 | 0 |
19
19
  <!-- BENCH-TREND:END -->
20
20
 
21
21
  Notes:
@@ -46,10 +46,12 @@ and regression-locked:
46
46
 
47
47
  | Invariant | Area | Regressed guarantee |
48
48
  |---|---|---|
49
- | Handoff ACK validity | coordination | `ROUTE_RELEASED` is honored only when the route still points to the receiver, the release matches `handoffFromWorkerId`, and the ACK generation is at least as new as the stored route — stale ACKs from an earlier a↔b ping-pong are dropped |
49
+ | SUBSCRIBE route binding | coordination | An inbound `CONTROL/SUBSCRIBE` is honored only when the durable route currently names the receiver and is not awaiting `ROUTE_RELEASED`; delayed frames from earlier assignment rounds cannot create ownership, subscribe the transport, or confirm a pending handoff |
50
+ | Handoff ACK validity | coordination | `ROUTE_RELEASED` is honored only when the route still points to the receiver, the release matches `handoffFromWorkerId`, and the ACK generation exactly matches the stored route — delayed ACKs from another a↔b ping-pong round are dropped |
50
51
  | Replay persistence cleanup ordering | durability | Queued batch flushes are filtered against the winning cleanup (`unsubscribe`/`clearReplayTopic` drop the topic's pending entries, `clearReplayBefore` drops entries older than the cutoff, and `suspend()`/`stop()` discard the queued batch); cleared or stopped-session history is never re-appended by an in-flight flush |
51
52
  | Storage write recovery | coordination | Coalesced writes retry with exponential backoff (50 ms → 1.6 s cap); a structurally failing key is dropped after 5 attempts (with `console.warn`) without blocking other queued keys; backoff resets once the queue drains or `clear()` cancels the retries |
52
53
  | Transport recovery budget | lifecycle | Auto-recovery is paced by a cooldown, bounded by `recovery.maxAttempts`, and reports `exhausted` when the budget is spent; a successful reopen resets attempt + exhausted, and an explicit `subscribe` on a down transport can still recover manually |
54
+ | Recovery waiter lifetime | lifecycle | A recovery operation parked behind the transport gate captures the recovery cancellation generation; `stop()` / page-hide invalidates it, so an immediate explicit `start()` cannot be followed by a stale duplicate operation after the replacement transport re-establishes subscriptions. By contrast, an explicit `start()` that supersedes the automatic recovery timer preserves the gate and its parked operations if the manual open also fails, allowing a later automatic or demand-driven reopen to flush them |
53
55
  | BFCache suspension | lifecycle | Hiding the tab stops the transport and silently cancels in-flight persistence retries; pageshow reopens the transport and re-establishes subscriptions exactly once per cycle |
54
56
  | Handoff channel close ordering | coordination | `pause()` defers the physical `channel.close()` by one task so queued handoff frames (including `ROUTE_RELEASED`) flush before the channel dies |
55
57
  | Loss & recovery matrix | coordination | Every coordination message has a bounded recovery path (re-send of unconfirmed SUBSCRIBE, REGISTRY nudge, TTL + re-election for lost ACKs); publications lost during a transport disconnect window are the one documented unrecoverable loss |
@@ -22,7 +22,7 @@ Only `pnpm bench:browser` / `pnpm bench:compare` stay local-only: shared-runner
22
22
  3. Gate dependency security: `pnpm audit --registry=https://registry.npmjs.org` (the configured mirror registry lacks the audit endpoint; CI runs it on the public registry in the verify job). Fail the release on any known-vulnerability advisory; `pnpm-workspace.yaml` overrides pin patched ranges.
23
23
  4. Gate browser benchmark regressions: `pnpm bench:compare --fail-above-pct 50` after two `pnpm bench:browser` runs, using a 50% ceiling so unrelated runner noise (see the known shared-runner jitter note) cannot fail the gate; a baseline shift (e.g. a metric becoming real instead of a no-op) is an expected one-time failure. Refresh the long-run trend doc with `pnpm bench:trend` and commit it when the tables change.
24
24
  5. Confirm the package contains only intended files with `npm pack --dry-run --json`.
25
- 6. Commit, tag the exact version, and push `main --tags`.
25
+ 6. Commit on a feature branch, push that branch, and merge its green PR using squash or fast-forward (no merge commit). Fetch the merged commit, tag that exact commit, and push only the specific version tag; never push `main`/`master` directly or force-push. The workflow runs `node scripts/verify-release-version.mjs` to require `RELEASE_TAG` to equal `v` + the package version and to require exactly one non-empty CHANGELOG section.
26
26
 
27
27
  ## Security and dependency scanning
28
28
 
@@ -30,7 +30,7 @@ The repository runs CodeQL (`javascript-typescript`; on push, on pull request, a
30
30
 
31
31
  ## Tagged-release workflow
32
32
 
33
- Pushing a version tag triggers the `Release` GitHub Action: it runs `pnpm check` and `pnpm lint` (a tag can point at a commit that never passed CI's lint step), runs `verify:compat` and `verify:pack`, opens the GitHub release from the `CHANGELOG` section, publishes to npm when the `NPM_TOKEN` secret is set, and then runs the **blocking** published-consumer verification with the same budget as a manual run (`PUBLISHED_VERIFY_ATTEMPTS=48`, `PUBLISHED_VERIFY_DELAY_MS=7500`, a 6-minute ceiling). A release whose published package cannot be imported by a clean consumer fails the workflow — treat every `verify:published` failure as a failed release and republish the tag after fixing it. When no token is configured the publish step is skipped, but verification still passes against whatever version is already on npm (e.g. one published manually).
33
+ Pushing a version tag triggers the `Release` GitHub Action: it runs `pnpm check` and `pnpm lint` (a tag can point at a commit that never passed CI's lint step), runs `verify:compat` and `verify:pack`, opens the GitHub release from the `CHANGELOG` section, publishes to npm when the `NPM_TOKEN` secret is set, and then runs the **blocking** published-consumer verification with the same budget as a manual run (`PUBLISHED_VERIFY_ATTEMPTS=48`, `PUBLISHED_VERIFY_DELAY_MS=7500`, a 6-minute ceiling). A release whose published package cannot be imported by a clean consumer fails the workflow — treat every `verify:published` failure as a failed release. For registry propagation or infrastructure failures, rerun the workflow against the unchanged tag; for artifact defects, ship a new patch version. Never move or reuse a published tag. When no token is configured the publish step is skipped, but verification still passes against whatever version is already on npm (e.g. one published manually).
34
34
 
35
35
  ## Publishing
36
36
 
package/docs/roadmap.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # Roadmap
2
2
 
3
- 0.20.90 is released. The project is intentionally continuing through reliability-focused releases before a 1.0.0 stability freeze.
3
+ 0.20.91 is released; 0.20.92 is in RELEASE_FREEZE pending PR and registry verification. The project is intentionally continuing through reliability-focused releases before a 1.0.0 stability freeze.
4
+
5
+ ## 0.20.92 frozen scope
6
+
7
+ The current line continues lifecycle error-path verification around predecessor reopen settlement, queued-start readiness, and stop-promise cleanup. Sixteen fixes have now landed. First, the shared stop gate is installed before the synchronous teardown prelude, so a `stop()` re-entered from the synchronous STOP lifecycle trace event shares the one teardown instead of running `transport.stop()` twice. Second, lifecycle ownership and `startPromise` are installed before the synchronous START trace: if a trace or status callback re-enters `stop()`, the outer `start()` immediately stops later timer, cluster, and topic setup; the epoch guard abandons the old opening before `transport.start()`. Third, the same ownership-before-callback ordering now applies to `reopenTransport()`'s synchronous CONNECTING status notification, so a stop from that callback cancels recovery instead of letting it open a replacement transport after teardown. Fourth, a stop from the synchronous RESUME lifecycle trace now invalidates both explicit and native pageshow resumes; `WorkerClusterRuntime` uses a lifecycle generation so the outer pageshow handler cannot reactivate a cluster already stopped or paused by `onResume`. Previously each outer operation could settle after the nested stop and leave a stopped bus with a live connected transport or an active cluster. Fifth, transport operations already parked on the recovery gate are no longer stranded when the automatic recovery attempt they were waiting on fails: `runTransport()` now counts the parked operations, and a failed automatic attempt starts one on-demand reopen immediately when any waiter is present, so a `publish()` / `subscribe()` issued during the cooldown itself drives recovery instead of waiting for an unrelated later operation. A failed on-demand reopen still re-arms the demand flag for a later operation rather than looping on its own failure, and every waiter flushes in order once a reopen succeeds. The cluster-key isolation guarantee now has a dedicated two-runtime regression: same-topic ownership remains independent across tenants, publications do not cross the namespace boundary, and persisted keys use distinct opaque hashes without exposing the plaintext key. A sixth replay-retention fix now hands a cutoff queued during a suspend/resume cycle to a fresh cleanup when the older transaction is still unwinding, so the newest cutoff is not stranded until an unrelated future publication. A seventh fix clears `transportReady` before `reopenTransport()` emits its synchronous CONNECTING notification, so a second operation in the same tick cannot mistake the still-closing old transport for a usable connection; all operations remain parked on the opening and flush against the replacement in order. An eighth fix makes the pagehide suspension continuation epoch-aware: if the synchronous DISCONNECTED status callback invokes public `start()` as the documented resume path, the stale hide continuation is abandoned instead of stopping the replacement transport after it opens and leaving a false healthy state. A ninth fix preserves replay ordering when durable hydration is slower than live traffic: the loaded snapshot is placed ahead of publications recorded while `load()` was pending, so count pruning retains the newest live message instead of evicting it as older history. A tenth fix makes replay hydration replaceable across lifecycle boundaries: clear/clear-topic/unsubscribe/clear-before mutations are applied to an in-flight snapshot so they cannot be resurrected, a suspend/restart cancels the superseded load and starts a fresh hydration generation, and an explicit stop/start restores durable history instead of leaving the restarted rings empty. An eleventh fix closes the strict-handoff generation gap: `ROUTE_RELEASED` must exactly match the stored route generation, so an ACK from another handoff round cannot confirm the current route or release its `SUBSCRIBE` early. A twelfth fix closes the queued-restart/BFCache race: `WorkerClusterRuntime.start()` installs lifecycle listeners before checking visibility, so a restart queued behind an asynchronous stop observes a `pagehide` that arrives during cleanup and waits suspended for `pageshow` instead of reconnecting the hidden page.
8
+ A fifteenth fix preserves operations parked on that gate when an explicit `start()` supersedes the automatic timer: cancelling the timer no longer releases the gate, so if the manual open also fails those operations stay queued for the next automatic or demand-driven recovery instead of being silently dropped.
9
+ A sixteenth fix keeps a rejected teardown in both failure ledgers: `stop()` still resolves and reports through `onError`, but the recovery ledger now retains the same stop failure as the unified `lastFailure` record until an explicit `start()` resets both.
10
+ A thirteenth fix binds every inbound `CONTROL/SUBSCRIBE` to the durable route: a delayed frame from an earlier assignment round can no longer make a non-owner subscribe, and it cannot confirm a pending graceful handoff before the matching `ROUTE_RELEASED`; legitimate same-route subscriptions and normal handoff ACKs remain unchanged.
11
+ A fourteenth fix invalidates operations parked on the recovery gate when page-hide/stop cancels the recovery cycle: an immediate explicit `start()` can re-establish subscriptions on the replacement transport, but a stale waiter can no longer replay afterward and race that restart with a duplicate subscription.
12
+
13
+
14
+ ## 0.20.91 delivered scope
15
+
16
+ The reliability line continues with error-path coverage and release-gate maintenance. IndexedDB replay cleanup now has regressions for transaction-level errors on `clear()`, `clearTopic()`, and `clearBefore()`, including connection invalidation/recovery and fallback rejections when the browser exposes no transaction error object. The WebSocket transport now best-effort closes a socket after connection invalidation so automatic recovery cannot orphan a dead connection; the published-consumer gate normal path was audited with no fixed wait or redundant registry round-trip found. Centrifuge token-bridge providers are now bound to the lifecycle of the client that created them, so a replaced client cannot route a late credential request into a new session. Subscription-level callbacks and publish rejections from a replaced client are also pinned not to mutate or report into its replacement.
17
+ The DataBus lifecycle audit now also pins failed-reopen stop-gate reuse, late rejection isolation for a superseded initial open, generation isolation for late message/status/error callbacks from a retired transport, and the readiness/recovery contract (`stop()`-then-`pageshow` must not restart background work, automatic-recovery failure must surface through `ready()`, a canceled queued start must not satisfy a replacement restart, a queued restart suspended before usable must reject `ready()`, and a stale recovery timer must not reopen the transport). The audit also surfaced and fixed a real timer leak: `WorkerClusterRuntime.pause()` no longer schedules a deferred `channel.close()` when there is no channel. Mutation checks prove each regression fails when its lifecycle guard is removed. This phase additionally pins failed-open stop-gate reuse, page-hide stop-rejection recovery, replay-handler dispatch isolation, and in-flight recovery reopen reuse, with mutation coverage for the reopen guard.
18
+
19
+ The queued transport-operation audit now pins that a `subscribe()` parked behind the initial open reports its rejection through `onError` exactly once and does not escape as an unhandled rejection, while the still-opening transport is never touched before the start gate opens.
20
+ The DataBus lifecycle audit now also pins rejection ownership when an in-flight initial `transport.start()` is superseded by `stop()` and rejects while teardown is waiting: the abandoned `start()` call observes its own failure, `stop()` still resolves, the transport is closed exactly once, and the stale rejection never enters the new lifecycle's error ledger.
21
+ The cross-tab `EVENT` boundary now rejects publication payloads that are not objects with a string `topic`, so one malformed same-origin frame cannot throw from the BroadcastChannel listener and break later delivery. Unknown event types remain forward-compatible, legacy payloads inherit frame-level `originTabId`, and payload-level attribution takes precedence; both behaviors are pinned by regressions.
4
22
 
5
23
  ## 0.20.90 delivered scope
6
24
 
@@ -86,7 +86,7 @@ graph TB
86
86
  | **Sticky**(粘性) | — | 已有 route 在 owner 存活期间保持归属;负载和可见性只影响全新 route 的放置。 |
87
87
  | **Heartbeat**(心跳) | `heartbeatAt` | Worker 周期性写入存储的存活标记。超过 `workerTtlMs` 未刷新即视为死亡。 |
88
88
  | **Handoff**(交接) | `handoffFromWorkerId` | 把 Topic 从旧 owner 移交给新 owner(如 `pagehide` 时)的流程,使用严格的释放-确认协议,保证同一 Topic 不会被两个 Worker 同时拥有。 |
89
- | **Generation**(代次) | `generation` | 每条 route 上的单调递增计数器。交接确认必须引用不早于当前 route 的代次,因此过期确认会被忽略。 |
89
+ | **Generation**(代次) | `generation` | 每条 route 上的单调递增计数器。交接确认必须精确引用当前 route 的代次,因此其他交接轮次的迟到确认会被忽略。 |
90
90
  | **Local mode**(本地模式) | `coordinated: false` | storage 或 BroadcastChannel 不可用时的降级运行:无跨 Tab 路由,仅使用本 Tab 自己的 transport。 |
91
91
 
92
92
  ## 存储结构
@@ -136,7 +136,7 @@ interface WorkerRoute {
136
136
 
137
137
  `generation` 在每次重新分配时递增,交接握手必须匹配该值;`handoffFromWorkerId` 记录优雅迁移时的前任 owner。上面的接口与当前协议一致——这两个字段如何驱动接管见 [故障转移](#故障转移)。
138
138
 
139
- 路由不保存原始 Topic 字符串或 payload。真实 owner 收到 `CONTROL/SUBSCRIBE` 时,原始 Topic 字符串只通过 BroadcastChannel 内存消息传递。`confirmedAt` 在 owner 处理控制消息后写入;在路由确认之前,持有原始 Topic 字符串的订阅方 Runtime 会重发 `SUBSCRIBE`,以从"有路由但无真实订阅"的 BroadcastChannel 消息丢失中恢复。
139
+ 路由不保存原始 Topic 字符串或 payload。真实 owner 收到 `CONTROL/SUBSCRIBE` 时,原始 Topic 字符串只通过 BroadcastChannel 内存消息传递。接收方只有在持久化 route 当前指向自己时才接受该控制帧;来自较早分配轮的迟到帧会被丢弃,等待 `ROUTE_RELEASED` 的交接也只能由精确匹配的 ACK 确认。`confirmedAt` 在 owner 处理控制消息后写入;在路由确认之前,持有原始 Topic 字符串的订阅方 Runtime 会重发 `SUBSCRIBE`,以从"有路由但无真实订阅"的 BroadcastChannel 消息丢失中恢复。
140
140
 
141
141
  ### `topic`、`topicKey`、`tabId`、`workerId` 与 BroadcastChannel 的关联
142
142
 
@@ -309,7 +309,7 @@ SDK 不依赖 `storage` 事件驱动协调,控制通知使用 BroadcastChannel
309
309
 
310
310
  | 类型 | 方向 | 用途 |
311
311
  |---|---|---|
312
- | `CONTROL` | 点对点(A → B) | 请求目标 Worker 对某 Topic 执行 `SUBSCRIBE`、`UNSUBSCRIBE` 或 `PUBLISH`。携带 `action`、`topic`、`topicKey`、`targetWorkerId` 和可选 `data`。 |
312
+ | `CONTROL` | 点对点(A → B) | 请求目标 Worker 对某 Topic 执行 `SUBSCRIBE`、`UNSUBSCRIBE` 或 `PUBLISH`。携带 `action`、`topic`、`topicKey`、`targetWorkerId` 和可选 `data`。只有持久化 route 当前指向目标且不在等待 `ROUTE_RELEASED` 时,`SUBSCRIBE` 才会被执行。 |
313
313
  | `EVENT` | 广播(owner → 所有 Tab) | 把 transport 投递给 owner Worker 的 publication 扇出到所有 Tab。携带 `eventType` 和 `payload`。 |
314
314
  | `REGISTRY` | 广播 | 注册表或路由写入后通知所有 Tab 立即 reconcile,而不是等下一轮心跳。 |
315
315
  | `ROUTE_RELEASED` | 点对点(旧 owner → 新 owner) | 确认一次优雅迁移;只有 route `generation` 匹配的新 owner 才允许发送 `SUBSCRIBE`(见故障转移)。 |
@@ -424,6 +424,8 @@ sequenceDiagram
424
424
 
425
425
  可选的 publication 元数据(`messageId` 与 `timestamp`)与 payload 走同一条路径:`CONTROL/PUBLISH` → transport/服务器 → `DataBusMessage` → `EVENT` 扇出。它不会写入协调存储。各 transport 会在三处分发门之前,把旧 payload 格式与标准 `DataBusPublicationEnvelope` 统一规范化。
426
426
 
427
+ 在 `EVENT` 边界,未知 `eventType` 或缺少字符串 `topic` 的 publication payload 会被忽略;不带 `originTabId` 的旧版 publication payload 会继承帧级值,而 payload 自带值优先。这样既能与新旧 SDK peer 保持前向兼容,也能避免一条畸形帧破坏后续投递。
428
+
427
429
  在 transport 层,Centrifuge 客户端可能同时在 `client` 对象和对应 `Subscription` 对象上触发同一 publication。为避免把同一条服务器 publication 分发两次,CentrifugeSession 的 client 级 `publication` 监听只处理**没有客户端订阅**的 topic(即服务端订阅);已有活跃订阅的 topic 仅由 subscription 级监听派发。
428
430
 
429
431
  ```mermaid
@@ -507,11 +509,14 @@ Transport 消息 → isAssigned(topic)? → 是 → broadcastEvent(EVENT)
507
509
 
508
510
  以下不变量由回归测试固化(见 `tests/stability.test.ts` 与 `tests/replay-persistence.test.ts`),后续重构必须继续保持:
509
511
 
510
- - **Handoff ACK 有效性。** `ROUTE_RELEASED` 只有在 route 仍指向接收方、释放来自记录的 `handoffFromWorkerId`、且 ACK generation 不小于存储 route 的 generation 时才被接受。来自更早交接轮次的重复 ACK(如 a↔b 反复交接)携带更旧的 generation,会被丢弃。
512
+ - **SUBSCRIBE 路由绑定。** 入站 `CONTROL/SUBSCRIBE` 只有在持久化 route 当前指向接收方、且该 route 不是未确认的优雅交接时才会被接受。来自较早分配轮的迟到帧不能加入 ownership、不能订阅 transport,也不能确认 route;等待中的交接只能由精确匹配的 `ROUTE_RELEASED` 授权。ownership 以当前 route 记录为准,而不以控制帧到达顺序为准。
513
+ - **Handoff ACK 有效性。** `ROUTE_RELEASED` 只有在 route 仍指向接收方、释放来自记录的 `handoffFromWorkerId`、且 ACK generation 与存储 route 的 generation 精确相等时才被接受。来自其他交接轮次的重复或迟到 ACK(如 a↔b 反复交接)会被丢弃,不能确认当前 route。
511
514
  - **Replay 持久化清理顺序。** 排队在当前任务之后的批量持久化 flush 会与竞速的清理操作对账:`unsubscribe` 与 `clearReplayTopic` 丢弃该 topic 的待写条目,`clearReplayBefore` 丢弃早于截止时间的条目,`suspend()`/`stop()` 则丢弃整个待写批次,避免它在新生命周期代际下启动。已清理或属于已停止会话的历史不会被在途 flush 复活。
512
515
  - **存储写失败恢复。** 合并写入按指数退避重试(50 ms → 1.6 s 封顶)。结构性失败的关键在 5 次尝试后被丢弃(伴随 `console.warn`),且不会永久阻塞其他排队 key;队列完全清空或 `clear()` 取消重试后,退避延迟重置。
513
516
  - **Transport 恢复预算。** 自动恢复由冷却时间限速、由 `recovery.maxAttempts` 限量,预算耗尽后标记 `exhausted`。成功的重开会重置尝试计数与 exhausted 标记;transport 宕机时显式 `subscribe` 仍可手动恢复。自动调度本身不会重开连接:后端必须先释放失效连接,重试才能创建或重开 socket。`WebSocketTransport` 只在 socket `open` 后 resolve `start()`;握手前的 `error`/`close` 或 `connectTimeoutMs` 超时都会 reject。它仅在 socket 有效期间将其标记为 active;`error`/`close` 会立即失效,下一次 `start()` 在调用工厂前清除旧引用,因此被取代 socket 的迟到回调会被忽略。
514
517
  - **BFCache 挂起。** Tab 隐藏时停止 transport、递增持久化重试 generation(取消在途重试且不对外报错),同时暂停 trace metrics 与 dedup/replay 周期清理并门控分发;`pageshow` 或显式 `start()` 会重开 transport、恢复这些周期资源,并且每轮循环只重建一次订阅。
518
+ - **恢复 waiter 失效。** 每个停靠在 recovery gate 上的操作都会捕获当前恢复取消 generation;`stop()` 与 `suspendTransport()` 取代恢复周期时会递增该 generation,因此即使 waiter 的微任务在紧随其后的显式 `start()` 之后才运行,也不能把操作重放到替换 transport。重建后的 cluster 对每个已分配 topic 只负责一次重新订阅,旧操作不能再发出重复订阅。
519
+ - **隐藏文档的激活守卫。** `WorkerClusterRuntime.start()` 会先安装 lifecycle listener 再检查可见性。若启动时文档已隐藏,则只记录挂起状态,不注册 worker、不打开 channel,也不启动 heartbeat;因此排队在异步 stop 之后的重启不会漏掉清理期间的 `pagehide` 并在后台重连,后续 `pageshow` 会由已安装的 listener 恢复。
515
520
  - **挂起态就绪判定。** 挂起中的 bus 会把 `startPromise` 复用为 `pendingStop`(即 chained `transport.stop()` 的 gate),该 Promise 只能证明清理完成,不能证明可以承载数据。`ready()` 在 `stopping` 门之后检查 `suspended`,以挂起态错误 reject,而不是返回 stop gate;`pageshow`/`reopenTransport()` 与显式 `start()` 会清除标记并安装真正的重开 Promise,使 `ready()` 跟随最新生命周期意图。`getHealthSummary()` 原本就报告 `{ healthy: false, state: 'suspended' }`,reject 让 `ready()` 与该判定保持一致。由于 `pagehide` 会独立于 transport 暂停 cluster,显式 `start()` 在解除挂起时也必须一并恢复 cluster;否则 bus 会报告 transport 健康,而 channel listener、heartbeat 与 route 分配仍保持休眠直到下一次 `pageshow`,所有入站 publication 都会因 `isAssigned()` 对照已清空的分配表而被丢弃。
516
521
  - **交接通道关闭顺序。** `pause()` 将物理 `channel.close()` 推迟一个任务。同步关闭会丢弃仍在排队等待投递的消息(包括交接的 `ROUTE_RELEASED`),使交接目标持有未确认路由。
517
522
  - **悬挂交接恢复。** 若前任 owner 已消失而其 `ROUTE_RELEASED` 始终未到达(高负载下通道消息丢失,或 route 写入与 ACK 发送之间崩溃),reconcile 循环会在该未确认交接悬挂超过一个 worker TTL(默认 10 秒)后重新选举存活 owner:路由以全新 generation 重写并清除交接标记,使常规确认路径得以完成(已有回归固化)。年龄门限很关键——刚写入的未确认路由可能只是在等确认落盘,不能误判为悬挂;而只要前任 owner 仍然存活,新 owner 会继续等待,因此严格交接的无重叠保证不受影响。
@@ -524,7 +529,7 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
524
529
 
525
530
  内置 Centrifuge transport 也会保留自己的 Subscriptions 并做协议层重连。两层恢复都要求 `subscribe` / `unsubscribe` 幂等。
526
531
 
527
- 运行期 `error` 会有意保留 `transportReady`:该标记记录「本次会话中已安装的 transport 曾成功打开」,使 `ready()` 跟随 transport 而不是随协议连接抖动。transport 的*操作*由独立的恢复门(recovery gate)控制:当自动或按需重开尚未完成时,`runTransport()` 会把新的 `subscribe` / `publish` 挂在该门之后,而不是写入刚刚上报 `error` 的连接;门只在重开成功(或 transport 自愈回到 `connected`)后释放,此时所有挂起的操作才在可用 transport 上执行。自动尝试失败后门保持关闭,但下一次显式操作可以立即触发按需重开,而不必再等一个冷却周期;当 `recovery.maxAttempts` 耗尽,或被 `stop()` / 页面隐藏取代时,门会被释放,使文档化的显式重试路径与挂起丢弃语义继续成立。`disconnected` 是干净关闭而非可恢复失败:它不会调度后台 DataBus 重开,只有显式 `start()`、页面恢复、transport 自身的重连,或后续的 transport 操作才会回到 `connected`。最后一条路径很关键:transport 一旦真正到达过 `connected` 后再上报 `disconnected`,ready 快速路径就会被拒绝;这类干净关闭后到达的 `subscribe()` / `publish()` 会被挂在同一个恢复门之后并触发一次按需重开,随后在替换连接上 flush,而不再写入已关闭的连接。对于在首次 `connected` 之前就 resolve `start()` 的 transport(worker 型后端异步上报连接状态),操作仍会直接交给它,因为此时的 `disconnected` 表示「尚未连接」,而不是「已建立的连接断开」。
532
+ 运行期 `error` 会有意保留 `transportReady`:该标记记录「本次会话中已安装的 transport 曾成功打开」,使 `ready()` 跟随 transport 而不是随协议连接抖动。transport 的*操作*由独立的恢复门(recovery gate)控制:当自动或按需重开尚未完成时,`runTransport()` 会把新的 `subscribe` / `publish` 挂在该门之后,而不是写入刚刚上报 `error` 的连接;门只在重开成功(或 transport 自愈回到 `connected`)后释放,此时所有挂起的操作才在可用 transport 上执行。自动尝试失败后门保持关闭,但下一次显式操作可以立即触发按需重开,而不必再等一个冷却周期;`recovery.maxAttempts` 耗尽后会释放门,使停靠操作遵循文档化的显式重试路径。若等待被 `stop()` / 页面隐藏取代,则释放门前会使这一代停靠操作失效:随后的显式 `start()` 会在替换连接上重建订阅,而旧 waiter 不会再次执行并造成重复订阅。`disconnected` 是干净关闭而非可恢复失败:它不会调度后台 DataBus 重开,只有显式 `start()`、页面恢复、transport 自身的重连,或后续的 transport 操作才会回到 `connected`。最后一条路径很关键:transport 一旦真正到达过 `connected` 后再上报 `disconnected`,ready 快速路径就会被拒绝;这类干净关闭后到达的 `subscribe()` / `publish()` 会被挂在同一个恢复门之后并触发一次按需重开,随后在替换连接上 flush,而不再写入已关闭的连接。对于在首次 `connected` 之前就 resolve `start()` 的 transport(worker 型后端异步上报连接状态),操作仍会直接交给它,因为此时的 `disconnected` 表示「尚未连接」,而不是「已建立的连接断开」。
528
533
 
529
534
  ## 生命周期状态机
530
535
 
@@ -585,7 +590,7 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
585
590
  - **启动期间隐藏**:`pagehide` 在 `openTransport` 飞行中触发时,`suspendTransport()` 设置 `suspended = true`,并在飞行中的 start 之后链式执行 `transport.stop()`。`openTransport` 的 catch 路径检测到 `suspended` 后放弃本次 open,不视为失败。当重复的 hide/show 让排队的 resume opening 与更早的 stop gate 交错时,挂起会安装新的串行 stop gate 并恢复 `startPromise === pendingStop` 不变量,使下一次 `pageshow` 真正重开,而不是复用已被淘汰的 opening 并永久停留在挂起状态。
586
591
  - **被取代 open 失效**:每次全新 start、reopen、suspend 和 stop 都会推进 `lifecycleEpoch`。open 会捕获自己的 epoch;一旦更新的转换接管生命周期,旧 open 的 status/message/error 回调会被忽略,也不会再把 transport 标记为 ready 或执行失败清理。因此 `stop()` 会等待未完成的 open/reopen,并阻止被取代的 open 在 stop 完成后变为 ready。
587
592
  - **恢复冷却**:transport 上报 `error` 且 `started` 为 true、`stopping` 为 false 时,`updateStatus` 在 `RECOVERY_COOLDOWN_MS`(1000 ms)后调度自动 `reopenTransport()`。冷却窗口内的第二次错误被抑制,防止紧循环重试。
588
- - **传输恢复门**:调度重开的同时会抬起恢复门,使冷却期间发起的 `subscribe` / `publish` 无法到达失效连接,等重开成功后才释放。自动尝试失败后门刻意保持关闭:下一次显式操作会立即触发按需重开,而不是等待下一个限速尝试,挂起的操作则在该次成功后一并 flush。`recovery.maxAttempts` 耗尽,或被 `stop()` / `suspendTransport()` 取代时释放门,从而保留显式重试路径与挂起丢弃语义。运行期 `error` 不会清除 `transportReady`——清除它会让调用方流量绕过冷却重开,并在连接尚未承载数据时报告 ready。
593
+ - **传输恢复门**:调度重开的同时会抬起恢复门,使冷却期间发起的 `subscribe` / `publish` 无法到达失效连接,等重开成功后才释放。自动尝试失败后门刻意保持关闭:下一次显式操作会立即触发按需重开,而不是等待下一个限速尝试,挂起的操作则在该次成功后一并 flush。显式 `start()` 取代自动 timer 时会取消该次尝试并重置失败账本,但保持门关闭;若手动重开也失败,停靠操作会继续等待后续自动或按需重开,而不会随被取代的 opening 一起静默丢弃。`recovery.maxAttempts` 耗尽时会释放门,以保留显式重试路径;若被 `stop()` / `suspendTransport()` 取代,则在释放门前使全部停靠 waiter 失效,因此显式重启及其 cluster 重新订阅之后不会再多执行一次过期操作。运行期 `error` 不会清除 `transportReady`——清除它会让调用方流量绕过冷却重开,并在连接尚未承载数据时报告 ready。
589
594
  - **暂停期间停止**:`stop()` 设置 `stopping = true`,阻止 `suspendTransport()` 执行。清理过程会 await `startPromise` 和 `pendingStop`,确保任何飞行中的 open 或 stop 完成后才执行最终的 `transport.stop()`。
590
595
 
591
596
  ## 降级
@@ -2,20 +2,20 @@
2
2
 
3
3
  # 浏览器基准趋势
4
4
 
5
- > 数据截至 2026-09-15,基于 23 份归档的 `bench-results/browser-*.json` 报告(运行 `pnpm bench:browser` 追加一份;用 `node scripts/bench-trend.mjs` 重新生成本文档)。
5
+ > 数据截至 2026-09-17,基于 27 份归档的 `bench-results/browser-*.json` 报告(运行 `pnpm bench:browser` 追加一份;用 `node scripts/bench-trend.mjs` 重新生成本文档)。
6
6
 
7
7
  发布门禁的对比基线是最近两份报告之间的 `pnpm bench:compare --fail-above-pct 50`(50% 上限用于吸收共享 runner 的噪声)。本文记录长期趋势:数值为逐指标延迟,越低越好;历史最优为本机观察到的最健康一次运行。
8
8
 
9
9
  <!-- BENCH-TREND:BEGIN (machine-generated table) -->
10
10
  | 指标 | 上次 (ms) | 本次 (ms) | Δ | 历史最优 (ms) |
11
11
  |---|---|---|---|---|
12
- | publish per-message (ms, lower is better) — dedicated | 53.7635 | 42.335 | -11.43 | 35.3543 |
13
- | publish per-message (ms, lower is better) — shared | 39.2276 | 35.806 | -3.42 | 33.6784 |
14
- | wildcard dispatch ×1000 (ms, lower is better) | 7.4 | 7.2 | -0.20 | 0.1 |
15
- | publishBatch ×1000 (ms, lower is better) | 4.3 | 4.9 | +0.60 | 0.4 |
16
- | dedup ×1000 (ms, lower is better) | 17.4 | 21.5 | +4.10 | 0 |
17
- | trace + publish ×1000 (ms, lower is better) | 6.7 | 5.3 | -1.40 | 4.8 |
18
- | first-packet cold dispatch (ms, lower is better) | 0 | 0.1 | +0.10 | 0 |
12
+ | publish per-message (ms, lower is better) — dedicated | 52.4371 | 50.838 | -1.60 | 35.3543 |
13
+ | publish per-message (ms, lower is better) — shared | 34.2414 | 34.4971 | +0.26 | 33.6784 |
14
+ | wildcard dispatch ×1000 (ms, lower is better) | 5.7 | 6.4 | +0.70 | 0.1 |
15
+ | publishBatch ×1000 (ms, lower is better) | 4.2 | 4.3 | +0.10 | 0.4 |
16
+ | dedup ×1000 (ms, lower is better) | 23.2 | 13.3 | -9.90 | 0 |
17
+ | trace + publish ×1000 (ms, lower is better) | 5 | 5 | +0.00 | 4.5 |
18
+ | first-packet cold dispatch (ms, lower is better) | 0.1 | 0.1 | +0.00 | 0 |
19
19
  <!-- BENCH-TREND:END -->
20
20
 
21
21
  说明:
@@ -43,10 +43,12 @@
43
43
 
44
44
  | 不变量 | 领域 | 被回归固化的保证 |
45
45
  |---|---|---|
46
- | 交接 ACK 有效性 | 协调 | 仅当 route 仍指向接收方、释放方匹配 `handoffFromWorkerId`、且 ACK 代数 ≥ 存储 route 代数时才接受 `ROUTE_RELEASED`——更早交接轮次的过期 ACK(如 a↔b 乒乓)会被丢弃 |
46
+ | SUBSCRIBE 路由绑定 | 协调 | 仅当持久化 route 当前指向接收方、且不在等待 `ROUTE_RELEASED` 时才执行入站 `CONTROL/SUBSCRIBE`;较早分配轮的迟到帧不能创建 ownership、订阅 transport 或确认悬挂交接 |
47
+ | 交接 ACK 有效性 | 协调 | 仅当 route 仍指向接收方、释放方匹配 `handoffFromWorkerId`、且 ACK 代数与存储 route 代数精确相等时才接受 `ROUTE_RELEASED`——其他交接轮次的过期 ACK(如 a↔b 乒乓)会被丢弃 |
47
48
  | 回放持久化清理顺序 | 持久性 | 排队中的批量 flush 会按先到清理过滤(`unsubscribe`/`clearReplayTopic` 丢弃该 topic 的待写条目,`clearReplayBefore` 丢弃早于截止时间的条目,`suspend()`/`stop()` 丢弃整个待写批次);已清或已停止会话的历史不会被进行中的 flush 重新追加 |
48
49
  | 存储写入恢复 | 协调 | 合并写以指数退避重试(50 ms → 1.6 s 上限);结构性失败键在 5 次后丢弃(并 `console.warn`)而不阻塞其他排队键;队列清空或 `clear()` 取消后退避重置 |
49
50
  | 传输恢复预算 | 生命周期 | 自动恢复由冷却间隔节流、以 `recovery.maxAttempts` 为界,预算耗尽时报 `exhausted`;成功重开后重置尝试计数与 exhausted,断线传输上的显式 `subscribe` 仍可手动恢复 |
51
+ | 恢复 waiter 生命周期 | 生命周期 | 停靠在 transport recovery gate 上的操作会捕获恢复取消 generation;`stop()` / 页面隐藏会使其失效,因此紧随其后的显式 `start()` 在 replacement transport 重建订阅后,不会再执行过期操作造成重复订阅。相对地,显式 `start()` 取代自动恢复 timer 时会保留门及其停靠操作;若手动重开也失败,后续自动或按需重开仍可成功回放这些操作 |
50
52
  | BFCache 挂起 | 生命周期 | 隐藏页面停掉传输并静默取消进行中的持久化重试;pageshow 重开传输并每个周期恰好一次重建订阅 |
51
53
  | 交接通道关闭顺序 | 协调 | `pause()` 将物理 `channel.close()` 推迟一个任务,确保排队中的交接帧(含 `ROUTE_RELEASED`)先冲刷再关闭 |
52
54
  | 丢失与恢复矩阵 | 协调 | 每条协调消息都有有界的恢复路径(未确认 SUBSCRIBE 的重发、REGISTRY 催促、丢失 ACK 的 TTL 清理 + 重新选举);传输断线窗口内丢失的发布是唯一有文档佐证的不恢复损失 |
@@ -22,7 +22,7 @@
22
22
  3. 依赖安全门禁:`pnpm audit --registry=https://registry.npmjs.org`(配置的镜像 registry 缺少 audit 端点;CI 在 verify job 中于公共 registry 运行)。任一已知漏洞公告即视为发布失败;`pnpm-workspace.yaml` overrides 钉住补丁版本。
23
23
  4. 浏览器基准回归门禁:运行两次 `pnpm bench:browser` 后执行 `pnpm bench:compare --fail-above-pct 50`(50% 上限用于吸收共享 runner 的无关噪声,参见已知的共享 runner 抖动说明);基线迁移(例如某指标从空操作变为真实路径)属预期内的一次性失败。用 `pnpm bench:trend` 刷新长期趋势文档,表格变化时一并提交。
24
24
  5. 用 `npm pack --dry-run --json` 确认发布包只包含预期文件。
25
- 6. 提交、给精确版本打 tag,并推送 `main --tags`。
25
+ 6. 在功能分支提交并推送该分支,PR 验证通过后使用 squash 或 fast-forward 合入(不创建 merge commit)。获取合入后的精确提交并打 tag,只推送该版本 tag;禁止直接推送 `main`/`master` 或 force-push。工作流运行 `node scripts/verify-release-version.mjs`,要求 `RELEASE_TAG` 等于 `v` 加 package 版本,且 CHANGELOG 中恰好有一个非空的对应版本章节。
26
26
 
27
27
  ## 安全与依赖扫描
28
28
 
@@ -30,7 +30,7 @@
30
30
 
31
31
  ## 打 tag 的发布工作流
32
32
 
33
- 推送版本 tag 会触发 `Release` GitHub Action:先跑 `pnpm check` 与 `pnpm lint`(tag 可能指向从未通过 CI lint 步骤的提交),再跑 `verify:compat` 与 `verify:pack`,从 `CHANGELOG` 对应章节生成 GitHub release,配置了 `NPM_TOKEN` 时自动发布到 npm,然后运行与手动执行相同预算的**阻塞式**消费者验证(`PUBLISHED_VERIFY_ATTEMPTS=48`、`PUBLISHED_VERIFY_DELAY_MS=7500`,即 6 分钟上限)。已发布包若无法被干净消费者导入,工作流即失败——任何 `verify:published` 失败都应视为发布失败,修复后重新发布该 tag。未配置 token 时跳过发布步骤,但验证仍会针对 npm 上已有的版本(例如手动发布的)通过。
33
+ 推送版本 tag 会触发 `Release` GitHub Action:先跑 `pnpm check` 与 `pnpm lint`(tag 可能指向从未通过 CI lint 步骤的提交),再跑 `verify:compat` 与 `verify:pack`,从 `CHANGELOG` 对应章节生成 GitHub release,配置了 `NPM_TOKEN` 时自动发布到 npm,然后运行与手动执行相同预算的**阻塞式**消费者验证(`PUBLISHED_VERIFY_ATTEMPTS=48`、`PUBLISHED_VERIFY_DELAY_MS=7500`,即 6 分钟上限)。已发布包若无法被干净消费者导入,工作流即失败——任何 `verify:published` 失败都应视为发布失败。若为 registry 传播延迟或基础设施故障,针对不变的 tag 重跑工作流;若为产物缺陷,发布新的 patch 版本。禁止移动或重用已发布 tag。未配置 token 时跳过发布步骤,但验证仍会针对 npm 上已有的版本(例如手动发布的)通过。
34
34
 
35
35
  ## 发布(手动场景)
36
36
 
@@ -1,6 +1,24 @@
1
1
  # 路线图
2
2
 
3
- 0.20.90 已发布。项目会先持续完成可靠性发布,再进入 1.0.0 稳定性冻结。
3
+ 0.20.91 已发布;0.20.92 处于 RELEASE_FREEZE,等待 PR 与 registry 验证。项目会先持续完成可靠性发布,再进入 1.0.0 稳定性冻结。
4
+
5
+ ## 0.20.92 冻结范围
6
+
7
+ 当前开发线继续验证 predecessor reopen 结算、排队启动就绪与 stop promise 清理等生命周期错误路径。该开发线现已落地十六处修复。第一处让共享 stop gate 先于同步 teardown 前奏安装:从同步 STOP lifecycle trace 事件重入的 `stop()` 会共享同一次 teardown,而不是再调用一次 `transport.stop()`。第二处在 START trace 发出前安装 lifecycle epoch 与 `startPromise`:若同步 trace/status 回调重入 `stop()`,外层 `start()` 会立即停止后续 timer、cluster 与 topic 启动,旧 opening 由 epoch guard 放弃,transport 不会被重新打开。第三处把相同的「先安装 lifecycle、再发同步回调」顺序应用到 `reopenTransport()` 的 CONNECTING 状态通知,使恢复期间重入的 stop 能取消本次 reopen,而不是在 teardown 后重新打开 transport。第四处让同步 RESUME trace 回调内的 stop 同时取消显式 `start()` 与原生 `pageshow` 恢复;`WorkerClusterRuntime` 通过 lifecycle generation 阻止外层 pageshow 在 `onResume` 已停止或暂停 cluster 后再次 `activate()`。此前这些场景都可能留下 `state: stopped` 但 transport 或 cluster 仍活跃的半停止状态。第五处让已经停靠在 recovery gate 上的 transport 操作不再因等待中的自动恢复尝试失败而滞留:`runTransport()` 现在统计停靠操作数,自动尝试失败时若仍有 waiter 会立即发起一次 on-demand reopen,使在 cooldown 期间发出的 `publish()` / `subscribe()` 自身即可驱动恢复,而不必等待之后某个无关操作。demand reopen 自身失败时仍会重新武装 flag 留待后续操作重试,而不会在自身失败上自循环;一旦 reopen 成功,全部 waiter 会按序 flush。cluster-key 隔离保证现在由双 runtime 回归固定:不同租户可独立拥有同一 topic、publication 不会跨命名空间边界、持久化 key 使用不同 opaque hash 且不暴露明文 key。第六处 replay retention 修复会在 suspend/resume 期间旧 cleanup 事务仍在收尾时,把新排队的最新 cutoff 交给新一轮 cleanup,避免它一直滞留到未来某个无关 publication 才被处理。第七处在 `reopenTransport()` 发出同步 CONNECTING 通知前清空 `transportReady`,避免同一 tick 内第二个操作把仍在关闭的旧 transport 误判为可用连接;所有操作都会继续停靠在 opening 上,并在 replacement 打开后按序 flush。第八处让 pagehide suspension 续体具备 epoch 感知:若同步 DISCONNECTED status 回调按公开恢复路径调用 `start()`,旧 hide 续体会被放弃,不会在 replacement 打开后再次停止 transport 并留下错误的 healthy 状态。第九处修复保留 durable hydration 与实时流量的回放顺序:若 `load()` 尚未完成时已有 publication 写入,加载快照会排在实时消息之前,数量裁剪因此保留最新的实时消息,而不会把旧历史误认为更新内容。第十处让 replay hydration 能跨 lifecycle 替换:清除全部、清除单 topic、退订与 cutoff 裁剪会应用到仍在进行的加载快照,避免已清除历史复活;suspend/restart 会取消被取代的 load 并启动新一代 hydration;显式 stop/start 后也会重新加载 durable history,而不是留下空 ring。第十一处补上严格交接的 generation 缺口:`ROUTE_RELEASED` 必须与存储 route 的 generation 精确相等,其他交接轮次的 ACK 不能确认当前 route,也不能提前释放其 `SUBSCRIBE`。第十二处修复排队重启与 BFCache 的竞态:`WorkerClusterRuntime.start()` 会先安装 lifecycle listener 再检查可见性,因此排队在异步 stop 之后的重启能观察到清理期间到达的 `pagehide`,保持挂起并等待 `pageshow`,而不会重新连接隐藏页面。
8
+ 第十三处把每个入站 `CONTROL/SUBSCRIBE` 绑定到持久化 route:较早分配轮的迟到帧不能再让非 owner 订阅 transport,也不能在匹配的 `ROUTE_RELEASED` 之前确认悬挂交接;同一 route 的合法订阅与正常交接 ACK 保持不变。
9
+ 第十四处修复 recovery gate 取消竞态:当 pagehide/stop 取代恢复周期时,已停靠在门上的操作会失效;紧随其后的显式 `start()` 可以在 replacement transport 上重建订阅,而旧 waiter 不能再于重启后重放并与该过程竞争出重复订阅。
10
+ 第十五处修复显式 `start()` 取代自动恢复 timer 时停靠操作被静默丢弃的问题:取消 timer 不再释放 recovery gate;若该显式重开也失败,停靠操作会保留到下一次自动或按需恢复成功后再回放。
11
+ 第十六处修复让被拒绝的 teardown 同时保留在两个失败账本中:`stop()` 仍会 resolve 并通过 `onError` 上报,但 recovery 账本会与统一的 `lastFailure` 记录保持同一次 stop 失败,直到显式 `start()` 同时重置两者。
12
+
13
+
14
+ ## 0.20.91 已完成范围
15
+
16
+ 可靠性开发线继续补强错误路径覆盖并维护发布门禁。IndexedDB replay 清理现在覆盖 `clear()`、`clearTopic()`、`clearBefore()` 的事务级错误,包括连接失效后的恢复,以及浏览器未提供 transaction error 对象时的领域级 fallback 拒绝信息。WebSocket transport 也会在连接失活后 best-effort 关闭 socket,避免自动恢复遗留死连接;已发布包验证的正常路径经审计确认没有固定等待或多余 registry 往返。Centrifuge token bridge provider 现在绑定到创建它的 client lifecycle,已被替换的 client 无法把迟到凭证请求送入新会话。旧 client 的 subscription 回调与 publish rejection 也已有回归约束,不能修改或上报到替代会话。
17
+ DataBus 生命周期审计现在还固定了 failed-reopen 的 stop gate 复用、被取代 initial open 的迟到 rejection 隔离、已退休 transport 迟到的 message/status/error 回调 generation 隔离,以及就绪/恢复契约(`stop()` 后迟到的 `pageshow` 不得重启后台工作、自动恢复再次失败必须通过 `ready()` 暴露、被取消的排队启动不得满足替代重启、排队重启在可用前被挂起必须让 `ready()` reject、旧 recovery timer 不得重开 transport)。本轮审计还发现并修复了一处真实定时器泄漏:`WorkerClusterRuntime.pause()` 在无 channel 时不再排定延迟 `channel.close()`。mutation check 已确认移除对应守卫时每条回归都会失败。本阶段还覆盖 failed-open 后 stop gate 复用、pagehide stop rejection 恢复、replay handler 分发隔离,以及 in-flight recovery reopen 复用;重开守卫已有 mutation 覆盖。
18
+
19
+ 排队 transport 操作审计还固定了:在 initial open 尚未完成时排队的 `subscribe()`,其 rejection 必须恰好通过 `onError` 上报一次且不得成为 unhandled rejection;start gate 放行前不得触碰仍在 opening 的 transport。
20
+ DataBus 生命周期审计还固定了 initial `transport.start()` 被 `stop()` 取代后、teardown 等待期间才 reject 的 rejection ownership:原 `start()` 调用方看到自己的失败,`stop()` 仍成功,transport 恰好关闭一次,旧失败不会进入新 lifecycle 的 error ledger。
21
+ 跨 Tab `EVENT` 边界现在会拒绝不是对象、或缺少字符串 `topic` 的 publication payload,因此一条畸形的同源帧不会再从 BroadcastChannel 监听器抛出并破坏后续投递。未知事件类型保持前向兼容;旧版 payload 会继承帧级 `originTabId`,payload 自带归属优先,两项行为均有回归固定。
4
22
 
5
23
  ## 0.20.90 已完成范围
6
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cross-tab-worker-databus",
3
- "version": "0.20.90",
3
+ "version": "0.20.92",
4
4
  "description": "Framework-agnostic cross-tab data bus with Dedicated/Shared Worker clustering and Centrifuge support.",
5
5
  "type": "module",
6
6
  "license": "MIT",