cross-tab-worker-databus 0.20.86 → 0.20.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.20.88] - 2026-09-16
4
+
5
+ ### Fixed
6
+ - A transport that reports `error` synchronously during startup now lets `start()` retry from either the startup-failure `onStatus` callback or the startup-failure `onError` callback. The internal status is updated immediately, but the user-facing `onStatus('error')` notification is deferred until the failed open has cleared `transportReady`, torn down the initial cluster, installed the transport stop gate, recorded the failure, and cleared `startPromise`. The retry therefore opens a fresh lifecycle after cleanup and resets the failure ledger; the superseded opening's rejection is consumed by that lifecycle path and cannot repopulate the ledger after the retry succeeds.
7
+ - A synchronous `start()` retry issued from the startup-failure `onError` callback now opens a fresh lifecycle instead of returning the same rejecting open. `openTransport()` previously notified error handlers before clearing `startPromise`, so the retry shared the failed promise and never called `transport.start()` again. The failed open is now fully torn down and its lifecycle gate cleared before failure notification, and the retry is chained after the failed transport's stop cleanup.
8
+ - A bus whose initial transport open is still pending now recovers from repeated BFCache `pagehide`/`pageshow` cycles. Each suspend used to return early whenever a `pendingStop` gate existed, but a queued resume opening could already sit behind an older stop gate, so `startPromise` and `pendingStop` stopped being the same promise; the next `pageshow` then reused that superseded opening and the bus stayed suspended forever. `suspendTransport()` now reuses the gate only when it still represents this suspend, and otherwise chains a fresh serial `transport.stop()` that restores the `startPromise === pendingStop` invariant, so the following reopen actually restarts the transport.
9
+ - An explicit `start()` that resumes a BFCache-suspended bus now also resumes cross-tab coordination. `pagehide` pauses the bus *and* the `WorkerClusterRuntime` (closing the channel, stopping the heartbeat, releasing route assignments), but the started `start()` fast path only cleared `suspended` and reopened the transport, leaving `cluster.suspended` true. The bus then reported a healthy transport while every publication arriving from a peer tab was discarded by `isAssigned()` against the cleared assignment map, until a later `pageshow` happened to fire. `start()` now resumes the cluster whenever it is taking the bus out of suspension, so an explicit resume restores the full coordination plane.
10
+ - An explicit `start()` that resumes a BFCache-suspended bus now restarts every resource paused by `pagehide`, not only the transport and cluster. Trace metrics, the dedup expiry sweep, and replay retention cleanup were stopped by `onSuspend()`, while the native `pageshow` path restarted them through `onResume()`; the explicit resume bypassed that callback and could leave all three timers permanently off for the rest of a `healthy` session. Both resume paths now share one lifecycle helper.
11
+
12
+ ## [0.20.87] - 2026-09-16
13
+
14
+ ### Fixed
15
+ - Transport operations issued after a runtime `error` no longer reach the connection that just failed. While an automatic or demand-driven reopen is pending, `runTransport()` parks `subscribe()` / `publish()` behind a recovery gate and releases them only after a reopen succeeds (or the transport self-heals to `connected`). `transportReady` deliberately stays true through the error so `ready()` keeps tracking the installed transport, which meant a subscribe or publish inside the cooldown window was written to the dead connection and lost. A failed automatic attempt keeps the gate closed but now lets the next explicit operation start an immediate on-demand reopen instead of waiting out another cooldown, and parked operations flush behind that success; exhausting `recovery.maxAttempts`, or superseding the wait with `stop()` / page hide, releases the gate so the explicit-retry path and the documented suspend-drop semantics are unchanged. A clean `disconnected` still never schedules automatic recovery.
16
+ - An operation issued after a clean transport `disconnected` now reopens the transport on demand instead of being written to the closed connection. `runTransport()` previously exempted only `error`, so a `subscribe()` / `publish()` arriving after a clean `close` took the ready fast path and the backend dropped it (the WebSocket send guard reports dropped frames but never sends them). The fast path is now refused once the transport has actually reached `connected` and then reports `disconnected`: the clean close still schedules no background recovery, but the next explicit operation demands exactly one reopen and flushes the parked operation behind it. A transport that resolves `start()` before its first `connected` (worker-style backends report the connection asynchronously) keeps the previous behaviour, so the pre-connect window does not trigger a redundant reopen.
17
+ - `createWebSocketDataBus()` automatic recovery now actually reopens a failed WebSocket. `WebSocketTransport.start()` previously returned whenever `this.socket` was non-null, and an error/close left that reference in place, so every post-cooldown `reopenTransport()` was a no-op against the dead socket. The transport now tracks whether the current socket is active, replaces the stale connection on the next `start()`, re-sends subscriptions after the replacement opens, and ignores late callbacks from the superseded socket. An error followed by close also keeps the `error` status that schedules recovery.
18
+ - React and Vue `useCrossTabHealth` bindings now apply `intervalMs` changes without recreating the bus. React previously ignored an option change unless the bus identity changed, and the Vue composable ignored changes to a reactive options object; switching to `0` therefore left polling active, while changing a positive cadence kept the old timer. Both adapters now tear down the old listener/timer set and install the new cadence.
19
+ - `stop()` now resolves once the bus is torn down even when the transport's own `stop()` rejects or throws. The failure is routed through the same `onError` / unified `lastFailure` channel that page-hide suspension and open-failure cleanup already use, so the fire-and-forget `void bus.stop()` teardown in the React and Vue adapters can no longer surface as an unhandled rejection. The instance stays restartable either way.
20
+ - A failed transport open is now stamped once: `getRecoveryStats().errorAt` and the `lastFailure.at` of the same failure are equal instead of differing by a clock re-read. The open-failure path called the injected clock twice for one failure, so a consumer correlating the two ledgers (or a test using an advancing clock) saw two timestamps for one event. The open-failure path also clears `transportReady` before notifying status/error handlers, keeping the "transport is not accepting operations" verdict local to the failure block.
21
+ - `ready()` no longer reports a BFCache-suspended bus as ready. After `pagehide`, `suspendTransport()` chains `transport.stop()` and reuses `startPromise` as the stop gate; `ready()` previously returned that gate, so it resolved the moment cleanup finished even though the transport was intentionally stopped and publications were dropped. Readiness now rejects with a clear suspended-state error while hidden; `pageshow` (or an explicit `start()`) clears the flag and installs a real reopen promise, after which `ready()` resolves as before. This restores the documented invariant that `ready()` never resolves for a transport that cannot carry data.
22
+ - A runtime transport failure reported through `onError` now lands in the transport recovery ledger, not only in the unified `lastFailure` record. `getRecoveryStats().hasError` / `errorMessage` / `errorAt` were written exclusively by the transport-*open* failure path, so a failure raised after a successful open produced a self-contradicting health snapshot: `state: 'recovering'` and a retained `lastFailure` alongside `recovery.hasError: false, errorMessage: null`. The recovery ledger now tracks every transport-sourced failure (open or runtime) and still keeps non-transport failures (`persistence`, `dispatch`) out of it, where they remain visible through `lastFailure` and `getPersistenceStats()`.
23
+ - The native WebSocket backend now honors the `DataBusTransport.start()` contract: it resolves only after the socket `open` and rejects when the handshake errors, closes before opening, or exceeds the new `connectTimeoutMs` (default 30s). Previously `start()` returned while the socket was still `CONNECTING`, so `await bus.ready()` resolved before the connection was usable and an immediately following `publish()` was dropped by the not-open guard. A timed-out attempt now closes its half-open socket and ignores a late `open`; an in-place re-open after a successful handshake still re-asserts subscriptions.
24
+ - `getHealthSummary()` now treats a live `connected` transport as healthy even during the brief window before `start()` settles and `transportReady` flips to true. With a handshake-gated WebSocket start, the `connected` status event fires while `transportReady` is still false, so an event-driven `useCrossTabHealth` snapshot with `intervalMs: 0` could remain stuck in `starting`/`recovering`. `transportReady` remains a diagnostic field; operations are queued behind the in-flight start rather than dropped.
25
+
26
+
1
27
  ## [0.20.86] - 2026-09-16
2
28
 
3
29
  ### Added
@@ -5,7 +5,7 @@ import {
5
5
  parseDataBusPublication,
6
6
  publicationMetadata,
7
7
  selectWorkerBackend
8
- } from "./chunk-SDOV3UHG.js";
8
+ } from "./chunk-4UOLOWOD.js";
9
9
  import {
10
10
  CENTRIFUGE_INPUT_TYPE,
11
11
  CENTRIFUGE_OUTPUT_TYPE,
@@ -2203,7 +2203,7 @@ var DedupManager = class {
2203
2203
  };
2204
2204
 
2205
2205
  // src/core/version.ts
2206
- var SDK_VERSION = true ? "0.20.86" : "";
2206
+ var SDK_VERSION = true ? "0.20.88" : "";
2207
2207
 
2208
2208
  // src/core/data-bus.ts
2209
2209
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2228,6 +2228,11 @@ var CrossTabDataBus = class {
2228
2228
  started = false;
2229
2229
  stopping = false;
2230
2230
  transportReady = false;
2231
+ // Whether the installed transport has reported `connected` at least once
2232
+ // since the current open began. A clean `disconnected` after this point is
2233
+ // a lost working connection, not the pre-connect window of a worker-style
2234
+ // backend whose start() resolves before it reports the connection.
2235
+ transportHasConnected = false;
2231
2236
  // Last transport failure, retained so ready() can surface it to callers who
2232
2237
  // never awaited start() directly. Cleared on the next successful start.
2233
2238
  lastError = null;
@@ -2266,6 +2271,19 @@ var CrossTabDataBus = class {
2266
2271
  // a transport reopen succeeds so traces can correlate repeated failures.
2267
2272
  recoveryAttempt = 0;
2268
2273
  recoveryExhausted = false;
2274
+ // Gate that holds transport operations issued after a runtime `error` until
2275
+ // the scheduled recovery attempt has actually run. Without it, a dead
2276
+ // transport still has `transportReady === true` during the cooldown, so
2277
+ // publishes/subscribes would be written to the failed connection and lost.
2278
+ recoveryGate = null;
2279
+ recoveryGateRelease = null;
2280
+ recoveryTimer = null;
2281
+ recoveryTimerToken = 0;
2282
+ // Once an automatic attempt fails, an explicit transport operation may
2283
+ // recover immediately instead of waiting for the next paced attempt. The
2284
+ // gate still stays closed so the operation cannot reach the failed
2285
+ // transport; it is released by the successful on-demand reopen.
2286
+ recoveryDemandAllowed = false;
2269
2287
  /** Monotonic generation incremented on every successful transport open.
2270
2288
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2271
2289
  * transport has been reopened even if the timestamp window is short. */
@@ -2379,10 +2397,7 @@ var CrossTabDataBus = class {
2379
2397
  this.suspendTransport();
2380
2398
  },
2381
2399
  onResume: () => {
2382
- this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
2383
- this.trace.start();
2384
- this.startDedupSweep();
2385
- this.replayManager.start();
2400
+ this.resumeSuspendedResources();
2386
2401
  this.resumeTransport();
2387
2402
  },
2388
2403
  onDiagnostic: (event) => {
@@ -2412,7 +2427,11 @@ var CrossTabDataBus = class {
2412
2427
  if (!transportDown) return Promise.resolve();
2413
2428
  this.activeConfig = config;
2414
2429
  this.resetFailureState();
2415
- return this.reopenTransport();
2430
+ const resumingFromSuspend = this.suspended;
2431
+ if (resumingFromSuspend) this.resumeSuspendedResources();
2432
+ const opening2 = this.reopenTransport();
2433
+ if (resumingFromSuspend) this.cluster.start();
2434
+ return opening2;
2416
2435
  }
2417
2436
  this.started = true;
2418
2437
  this.stopping = false;
@@ -2484,8 +2503,38 @@ var CrossTabDataBus = class {
2484
2503
  this.queuedStart = queued;
2485
2504
  return queued;
2486
2505
  }
2506
+ /** Release every operation waiting on the scheduled recovery attempt. */
2507
+ releaseRecoveryGate() {
2508
+ const release = this.recoveryGateRelease;
2509
+ this.recoveryGate = null;
2510
+ this.recoveryGateRelease = null;
2511
+ this.recoveryDemandAllowed = false;
2512
+ release?.();
2513
+ }
2514
+ /** Cancel a pending automatic retry when an explicit lifecycle transition
2515
+ * supersedes it. The released gate re-enters runTransport(), which then
2516
+ * follows the newest start/stop/suspend intent. */
2517
+ cancelScheduledRecovery() {
2518
+ this.recoveryTimerToken += 1;
2519
+ if (this.recoveryTimer !== null) {
2520
+ clearTimeout(this.recoveryTimer);
2521
+ this.recoveryTimer = null;
2522
+ }
2523
+ this.releaseRecoveryGate();
2524
+ }
2525
+ /** Keep the recovery gate closed after a failed attempt while allowing the
2526
+ * next explicit transport operation to start an immediate on-demand reopen.
2527
+ * If no gate/successor retry remains, release any waiters. */
2528
+ allowDemandRecovery() {
2529
+ if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
2530
+ this.recoveryDemandAllowed = true;
2531
+ return;
2532
+ }
2533
+ this.releaseRecoveryGate();
2534
+ }
2487
2535
  /** Reset failure and recovery diagnostics for a new explicit start session. */
2488
2536
  resetFailureState() {
2537
+ this.cancelScheduledRecovery();
2489
2538
  this.lastError = null;
2490
2539
  this.lastErrorAt = null;
2491
2540
  this.lastFailure = null;
@@ -2505,22 +2554,27 @@ var CrossTabDataBus = class {
2505
2554
  this.transportReady = false;
2506
2555
  const chainedPendingStop = this.pendingStop;
2507
2556
  const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
2557
+ let startupInProgress = true;
2508
2558
  return before.catch(() => void 0).then(() => {
2509
2559
  if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
2510
2560
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
2561
+ this.transportHasConnected = false;
2511
2562
  return Promise.resolve(
2512
2563
  this.transport.start(config, {
2513
2564
  onMessage: (message) => {
2514
2565
  if (isCurrentLifecycle()) this.handleTransportMessage(message);
2515
2566
  },
2516
2567
  onStatus: (status) => {
2517
- if (isCurrentLifecycle()) this.updateStatus(status);
2568
+ if (isCurrentLifecycle()) {
2569
+ this.updateStatus(status, status !== WORKER_STATUS.ERROR || !startupInProgress);
2570
+ }
2518
2571
  },
2519
2572
  onError: (error) => {
2520
2573
  if (isCurrentLifecycle()) this.reportError(error);
2521
2574
  }
2522
2575
  })
2523
2576
  ).then(() => {
2577
+ startupInProgress = false;
2524
2578
  if (!isCurrentLifecycle()) return;
2525
2579
  if (this.status === WORKER_STATUS.ERROR) {
2526
2580
  throw new Error("Transport failed during startup.");
@@ -2529,24 +2583,26 @@ var CrossTabDataBus = class {
2529
2583
  this.recoveryGeneration += 1;
2530
2584
  this.lastSuccessAt = this.now();
2531
2585
  this.transportReady = true;
2586
+ this.releaseRecoveryGate();
2532
2587
  }
2533
2588
  });
2534
2589
  }).catch((error) => {
2535
2590
  if (!isCurrentLifecycle()) throw error;
2591
+ startupInProgress = false;
2536
2592
  if (stopClusterOnFailure) this.started = false;
2537
2593
  if (!this.pendingStop) {
2538
2594
  this.pendingStop = this.createStopPromise();
2539
2595
  }
2540
- this.updateStatus(WORKER_STATUS.ERROR);
2541
- this.reportError(error);
2542
- this.lastError = error;
2543
- this.lastErrorAt = this.now();
2544
2596
  this.transportReady = false;
2545
2597
  if (stopClusterOnFailure) {
2546
2598
  this.stopping = true;
2547
2599
  this.cluster.stop();
2548
2600
  this.stopping = false;
2549
2601
  }
2602
+ this.recordError(error);
2603
+ this.startPromise = null;
2604
+ this.updateStatus(WORKER_STATUS.ERROR);
2605
+ this.notifyError(error);
2550
2606
  throw error;
2551
2607
  });
2552
2608
  }
@@ -2555,7 +2611,10 @@ var CrossTabDataBus = class {
2555
2611
  * Returns a rejected promise when the transport has failed and no start is in
2556
2612
  * flight — the caller can retry by calling start() or ready() again. While an
2557
2613
  * explicit stop() is settling, this rejects unless a restart is queued behind
2558
- * it; false readiness during teardown is never reported.
2614
+ * it; false readiness during teardown is never reported. While the tab is
2615
+ * BFCache-suspended (pagehide without a following pageshow), this also
2616
+ * rejects: the suspended start promise is the transport-stop gate, not a
2617
+ * readiness signal.
2559
2618
  */
2560
2619
  ready() {
2561
2620
  if (this.queuedStart) return this.getQueuedStartReady();
@@ -2564,6 +2623,11 @@ var CrossTabDataBus = class {
2564
2623
  "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2565
2624
  ));
2566
2625
  }
2626
+ if (this.suspended) {
2627
+ return Promise.reject(new Error(
2628
+ "CrossTabDataBus is suspended; ready() cannot report readiness until pageshow resumes the transport."
2629
+ ));
2630
+ }
2567
2631
  if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2568
2632
  return Promise.reject(this.lastError);
2569
2633
  }
@@ -2699,11 +2763,15 @@ var CrossTabDataBus = class {
2699
2763
  getStatus() {
2700
2764
  return this.status;
2701
2765
  }
2702
- /** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
2703
2766
  /** Return the current automatic transport recovery state plus diagnostics.
2704
- * `generation` increments on every successful transport open (initial start
2705
- * and every recovery); `lastSuccessAt` is the timestamp of the most recent
2706
- * successful open, or `null` until the transport reaches `ready`. */
2767
+ * `hasError`/`errorMessage`/`errorAt` describe the most recent retained
2768
+ * *transport* failure from a transport open or a runtime `onError`. They
2769
+ * share the lifetime of the unified `lastFailure` ledger: a successful
2770
+ * recovery keeps the last failure visible, and only an explicit `start()`
2771
+ * clears it. `generation` increments on every successful transport open
2772
+ * (initial start and every recovery); `lastSuccessAt` is the timestamp of
2773
+ * the most recent successful open, or `null` until the transport reaches
2774
+ * `ready`. */
2707
2775
  getRecoveryStats() {
2708
2776
  const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
2709
2777
  return {
@@ -2730,7 +2798,7 @@ var CrossTabDataBus = class {
2730
2798
  * unified failure ledger and recovery context that explains the verdict. */
2731
2799
  getHealthSummary() {
2732
2800
  const transport = this.transport;
2733
- const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2801
+ const transportDown = this.status !== WORKER_STATUS.CONNECTED;
2734
2802
  const state = !this.started ? HEALTH_STATE.STOPPED : this.suspended ? HEALTH_STATE.SUSPENDED : transportDown ? this.recoveryExhausted ? HEALTH_STATE.DEGRADED : this.status === WORKER_STATUS.CONNECTING && this.recoveryAttempt === 0 ? HEALTH_STATE.STARTING : HEALTH_STATE.RECOVERING : HEALTH_STATE.HEALTHY;
2735
2803
  return {
2736
2804
  healthy: state === HEALTH_STATE.HEALTHY,
@@ -2821,6 +2889,7 @@ var CrossTabDataBus = class {
2821
2889
  async performStop() {
2822
2890
  this.lifecycleEpoch += 1;
2823
2891
  this.stopping = true;
2892
+ this.cancelScheduledRecovery();
2824
2893
  this.replayManager.suspend();
2825
2894
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
2826
2895
  this.trace.stop();
@@ -2833,6 +2902,8 @@ var CrossTabDataBus = class {
2833
2902
  const pendingStop = this.pendingStop;
2834
2903
  if (pendingStop) await pendingStop.catch(() => void 0);
2835
2904
  else await this.transport.stop();
2905
+ } catch (error) {
2906
+ this.reportError(error);
2836
2907
  } finally {
2837
2908
  this.transportSubscribedTopics.clear();
2838
2909
  this.resetDedup();
@@ -2895,13 +2966,15 @@ var CrossTabDataBus = class {
2895
2966
  * Propagate a status change to the cluster, trace, and all registered
2896
2967
  * status handlers. On reconnect, re-subscribe any topics assigned to us.
2897
2968
  */
2898
- updateStatus(status) {
2969
+ updateStatus(status, notifyHandlers = true) {
2899
2970
  const previousStatus = this.status;
2900
2971
  this.status = status;
2972
+ if (status === WORKER_STATUS.CONNECTED) this.transportHasConnected = true;
2901
2973
  if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
2902
2974
  this.cluster.setStatus(status);
2903
2975
  if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
2904
2976
  if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
2977
+ if (this.transportReady) this.releaseRecoveryGate();
2905
2978
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
2906
2979
  }
2907
2980
  if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
@@ -2914,23 +2987,49 @@ var CrossTabDataBus = class {
2914
2987
  this.recoveryExhausted = true;
2915
2988
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
2916
2989
  }
2990
+ this.releaseRecoveryGate();
2917
2991
  return;
2918
2992
  }
2919
2993
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
2920
- setTimeout(() => {
2921
- if (this.stopping || !this.started || this.suspended) return;
2922
- if (this.status !== WORKER_STATUS.ERROR) return;
2923
- void this.reopenTransport(attempt);
2994
+ if (this.recoveryGate === null) {
2995
+ let release;
2996
+ this.recoveryGate = new Promise((resolve) => {
2997
+ release = resolve;
2998
+ });
2999
+ this.recoveryGateRelease = release;
3000
+ }
3001
+ this.recoveryDemandAllowed = false;
3002
+ const timerToken = ++this.recoveryTimerToken;
3003
+ this.recoveryTimer = setTimeout(() => {
3004
+ if (timerToken !== this.recoveryTimerToken) return;
3005
+ this.recoveryTimer = null;
3006
+ if (this.stopping || !this.started || this.suspended || this.status !== WORKER_STATUS.ERROR) {
3007
+ this.releaseRecoveryGate();
3008
+ return;
3009
+ }
3010
+ this.recoveryDemandAllowed = false;
3011
+ const opening = this.reopenTransport(attempt);
3012
+ void opening.then(
3013
+ () => this.releaseRecoveryGate(),
3014
+ () => this.allowDemandRecovery()
3015
+ );
2924
3016
  }, this.recoveryCooldownMs);
2925
3017
  }
3018
+ } else if (status === WORKER_STATUS.ERROR) {
3019
+ this.releaseRecoveryGate();
2926
3020
  }
2927
- this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
3021
+ if (notifyHandlers) this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
2928
3022
  }
2929
- reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
3023
+ recordError(error, source = FAILURE_SOURCE.TRANSPORT) {
3024
+ const at = this.now();
3025
+ if (source === FAILURE_SOURCE.TRANSPORT) {
3026
+ this.lastError = error;
3027
+ this.lastErrorAt = at;
3028
+ }
2930
3029
  this.lastFailure = {
2931
3030
  source,
2932
3031
  message: error instanceof Error ? error.message : String(error),
2933
- at: this.now()
3032
+ at
2934
3033
  };
2935
3034
  if (source === FAILURE_SOURCE.PERSISTENCE) {
2936
3035
  this.persistenceFailureCount += 1;
@@ -2941,8 +3040,14 @@ var CrossTabDataBus = class {
2941
3040
  type: TRACE_EVENT_TYPE.ERROR,
2942
3041
  source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
2943
3042
  });
3043
+ }
3044
+ notifyError(error) {
2944
3045
  this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
2945
3046
  }
3047
+ reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
3048
+ this.recordError(error, source);
3049
+ this.notifyError(error);
3050
+ }
2946
3051
  /** Report a persistence failure to the trace and the unified failure ledger,
2947
3052
  * unless it is a {@link PersistenceRetryCancelledError} cancellation from a
2948
3053
  * lifecycle transition (teardown should stay quiet). */
@@ -3005,6 +3110,15 @@ var CrossTabDataBus = class {
3005
3110
  }
3006
3111
  }
3007
3112
  }
3113
+ /** Resume the resources paused by a pagehide suspension. Both the native
3114
+ * pageshow path and explicit start() must run this so an explicit resume
3115
+ * cannot leave trace metrics and periodic cleanup timers permanently off. */
3116
+ resumeSuspendedResources() {
3117
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
3118
+ this.trace.start();
3119
+ this.startDedupSweep();
3120
+ this.replayManager.start();
3121
+ }
3008
3122
  /**
3009
3123
  * Suspend the transport when the tab goes hidden. Stops the transport and
3010
3124
  * clears subscription state so it will be re-established on resume.
@@ -3013,11 +3127,15 @@ var CrossTabDataBus = class {
3013
3127
  if (this.stopping) return;
3014
3128
  this.lifecycleEpoch += 1;
3015
3129
  this.suspended = true;
3130
+ this.cancelScheduledRecovery();
3016
3131
  this.transportReady = false;
3017
3132
  this.transportSubscribedTopics.clear();
3018
3133
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
3019
- if (this.pendingStop) return;
3020
- const pending = this.startPromise ?? Promise.resolve();
3134
+ if (this.pendingStop && (this.startPromise === null || this.startPromise === this.pendingStop)) {
3135
+ this.startPromise = this.pendingStop;
3136
+ return;
3137
+ }
3138
+ const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3021
3139
  const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
3022
3140
  this.startPromise = stopping;
3023
3141
  this.pendingStop = stopping;
@@ -3082,7 +3200,24 @@ var CrossTabDataBus = class {
3082
3200
  */
3083
3201
  runTransport(operation) {
3084
3202
  if (this.suspended) return;
3085
- if (this.transportReady && !this.stopping) {
3203
+ if (this.recoveryGate && !this.stopping) {
3204
+ if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
3205
+ this.recoveryDemandAllowed = false;
3206
+ const opening = this.reopenTransport();
3207
+ void opening.then(
3208
+ () => this.releaseRecoveryGate(),
3209
+ () => this.allowDemandRecovery()
3210
+ );
3211
+ }
3212
+ const gate = this.recoveryGate;
3213
+ void gate.then(() => {
3214
+ if (this.stopping || this.suspended) return;
3215
+ this.runTransport(operation);
3216
+ });
3217
+ return;
3218
+ }
3219
+ const droppedAfterConnect = this.transportHasConnected && this.status === WORKER_STATUS.DISCONNECTED;
3220
+ if (this.transportReady && this.status !== WORKER_STATUS.ERROR && !droppedAfterConnect && !this.stopping) {
3086
3221
  try {
3087
3222
  void Promise.resolve(operation()).catch((error) => this.reportError(error));
3088
3223
  } catch (error) {
@@ -3095,10 +3230,17 @@ var CrossTabDataBus = class {
3095
3230
  ready = this.reopenTransport();
3096
3231
  }
3097
3232
  if (!ready || this.stopping) return;
3098
- void ready.then(() => {
3099
- if (!this.started || this.stopping || this.suspended) return;
3100
- return operation();
3101
- }).catch((error) => this.reportError(error));
3233
+ void ready.then(
3234
+ () => {
3235
+ if (!this.started || this.stopping || this.suspended) return;
3236
+ return operation();
3237
+ },
3238
+ // The opening promise reports its own lifecycle failure through
3239
+ // openTransport(). Swallowing it here prevents a stale startup
3240
+ // rejection from being recorded again after an onStatus/onError
3241
+ // callback has already started and reset the ledger for a retry.
3242
+ () => void 0
3243
+ ).catch((error) => this.reportError(error));
3102
3244
  }
3103
3245
  /**
3104
3246
  * Publications started after teardown begins cannot reach any transport.
@@ -3193,4 +3335,4 @@ export {
3193
3335
  parseDataBusPublication,
3194
3336
  selectWorkerBackend
3195
3337
  };
3196
- //# sourceMappingURL=chunk-SDOV3UHG.js.map
3338
+ //# sourceMappingURL=chunk-4UOLOWOD.js.map