cross-tab-worker-databus 0.20.91 → 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.
@@ -612,6 +612,9 @@ var WorkerClusterRuntime = class {
612
612
  started = false;
613
613
  suspended = false;
614
614
  lifecycleListening = false;
615
+ /** Invalidates an in-flight pageshow resume when a synchronous onResume
616
+ * callback stops or pauses the cluster before activate() is reached. */
617
+ lifecycleGeneration = 0;
615
618
  currentRecord;
616
619
  constructor(options) {
617
620
  assertClusterOptions(options);
@@ -648,8 +651,14 @@ var WorkerClusterRuntime = class {
648
651
  /** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
649
652
  start() {
650
653
  if (this.started) return;
651
- this.suspended = false;
654
+ this.lifecycleGeneration += 1;
652
655
  this.addLifecycleListeners();
656
+ if (this.environment.getVisibilityState() === TAB_VISIBILITY.HIDDEN) {
657
+ this.suspended = true;
658
+ this.handlers.onSuspend?.();
659
+ return;
660
+ }
661
+ this.suspended = false;
653
662
  this.activate();
654
663
  }
655
664
  /**
@@ -660,6 +669,7 @@ var WorkerClusterRuntime = class {
660
669
  * stop() path where callers expect every Set/Map to be empty afterwards.
661
670
  */
662
671
  stop() {
672
+ this.lifecycleGeneration += 1;
663
673
  if (!this.started && !this.suspended) return;
664
674
  this.pause();
665
675
  this.flushStorage();
@@ -706,7 +716,11 @@ var WorkerClusterRuntime = class {
706
716
  * to other workers, remove our worker record, and close the channel.
707
717
  */
708
718
  pause() {
709
- if (!this.started) return;
719
+ this.lifecycleGeneration += 1;
720
+ if (!this.started) {
721
+ if (this.lifecycleListening) this.suspended = true;
722
+ return;
723
+ }
710
724
  this.started = false;
711
725
  this.suspended = true;
712
726
  if (this.heartbeatHandle !== null) this.environment.clearInterval(this.heartbeatHandle);
@@ -1021,8 +1035,10 @@ var WorkerClusterRuntime = class {
1021
1035
  handlePageHide = () => this.pause();
1022
1036
  handlePageShow = () => {
1023
1037
  if (!this.suspended) return;
1038
+ const generation = ++this.lifecycleGeneration;
1024
1039
  this.suspended = false;
1025
1040
  this.handlers.onResume?.();
1041
+ if (generation !== this.lifecycleGeneration) return;
1026
1042
  this.activate();
1027
1043
  };
1028
1044
  handleVisibilityChange = () => {
@@ -1077,10 +1093,16 @@ var WorkerClusterRuntime = class {
1077
1093
  if (message.targetWorkerId !== this.workerId) return;
1078
1094
  this.rememberTopic(message.topic);
1079
1095
  switch (message.action) {
1080
- case CONTROL_ACTION.SUBSCRIBE:
1096
+ case CONTROL_ACTION.SUBSCRIBE: {
1097
+ const route = this.readRoute(message.topicKey);
1098
+ if (route && route.workerId !== this.workerId) return;
1099
+ if (route?.workerId === this.workerId && route.handoffFromWorkerId !== void 0 && route.confirmedAt === void 0) {
1100
+ return;
1101
+ }
1081
1102
  this.assignedTopics.set(message.topicKey, message.topic);
1082
1103
  this.confirmRoute(message.topicKey);
1083
1104
  break;
1105
+ }
1084
1106
  case CONTROL_ACTION.UNSUBSCRIBE:
1085
1107
  if (this.releaseHandoffOnUnsubscribe(message)) return;
1086
1108
  break;
@@ -1141,8 +1163,8 @@ var WorkerClusterRuntime = class {
1141
1163
  }
1142
1164
  /**
1143
1165
  * Accept a graceful handoff only when the route still points to this worker,
1144
- * the release comes from the recorded previous owner, and the generation is
1145
- * at least as new as ours. Any other ROUTE_RELEASED is stale and dropped.
1166
+ * the release comes from the recorded previous owner, and the generation
1167
+ * exactly matches ours. Any other ROUTE_RELEASED is stale and dropped.
1146
1168
  */
1147
1169
  handleRouteReleasedMessage(message) {
1148
1170
  if (message.targetWorkerId !== this.workerId) return;
@@ -1155,11 +1177,11 @@ var WorkerClusterRuntime = class {
1155
1177
  }
1156
1178
  /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
1157
1179
  * points to us, the release comes from the recorded previous owner, and
1158
- * the release generation is at least as new as ours. A replayed ACK from an
1159
- * earlier handoff round (e.g. an a↔b ping-pong) carries an older generation
1160
- * and must not confirm the current round. */
1180
+ * the release generation exactly matches ours. A delayed ACK from either an
1181
+ * earlier or later handoff round belongs to a different route and must not
1182
+ * confirm the current round. */
1161
1183
  isStaleRouteRelease(route, message) {
1162
- return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation < route.generation;
1184
+ return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation !== route.generation;
1163
1185
  }
1164
1186
  /** True when an unconfirmed handoff route has been stuck longer than a
1165
1187
  * worker TTL. The ACK for a live handoff is posted synchronously with the
@@ -1786,7 +1808,7 @@ var ReplayManager = class {
1786
1808
  this.trace = deps.trace;
1787
1809
  this.onPersistenceError = deps.onPersistenceError;
1788
1810
  this.onDispatchError = deps.onDispatchError;
1789
- this.hydration = this.hydrate();
1811
+ void this.requestHydration();
1790
1812
  }
1791
1813
  deps;
1792
1814
  buffers;
@@ -1805,7 +1827,18 @@ var ReplayManager = class {
1805
1827
  retryGeneration = 0;
1806
1828
  pendingReplayPersistence = [];
1807
1829
  persistenceFlushScheduled = false;
1808
- hydration;
1830
+ /** Current hydration operation, if one belongs to the active lifecycle. */
1831
+ hydration = null;
1832
+ /** Invalidates a load from a superseded suspend/reset lifecycle. */
1833
+ hydrationEpoch = 0;
1834
+ /** Whether the active lifecycle has finished (or deliberately skipped) hydration. */
1835
+ hydrationComplete = false;
1836
+ /** A failed load is retried by the next explicit start(), not every replay request. */
1837
+ hydrationFailed = false;
1838
+ /** Mutations that must be applied to a load already in flight. */
1839
+ hydrationClearAll = false;
1840
+ hydrationClearedTopics = /* @__PURE__ */ new Set();
1841
+ hydrationClearBefore = null;
1809
1842
  /** Coalesced retention cleanup: the newest cutoff wins while one is running. */
1810
1843
  retentionCleanup = null;
1811
1844
  retentionCutoff = null;
@@ -1859,7 +1892,7 @@ var ReplayManager = class {
1859
1892
  if (!this.buffers) return;
1860
1893
  const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
1861
1894
  if (this.persistence) {
1862
- void this.hydration.then(() => {
1895
+ void this.requestHydration().then(() => {
1863
1896
  if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
1864
1897
  });
1865
1898
  return;
@@ -1871,22 +1904,24 @@ var ReplayManager = class {
1871
1904
  * clearTopic), and prune durable history. */
1872
1905
  onTopicUnsubscribed(topic) {
1873
1906
  if (!this.buffers) return;
1907
+ const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
1908
+ this.hydrationClearedTopics.add(topic);
1874
1909
  this.buffers.delete(topic);
1875
1910
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1876
- if (this.persistence?.clearTopic) {
1877
- void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
1878
- }
1911
+ if (clearing) void clearing.catch((error) => this.onPersistenceError(error));
1879
1912
  }
1880
1913
  /** Clear all in-memory replay buffers and, when supported, durable history.
1881
1914
  * Reports persistence failures and rethrows, mirroring the public API
1882
1915
  * contract that callers can observe a failed clear. */
1883
1916
  async clearAll() {
1884
1917
  if (!this.buffers) return;
1918
+ const clearing = this.persistence?.clear ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear()) : null;
1919
+ this.hydrationClearAll = true;
1885
1920
  this.buffers.clear();
1886
1921
  this.pendingReplayPersistence = [];
1887
- if (this.persistence?.clear) {
1922
+ if (clearing) {
1888
1923
  try {
1889
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
1924
+ await clearing;
1890
1925
  } catch (error) {
1891
1926
  this.onPersistenceError(error);
1892
1927
  throw error;
@@ -1896,11 +1931,13 @@ var ReplayManager = class {
1896
1931
  /** Clear replay history for one exact topic, including durable storage. */
1897
1932
  async clearTopic(topic) {
1898
1933
  if (!this.buffers) return;
1934
+ const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
1935
+ this.hydrationClearedTopics.add(topic);
1899
1936
  this.buffers.delete(topic);
1900
1937
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1901
- if (this.persistence?.clearTopic) {
1938
+ if (clearing) {
1902
1939
  try {
1903
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
1940
+ await clearing;
1904
1941
  } catch (error) {
1905
1942
  this.onPersistenceError(error);
1906
1943
  throw error;
@@ -1910,7 +1947,9 @@ var ReplayManager = class {
1910
1947
  /** Remove replay entries older than an epoch-millisecond cutoff. */
1911
1948
  async clearBefore(timestamp) {
1912
1949
  if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1950
+ const clearing = this.persistence?.clearBefore ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp)) : null;
1913
1951
  if (this.buffers) {
1952
+ this.hydrationClearBefore = this.hydrationClearBefore === null ? timestamp : Math.max(this.hydrationClearBefore, timestamp);
1914
1953
  for (const [topic, messages] of this.buffers) {
1915
1954
  const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1916
1955
  if (kept.length) this.buffers.set(topic, kept);
@@ -1920,9 +1959,9 @@ var ReplayManager = class {
1920
1959
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
1921
1960
  (message) => message.timestamp === void 0 || message.timestamp >= timestamp
1922
1961
  );
1923
- if (this.persistence?.clearBefore) {
1962
+ if (clearing) {
1924
1963
  try {
1925
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
1964
+ await clearing;
1926
1965
  } catch (error) {
1927
1966
  this.onPersistenceError(error);
1928
1967
  throw error;
@@ -1932,6 +1971,11 @@ var ReplayManager = class {
1932
1971
  /** Start the periodic retention sweep. No-op when no durable retention
1933
1972
  * config makes it necessary. */
1934
1973
  start() {
1974
+ if (this.hydrationFailed) {
1975
+ this.hydrationComplete = false;
1976
+ this.hydrationFailed = false;
1977
+ }
1978
+ void this.requestHydration();
1935
1979
  if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
1936
1980
  this.retentionTimer = setInterval(() => {
1937
1981
  this.scheduleRetentionCleanup(this.now() - this.retentionMs);
@@ -1946,13 +1990,25 @@ var ReplayManager = class {
1946
1990
  * or stopped bus does not keep hammering the store) and stop the sweep. */
1947
1991
  suspend() {
1948
1992
  this.retryGeneration += 1;
1993
+ if (this.hydration) {
1994
+ this.hydrationEpoch += 1;
1995
+ this.hydration = null;
1996
+ this.hydrationComplete = false;
1997
+ }
1949
1998
  this.pendingReplayPersistence = [];
1950
1999
  this.retentionCutoff = null;
1951
2000
  this.stop();
1952
2001
  }
1953
- /** Drop all in-memory buffers (used on full teardown). */
2002
+ /** Drop all in-memory buffers and require hydration for the next lifecycle. */
1954
2003
  resetBuffers() {
1955
2004
  this.buffers?.clear();
2005
+ this.hydrationEpoch += 1;
2006
+ this.hydration = null;
2007
+ this.hydrationComplete = false;
2008
+ this.hydrationFailed = false;
2009
+ this.hydrationClearAll = false;
2010
+ this.hydrationClearedTopics.clear();
2011
+ this.hydrationClearBefore = null;
1956
2012
  }
1957
2013
  /** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
1958
2014
  * payload footprint (same heuristic as adaptive load weighting), computed on
@@ -2004,38 +2060,78 @@ var ReplayManager = class {
2004
2060
  void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
2005
2061
  });
2006
2062
  }
2007
- /** Load durable history into the in-memory rings once at startup, pruning
2063
+ /** Start the active lifecycle's one-shot hydration, if it has not completed
2064
+ * or deliberately skipped hydration already. */
2065
+ requestHydration() {
2066
+ if (!this.buffers || !this.persistence) {
2067
+ this.hydrationComplete = true;
2068
+ return Promise.resolve();
2069
+ }
2070
+ if (this.hydration) return this.hydration;
2071
+ if (this.hydrationComplete) return Promise.resolve();
2072
+ const epoch = this.hydrationEpoch;
2073
+ const generation = this.retryGeneration;
2074
+ const operation = this.hydrate(epoch, generation);
2075
+ this.hydration = operation;
2076
+ return operation;
2077
+ }
2078
+ /** Load durable history into the in-memory rings once per lifecycle, pruning
2008
2079
  * entries past the retention window first. Failures are reported but do not
2009
2080
  * block startup — the bus runs with whatever survived. */
2010
- async hydrate() {
2081
+ async hydrate(epoch, generation) {
2011
2082
  if (!this.buffers || !this.persistence) {
2012
2083
  return;
2013
2084
  }
2014
2085
  try {
2015
- if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2016
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2017
- }
2018
- const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2019
- for (const message of loaded) {
2020
- let buffer = this.buffers.get(message.topic);
2021
- if (!buffer) {
2022
- buffer = [];
2023
- this.buffers.set(message.topic, buffer);
2086
+ try {
2087
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2088
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2089
+ }
2090
+ const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2091
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2092
+ if (epoch !== this.hydrationEpoch) return;
2093
+ const loadedByTopic = /* @__PURE__ */ new Map();
2094
+ for (const message of loaded) {
2095
+ if (this.hydrationClearAll || this.hydrationClearedTopics.has(message.topic) || this.hydrationClearBefore !== null && message.timestamp !== void 0 && message.timestamp < this.hydrationClearBefore) continue;
2096
+ let buffer = loadedByTopic.get(message.topic);
2097
+ if (!buffer) {
2098
+ buffer = [];
2099
+ loadedByTopic.set(message.topic, buffer);
2100
+ }
2101
+ buffer.push(message);
2102
+ }
2103
+ for (const [topic, durableBuffer] of loadedByTopic) {
2104
+ const liveBuffer = this.buffers.get(topic);
2105
+ this.buffers.set(topic, liveBuffer ? [...durableBuffer, ...liveBuffer] : durableBuffer);
2106
+ }
2107
+ const hydrationNow = this.now();
2108
+ for (const [topic, buffer] of this.buffers) {
2109
+ const pruned = pruneReplayHistory(buffer, {
2110
+ maxPerTopic: this.maxPerTopic,
2111
+ pruneStrategy: this.pruneStrategy,
2112
+ retentionMs: this.retentionMs,
2113
+ now: hydrationNow
2114
+ });
2115
+ if (pruned !== buffer) this.buffers.set(topic, pruned);
2116
+ }
2117
+ this.hydrationComplete = true;
2118
+ this.hydrationFailed = false;
2119
+ } catch (error) {
2120
+ if (generation !== this.retryGeneration) {
2121
+ this.onPersistenceError(
2122
+ error instanceof PersistenceRetryCancelledError ? error : new PersistenceRetryCancelledError()
2123
+ );
2124
+ return;
2024
2125
  }
2025
- buffer.push(message);
2126
+ if (epoch !== this.hydrationEpoch) return;
2127
+ this.onPersistenceError(error);
2128
+ this.hydrationComplete = true;
2129
+ this.hydrationFailed = true;
2026
2130
  }
2027
- const hydrationNow = this.now();
2028
- for (const [topic, buffer] of this.buffers) {
2029
- const pruned = pruneReplayHistory(buffer, {
2030
- maxPerTopic: this.maxPerTopic,
2031
- pruneStrategy: this.pruneStrategy,
2032
- retentionMs: this.retentionMs,
2033
- now: hydrationNow
2034
- });
2035
- if (pruned !== buffer) this.buffers.set(topic, pruned);
2131
+ } finally {
2132
+ if (this.hydrationEpoch === epoch && this.retryGeneration === generation) {
2133
+ this.hydration = null;
2036
2134
  }
2037
- } catch (error) {
2038
- this.onPersistenceError(error);
2039
2135
  }
2040
2136
  }
2041
2137
  /** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
@@ -2059,7 +2155,7 @@ var ReplayManager = class {
2059
2155
  }
2060
2156
  })().finally(() => {
2061
2157
  this.retentionCleanup = null;
2062
- if (this.retentionCutoff !== null && generation === this.retryGeneration) {
2158
+ if (this.retentionCutoff !== null) {
2063
2159
  this.scheduleRetentionCleanup(this.retentionCutoff);
2064
2160
  }
2065
2161
  });
@@ -2212,7 +2308,7 @@ var DedupManager = class {
2212
2308
  };
2213
2309
 
2214
2310
  // src/core/version.ts
2215
- var SDK_VERSION = true ? "0.20.91" : "";
2311
+ var SDK_VERSION = true ? "0.20.92" : "";
2216
2312
 
2217
2313
  // src/core/data-bus.ts
2218
2314
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2288,11 +2384,21 @@ var CrossTabDataBus = class {
2288
2384
  recoveryGateRelease = null;
2289
2385
  recoveryTimer = null;
2290
2386
  recoveryTimerToken = 0;
2387
+ // Invalidates operations parked on the recovery gate when a hide/stop
2388
+ // supersedes the recovery cycle. Their microtask may run after an immediate
2389
+ // explicit start has cleared `suspended`, so a state check alone is not
2390
+ // enough to keep stale work from reaching the replacement transport.
2391
+ recoveryCancellationToken = 0;
2291
2392
  // Once an automatic attempt fails, an explicit transport operation may
2292
2393
  // recover immediately instead of waiting for the next paced attempt. The
2293
2394
  // gate still stays closed so the operation cannot reach the failed
2294
2395
  // transport; it is released by the successful on-demand reopen.
2295
2396
  recoveryDemandAllowed = false;
2397
+ // Number of transport operations currently parked behind `recoveryGate`.
2398
+ // When an automatic attempt fails, these already-parked operations are
2399
+ // themselves demand: the failure path starts an on-demand reopen instead of
2400
+ // stranding them until some unrelated future operation arrives.
2401
+ recoveryWaiters = 0;
2296
2402
  /** Monotonic generation incremented on every successful transport open.
2297
2403
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2298
2404
  * transport has been reopened even if the timestamp window is short. */
@@ -2407,7 +2513,7 @@ var CrossTabDataBus = class {
2407
2513
  this.suspendTransport();
2408
2514
  },
2409
2515
  onResume: () => {
2410
- this.resumeSuspendedResources();
2516
+ if (!this.resumeSuspendedResources()) return;
2411
2517
  this.resumeTransport();
2412
2518
  },
2413
2519
  onDiagnostic: (event) => {
@@ -2436,11 +2542,13 @@ var CrossTabDataBus = class {
2436
2542
  const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2437
2543
  if (!transportDown) return Promise.resolve();
2438
2544
  this.activeConfig = config;
2439
- this.resetFailureState();
2545
+ this.resetFailureState(true);
2440
2546
  const resumingFromSuspend = this.suspended;
2441
- if (resumingFromSuspend) this.resumeSuspendedResources();
2547
+ if (resumingFromSuspend && !this.resumeSuspendedResources()) {
2548
+ return this.stopPromise ?? Promise.resolve();
2549
+ }
2442
2550
  const opening2 = this.reopenTransport();
2443
- if (resumingFromSuspend) this.cluster.start();
2551
+ if (resumingFromSuspend && !this.stopping && !this.suspended) this.cluster.start();
2444
2552
  return opening2;
2445
2553
  }
2446
2554
  this.started = true;
@@ -2448,12 +2556,6 @@ var CrossTabDataBus = class {
2448
2556
  this.suspended = false;
2449
2557
  this.activeConfig = config;
2450
2558
  this.resetFailureState();
2451
- this.trace.start();
2452
- this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2453
- this.startDedupSweep();
2454
- this.replayManager.start();
2455
- this.updateStatus(WORKER_STATUS.CONNECTING);
2456
- this.cluster.start();
2457
2559
  const lifecycleEpoch = ++this.lifecycleEpoch;
2458
2560
  const opening = this.openTransport(
2459
2561
  config,
@@ -2462,7 +2564,17 @@ var CrossTabDataBus = class {
2462
2564
  lifecycleEpoch
2463
2565
  );
2464
2566
  this.startPromise = opening;
2567
+ this.trace.start();
2568
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2569
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
2570
+ this.startDedupSweep();
2571
+ this.replayManager.start();
2572
+ this.updateStatus(WORKER_STATUS.CONNECTING);
2573
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
2574
+ this.cluster.start();
2575
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
2465
2576
  for (const topic of this.topicHandlers.keys()) {
2577
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) break;
2466
2578
  this.cluster.subscribe(topic);
2467
2579
  }
2468
2580
  void opening.then(
@@ -2524,27 +2636,49 @@ var CrossTabDataBus = class {
2524
2636
  /** Cancel a pending automatic retry when an explicit lifecycle transition
2525
2637
  * supersedes it. The released gate re-enters runTransport(), which then
2526
2638
  * follows the newest start/stop/suspend intent. */
2527
- cancelScheduledRecovery() {
2639
+ cancelScheduledRecovery(invalidateParkedOperations = false, releaseGate = true) {
2528
2640
  this.recoveryTimerToken += 1;
2641
+ if (invalidateParkedOperations) this.recoveryCancellationToken += 1;
2529
2642
  if (this.recoveryTimer !== null) {
2530
2643
  clearTimeout(this.recoveryTimer);
2531
2644
  this.recoveryTimer = null;
2532
2645
  }
2533
- this.releaseRecoveryGate();
2646
+ if (releaseGate) this.releaseRecoveryGate();
2534
2647
  }
2535
2648
  /** Keep the recovery gate closed after a failed attempt while allowing the
2536
2649
  * next explicit transport operation to start an immediate on-demand reopen.
2537
- * If no gate/successor retry remains, release any waiters. */
2538
- allowDemandRecovery() {
2650
+ * If no gate/successor retry remains, release any waiters.
2651
+ *
2652
+ * `kickParkedWaiters` is set only when the failure is an automatic attempt: an
2653
+ * operation that was already parked on the gate is itself demand, so it must
2654
+ * not wait for some unrelated future operation. A failed *on-demand* reopen
2655
+ * passes `false`, so it re-arms the flag for a later operation instead of
2656
+ * looping on its own failure. */
2657
+ allowDemandRecovery(kickParkedWaiters = false) {
2539
2658
  if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
2540
2659
  this.recoveryDemandAllowed = true;
2660
+ if (kickParkedWaiters && this.recoveryWaiters > 0) this.startDemandRecovery();
2541
2661
  return;
2542
2662
  }
2543
2663
  this.releaseRecoveryGate();
2544
2664
  }
2665
+ /** Start one on-demand reopen if a failed attempt has left parked operations
2666
+ * and enabled demand recovery. Consumes the demand token so at most one
2667
+ * reopen is issued; every waiter stays behind the gate until it succeeds. */
2668
+ startDemandRecovery() {
2669
+ if (!this.recoveryDemandAllowed) return;
2670
+ if (this.status !== WORKER_STATUS.ERROR || this.suspended || this.stopping) return;
2671
+ this.recoveryDemandAllowed = false;
2672
+ const opening = this.reopenTransport();
2673
+ void opening.then(
2674
+ () => this.releaseRecoveryGate(),
2675
+ () => this.allowDemandRecovery()
2676
+ );
2677
+ }
2545
2678
  /** Reset failure and recovery diagnostics for a new explicit start session. */
2546
- resetFailureState() {
2547
- this.cancelScheduledRecovery();
2679
+ resetFailureState(preserveRecoveryGate = false) {
2680
+ this.cancelScheduledRecovery(false, !preserveRecoveryGate);
2681
+ if (preserveRecoveryGate) this.recoveryDemandAllowed = false;
2548
2682
  this.lastError = null;
2549
2683
  this.lastErrorAt = null;
2550
2684
  this.lastFailure = null;
@@ -2884,22 +3018,36 @@ var CrossTabDataBus = class {
2884
3018
  if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
2885
3019
  return Promise.resolve();
2886
3020
  }
3021
+ let resolveGate;
3022
+ const stopGate = new Promise((resolve) => {
3023
+ resolveGate = resolve;
3024
+ });
3025
+ this.stopPromise = stopGate;
3026
+ this.beginStop();
2887
3027
  const stopPromise = this.performStop();
2888
- this.stopPromise = stopPromise;
2889
3028
  void stopPromise.then(
2890
3029
  () => {
2891
- if (this.stopPromise === stopPromise) this.stopPromise = null;
3030
+ if (this.stopPromise === stopGate) this.stopPromise = null;
3031
+ resolveGate();
2892
3032
  },
2893
3033
  () => {
2894
- if (this.stopPromise === stopPromise) this.stopPromise = null;
3034
+ if (this.stopPromise === stopGate) this.stopPromise = null;
3035
+ resolveGate();
2895
3036
  }
2896
3037
  );
2897
- return stopPromise;
3038
+ return stopGate;
2898
3039
  }
2899
- async performStop() {
3040
+ /**
3041
+ * Synchronous teardown prelude. Flips `stopping` (the authoritative
3042
+ * in-flight signal), cancels scheduled work, releases handlers, and emits the
3043
+ * observable STOP lifecycle event. stop() calls it after the shared gate is
3044
+ * installed but in the same tick, so the stop still takes effect immediately
3045
+ * while a re-entrant stop() from the STOP event shares the one teardown.
3046
+ */
3047
+ beginStop() {
2900
3048
  this.lifecycleEpoch += 1;
2901
3049
  this.stopping = true;
2902
- this.cancelScheduledRecovery();
3050
+ this.cancelScheduledRecovery(true);
2903
3051
  this.replayManager.suspend();
2904
3052
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
2905
3053
  this.trace.stop();
@@ -2907,6 +3055,8 @@ var CrossTabDataBus = class {
2907
3055
  this.topicHandlers.clear();
2908
3056
  this.replayManager.resetBuffers();
2909
3057
  this.cluster.stop();
3058
+ }
3059
+ async performStop() {
2910
3060
  try {
2911
3061
  await this.startPromise?.catch(() => void 0);
2912
3062
  const pendingStop = this.pendingStop;
@@ -2923,8 +3073,6 @@ var CrossTabDataBus = class {
2923
3073
  this.transportReady = false;
2924
3074
  this.startPromise = null;
2925
3075
  this.pendingStop = null;
2926
- this.lastError = null;
2927
- this.lastErrorAt = null;
2928
3076
  this.activeConfig = void 0;
2929
3077
  this.recoveryAttempt = 0;
2930
3078
  this.recoveryExhausted = false;
@@ -3021,7 +3169,9 @@ var CrossTabDataBus = class {
3021
3169
  const opening = this.reopenTransport(attempt);
3022
3170
  void opening.then(
3023
3171
  () => this.releaseRecoveryGate(),
3024
- () => this.allowDemandRecovery()
3172
+ // Operations already parked on the gate are demand: run one
3173
+ // on-demand reopen now instead of waiting for an unrelated event.
3174
+ () => this.allowDemandRecovery(true)
3025
3175
  );
3026
3176
  }, this.recoveryCooldownMs);
3027
3177
  }
@@ -3124,10 +3274,13 @@ var CrossTabDataBus = class {
3124
3274
  * pageshow path and explicit start() must run this so an explicit resume
3125
3275
  * cannot leave trace metrics and periodic cleanup timers permanently off. */
3126
3276
  resumeSuspendedResources() {
3277
+ const lifecycleEpoch = this.lifecycleEpoch;
3127
3278
  this.trace.start();
3128
3279
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
3280
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return false;
3129
3281
  this.startDedupSweep();
3130
3282
  this.replayManager.start();
3283
+ return lifecycleEpoch === this.lifecycleEpoch && !this.stopping;
3131
3284
  }
3132
3285
  /**
3133
3286
  * Suspend the transport when the tab goes hidden. Stops the transport and
@@ -3135,12 +3288,13 @@ var CrossTabDataBus = class {
3135
3288
  */
3136
3289
  suspendTransport() {
3137
3290
  if (this.stopping) return;
3138
- this.lifecycleEpoch += 1;
3291
+ const suspensionEpoch = ++this.lifecycleEpoch;
3139
3292
  this.suspended = true;
3140
- this.cancelScheduledRecovery();
3293
+ this.cancelScheduledRecovery(true);
3141
3294
  this.transportReady = false;
3142
3295
  this.transportSubscribedTopics.clear();
3143
3296
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
3297
+ if (suspensionEpoch !== this.lifecycleEpoch || this.stopping || !this.suspended) return;
3144
3298
  if (this.pendingStop && (this.startPromise === null || this.startPromise === this.pendingStop)) {
3145
3299
  this.startPromise = this.pendingStop;
3146
3300
  return;
@@ -3175,13 +3329,18 @@ var CrossTabDataBus = class {
3175
3329
  if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
3176
3330
  const config = this.activeConfig;
3177
3331
  const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
3178
- this.started = true;
3179
- this.suspended = false;
3180
- this.updateStatus(WORKER_STATUS.CONNECTING);
3181
3332
  const lifecycleEpoch = ++this.lifecycleEpoch;
3182
3333
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3183
3334
  const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
3184
3335
  this.startPromise = opening;
3336
+ this.started = true;
3337
+ this.suspended = false;
3338
+ this.transportReady = false;
3339
+ this.updateStatus(WORKER_STATUS.CONNECTING);
3340
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping || this.suspended) {
3341
+ void opening.catch(() => void 0);
3342
+ return opening;
3343
+ }
3185
3344
  void opening.then(
3186
3345
  () => {
3187
3346
  if (this.startPromise === opening) this.startPromise = null;
@@ -3211,17 +3370,13 @@ var CrossTabDataBus = class {
3211
3370
  runTransport(operation) {
3212
3371
  if (this.suspended) return;
3213
3372
  if (this.recoveryGate && !this.stopping) {
3214
- if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
3215
- this.recoveryDemandAllowed = false;
3216
- const opening = this.reopenTransport();
3217
- void opening.then(
3218
- () => this.releaseRecoveryGate(),
3219
- () => this.allowDemandRecovery()
3220
- );
3221
- }
3222
3373
  const gate = this.recoveryGate;
3374
+ const cancellationToken = this.recoveryCancellationToken;
3375
+ this.startDemandRecovery();
3376
+ this.recoveryWaiters += 1;
3223
3377
  void gate.then(() => {
3224
- if (this.stopping || this.suspended) return;
3378
+ this.recoveryWaiters -= 1;
3379
+ if (this.stopping || this.suspended || cancellationToken !== this.recoveryCancellationToken) return;
3225
3380
  this.runTransport(operation);
3226
3381
  });
3227
3382
  return;
@@ -3345,4 +3500,4 @@ export {
3345
3500
  parseDataBusPublication,
3346
3501
  selectWorkerBackend
3347
3502
  };
3348
- //# sourceMappingURL=chunk-DMM5KBNG.js.map
3503
+ //# sourceMappingURL=chunk-QB74C4EH.js.map