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.
@@ -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);
@@ -723,10 +737,12 @@ var WorkerClusterRuntime = class {
723
737
  const channel = this.channel;
724
738
  this.channel = null;
725
739
  this.handlers.onSuspend?.();
726
- if (typeof globalThis.setTimeout === "function") {
727
- globalThis.setTimeout(() => channel?.close(), 0);
728
- } else {
729
- channel?.close();
740
+ if (channel) {
741
+ if (typeof globalThis.setTimeout === "function") {
742
+ globalThis.setTimeout(() => channel.close(), 0);
743
+ } else {
744
+ channel.close();
745
+ }
730
746
  }
731
747
  }
732
748
  /** Update the worker's connection status and persist the change. */
@@ -1019,8 +1035,10 @@ var WorkerClusterRuntime = class {
1019
1035
  handlePageHide = () => this.pause();
1020
1036
  handlePageShow = () => {
1021
1037
  if (!this.suspended) return;
1038
+ const generation = ++this.lifecycleGeneration;
1022
1039
  this.suspended = false;
1023
1040
  this.handlers.onResume?.();
1041
+ if (generation !== this.lifecycleGeneration) return;
1024
1042
  this.activate();
1025
1043
  };
1026
1044
  handleVisibilityChange = () => {
@@ -1075,10 +1093,16 @@ var WorkerClusterRuntime = class {
1075
1093
  if (message.targetWorkerId !== this.workerId) return;
1076
1094
  this.rememberTopic(message.topic);
1077
1095
  switch (message.action) {
1078
- 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
+ }
1079
1102
  this.assignedTopics.set(message.topicKey, message.topic);
1080
1103
  this.confirmRoute(message.topicKey);
1081
1104
  break;
1105
+ }
1082
1106
  case CONTROL_ACTION.UNSUBSCRIBE:
1083
1107
  if (this.releaseHandoffOnUnsubscribe(message)) return;
1084
1108
  break;
@@ -1139,8 +1163,8 @@ var WorkerClusterRuntime = class {
1139
1163
  }
1140
1164
  /**
1141
1165
  * Accept a graceful handoff only when the route still points to this worker,
1142
- * the release comes from the recorded previous owner, and the generation is
1143
- * 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.
1144
1168
  */
1145
1169
  handleRouteReleasedMessage(message) {
1146
1170
  if (message.targetWorkerId !== this.workerId) return;
@@ -1153,11 +1177,11 @@ var WorkerClusterRuntime = class {
1153
1177
  }
1154
1178
  /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
1155
1179
  * points to us, the release comes from the recorded previous owner, and
1156
- * the release generation is at least as new as ours. A replayed ACK from an
1157
- * earlier handoff round (e.g. an a↔b ping-pong) carries an older generation
1158
- * 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. */
1159
1183
  isStaleRouteRelease(route, message) {
1160
- 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;
1161
1185
  }
1162
1186
  /** True when an unconfirmed handoff route has been stuck longer than a
1163
1187
  * worker TTL. The ACK for a live handoff is posted synchronously with the
@@ -1784,7 +1808,7 @@ var ReplayManager = class {
1784
1808
  this.trace = deps.trace;
1785
1809
  this.onPersistenceError = deps.onPersistenceError;
1786
1810
  this.onDispatchError = deps.onDispatchError;
1787
- this.hydration = this.hydrate();
1811
+ void this.requestHydration();
1788
1812
  }
1789
1813
  deps;
1790
1814
  buffers;
@@ -1803,7 +1827,18 @@ var ReplayManager = class {
1803
1827
  retryGeneration = 0;
1804
1828
  pendingReplayPersistence = [];
1805
1829
  persistenceFlushScheduled = false;
1806
- 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;
1807
1842
  /** Coalesced retention cleanup: the newest cutoff wins while one is running. */
1808
1843
  retentionCleanup = null;
1809
1844
  retentionCutoff = null;
@@ -1857,7 +1892,7 @@ var ReplayManager = class {
1857
1892
  if (!this.buffers) return;
1858
1893
  const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
1859
1894
  if (this.persistence) {
1860
- void this.hydration.then(() => {
1895
+ void this.requestHydration().then(() => {
1861
1896
  if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
1862
1897
  });
1863
1898
  return;
@@ -1869,22 +1904,24 @@ var ReplayManager = class {
1869
1904
  * clearTopic), and prune durable history. */
1870
1905
  onTopicUnsubscribed(topic) {
1871
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);
1872
1909
  this.buffers.delete(topic);
1873
1910
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1874
- if (this.persistence?.clearTopic) {
1875
- void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
1876
- }
1911
+ if (clearing) void clearing.catch((error) => this.onPersistenceError(error));
1877
1912
  }
1878
1913
  /** Clear all in-memory replay buffers and, when supported, durable history.
1879
1914
  * Reports persistence failures and rethrows, mirroring the public API
1880
1915
  * contract that callers can observe a failed clear. */
1881
1916
  async clearAll() {
1882
1917
  if (!this.buffers) return;
1918
+ const clearing = this.persistence?.clear ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear()) : null;
1919
+ this.hydrationClearAll = true;
1883
1920
  this.buffers.clear();
1884
1921
  this.pendingReplayPersistence = [];
1885
- if (this.persistence?.clear) {
1922
+ if (clearing) {
1886
1923
  try {
1887
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
1924
+ await clearing;
1888
1925
  } catch (error) {
1889
1926
  this.onPersistenceError(error);
1890
1927
  throw error;
@@ -1894,11 +1931,13 @@ var ReplayManager = class {
1894
1931
  /** Clear replay history for one exact topic, including durable storage. */
1895
1932
  async clearTopic(topic) {
1896
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);
1897
1936
  this.buffers.delete(topic);
1898
1937
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1899
- if (this.persistence?.clearTopic) {
1938
+ if (clearing) {
1900
1939
  try {
1901
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
1940
+ await clearing;
1902
1941
  } catch (error) {
1903
1942
  this.onPersistenceError(error);
1904
1943
  throw error;
@@ -1908,7 +1947,9 @@ var ReplayManager = class {
1908
1947
  /** Remove replay entries older than an epoch-millisecond cutoff. */
1909
1948
  async clearBefore(timestamp) {
1910
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;
1911
1951
  if (this.buffers) {
1952
+ this.hydrationClearBefore = this.hydrationClearBefore === null ? timestamp : Math.max(this.hydrationClearBefore, timestamp);
1912
1953
  for (const [topic, messages] of this.buffers) {
1913
1954
  const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1914
1955
  if (kept.length) this.buffers.set(topic, kept);
@@ -1918,9 +1959,9 @@ var ReplayManager = class {
1918
1959
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
1919
1960
  (message) => message.timestamp === void 0 || message.timestamp >= timestamp
1920
1961
  );
1921
- if (this.persistence?.clearBefore) {
1962
+ if (clearing) {
1922
1963
  try {
1923
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
1964
+ await clearing;
1924
1965
  } catch (error) {
1925
1966
  this.onPersistenceError(error);
1926
1967
  throw error;
@@ -1930,6 +1971,11 @@ var ReplayManager = class {
1930
1971
  /** Start the periodic retention sweep. No-op when no durable retention
1931
1972
  * config makes it necessary. */
1932
1973
  start() {
1974
+ if (this.hydrationFailed) {
1975
+ this.hydrationComplete = false;
1976
+ this.hydrationFailed = false;
1977
+ }
1978
+ void this.requestHydration();
1933
1979
  if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
1934
1980
  this.retentionTimer = setInterval(() => {
1935
1981
  this.scheduleRetentionCleanup(this.now() - this.retentionMs);
@@ -1944,13 +1990,25 @@ var ReplayManager = class {
1944
1990
  * or stopped bus does not keep hammering the store) and stop the sweep. */
1945
1991
  suspend() {
1946
1992
  this.retryGeneration += 1;
1993
+ if (this.hydration) {
1994
+ this.hydrationEpoch += 1;
1995
+ this.hydration = null;
1996
+ this.hydrationComplete = false;
1997
+ }
1947
1998
  this.pendingReplayPersistence = [];
1948
1999
  this.retentionCutoff = null;
1949
2000
  this.stop();
1950
2001
  }
1951
- /** Drop all in-memory buffers (used on full teardown). */
2002
+ /** Drop all in-memory buffers and require hydration for the next lifecycle. */
1952
2003
  resetBuffers() {
1953
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;
1954
2012
  }
1955
2013
  /** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
1956
2014
  * payload footprint (same heuristic as adaptive load weighting), computed on
@@ -2002,38 +2060,78 @@ var ReplayManager = class {
2002
2060
  void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
2003
2061
  });
2004
2062
  }
2005
- /** 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
2006
2079
  * entries past the retention window first. Failures are reported but do not
2007
2080
  * block startup — the bus runs with whatever survived. */
2008
- async hydrate() {
2081
+ async hydrate(epoch, generation) {
2009
2082
  if (!this.buffers || !this.persistence) {
2010
2083
  return;
2011
2084
  }
2012
2085
  try {
2013
- if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2014
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2015
- }
2016
- const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2017
- for (const message of loaded) {
2018
- let buffer = this.buffers.get(message.topic);
2019
- if (!buffer) {
2020
- buffer = [];
2021
- 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);
2022
2116
  }
2023
- buffer.push(message);
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;
2125
+ }
2126
+ if (epoch !== this.hydrationEpoch) return;
2127
+ this.onPersistenceError(error);
2128
+ this.hydrationComplete = true;
2129
+ this.hydrationFailed = true;
2024
2130
  }
2025
- const hydrationNow = this.now();
2026
- for (const [topic, buffer] of this.buffers) {
2027
- const pruned = pruneReplayHistory(buffer, {
2028
- maxPerTopic: this.maxPerTopic,
2029
- pruneStrategy: this.pruneStrategy,
2030
- retentionMs: this.retentionMs,
2031
- now: hydrationNow
2032
- });
2033
- if (pruned !== buffer) this.buffers.set(topic, pruned);
2131
+ } finally {
2132
+ if (this.hydrationEpoch === epoch && this.retryGeneration === generation) {
2133
+ this.hydration = null;
2034
2134
  }
2035
- } catch (error) {
2036
- this.onPersistenceError(error);
2037
2135
  }
2038
2136
  }
2039
2137
  /** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
@@ -2057,7 +2155,7 @@ var ReplayManager = class {
2057
2155
  }
2058
2156
  })().finally(() => {
2059
2157
  this.retentionCleanup = null;
2060
- if (this.retentionCutoff !== null && generation === this.retryGeneration) {
2158
+ if (this.retentionCutoff !== null) {
2061
2159
  this.scheduleRetentionCleanup(this.retentionCutoff);
2062
2160
  }
2063
2161
  });
@@ -2210,7 +2308,7 @@ var DedupManager = class {
2210
2308
  };
2211
2309
 
2212
2310
  // src/core/version.ts
2213
- var SDK_VERSION = true ? "0.20.90" : "";
2311
+ var SDK_VERSION = true ? "0.20.92" : "";
2214
2312
 
2215
2313
  // src/core/data-bus.ts
2216
2314
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2286,11 +2384,21 @@ var CrossTabDataBus = class {
2286
2384
  recoveryGateRelease = null;
2287
2385
  recoveryTimer = null;
2288
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;
2289
2392
  // Once an automatic attempt fails, an explicit transport operation may
2290
2393
  // recover immediately instead of waiting for the next paced attempt. The
2291
2394
  // gate still stays closed so the operation cannot reach the failed
2292
2395
  // transport; it is released by the successful on-demand reopen.
2293
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;
2294
2402
  /** Monotonic generation incremented on every successful transport open.
2295
2403
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2296
2404
  * transport has been reopened even if the timestamp window is short. */
@@ -2392,6 +2500,7 @@ var CrossTabDataBus = class {
2392
2500
  // own broadcastEvent call, which always posts a DataBusMessage.
2393
2501
  onEvent: (eventType, payload, _sourceWorkerId, originTabId) => {
2394
2502
  if (eventType !== PUBLICATION_EVENT) return;
2503
+ if (typeof payload !== "object" || payload === null || typeof payload.topic !== "string") return;
2395
2504
  const incoming = payload;
2396
2505
  const message = incoming.originTabId !== void 0 ? incoming : originTabId !== void 0 ? { ...incoming, originTabId } : incoming;
2397
2506
  if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
@@ -2404,7 +2513,7 @@ var CrossTabDataBus = class {
2404
2513
  this.suspendTransport();
2405
2514
  },
2406
2515
  onResume: () => {
2407
- this.resumeSuspendedResources();
2516
+ if (!this.resumeSuspendedResources()) return;
2408
2517
  this.resumeTransport();
2409
2518
  },
2410
2519
  onDiagnostic: (event) => {
@@ -2433,11 +2542,13 @@ var CrossTabDataBus = class {
2433
2542
  const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2434
2543
  if (!transportDown) return Promise.resolve();
2435
2544
  this.activeConfig = config;
2436
- this.resetFailureState();
2545
+ this.resetFailureState(true);
2437
2546
  const resumingFromSuspend = this.suspended;
2438
- if (resumingFromSuspend) this.resumeSuspendedResources();
2547
+ if (resumingFromSuspend && !this.resumeSuspendedResources()) {
2548
+ return this.stopPromise ?? Promise.resolve();
2549
+ }
2439
2550
  const opening2 = this.reopenTransport();
2440
- if (resumingFromSuspend) this.cluster.start();
2551
+ if (resumingFromSuspend && !this.stopping && !this.suspended) this.cluster.start();
2441
2552
  return opening2;
2442
2553
  }
2443
2554
  this.started = true;
@@ -2445,12 +2556,6 @@ var CrossTabDataBus = class {
2445
2556
  this.suspended = false;
2446
2557
  this.activeConfig = config;
2447
2558
  this.resetFailureState();
2448
- this.trace.start();
2449
- this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2450
- this.startDedupSweep();
2451
- this.replayManager.start();
2452
- this.updateStatus(WORKER_STATUS.CONNECTING);
2453
- this.cluster.start();
2454
2559
  const lifecycleEpoch = ++this.lifecycleEpoch;
2455
2560
  const opening = this.openTransport(
2456
2561
  config,
@@ -2459,7 +2564,17 @@ var CrossTabDataBus = class {
2459
2564
  lifecycleEpoch
2460
2565
  );
2461
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;
2462
2576
  for (const topic of this.topicHandlers.keys()) {
2577
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) break;
2463
2578
  this.cluster.subscribe(topic);
2464
2579
  }
2465
2580
  void opening.then(
@@ -2521,27 +2636,49 @@ var CrossTabDataBus = class {
2521
2636
  /** Cancel a pending automatic retry when an explicit lifecycle transition
2522
2637
  * supersedes it. The released gate re-enters runTransport(), which then
2523
2638
  * follows the newest start/stop/suspend intent. */
2524
- cancelScheduledRecovery() {
2639
+ cancelScheduledRecovery(invalidateParkedOperations = false, releaseGate = true) {
2525
2640
  this.recoveryTimerToken += 1;
2641
+ if (invalidateParkedOperations) this.recoveryCancellationToken += 1;
2526
2642
  if (this.recoveryTimer !== null) {
2527
2643
  clearTimeout(this.recoveryTimer);
2528
2644
  this.recoveryTimer = null;
2529
2645
  }
2530
- this.releaseRecoveryGate();
2646
+ if (releaseGate) this.releaseRecoveryGate();
2531
2647
  }
2532
2648
  /** Keep the recovery gate closed after a failed attempt while allowing the
2533
2649
  * next explicit transport operation to start an immediate on-demand reopen.
2534
- * If no gate/successor retry remains, release any waiters. */
2535
- 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) {
2536
2658
  if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
2537
2659
  this.recoveryDemandAllowed = true;
2660
+ if (kickParkedWaiters && this.recoveryWaiters > 0) this.startDemandRecovery();
2538
2661
  return;
2539
2662
  }
2540
2663
  this.releaseRecoveryGate();
2541
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
+ }
2542
2678
  /** Reset failure and recovery diagnostics for a new explicit start session. */
2543
- resetFailureState() {
2544
- this.cancelScheduledRecovery();
2679
+ resetFailureState(preserveRecoveryGate = false) {
2680
+ this.cancelScheduledRecovery(false, !preserveRecoveryGate);
2681
+ if (preserveRecoveryGate) this.recoveryDemandAllowed = false;
2545
2682
  this.lastError = null;
2546
2683
  this.lastErrorAt = null;
2547
2684
  this.lastFailure = null;
@@ -2881,22 +3018,36 @@ var CrossTabDataBus = class {
2881
3018
  if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
2882
3019
  return Promise.resolve();
2883
3020
  }
3021
+ let resolveGate;
3022
+ const stopGate = new Promise((resolve) => {
3023
+ resolveGate = resolve;
3024
+ });
3025
+ this.stopPromise = stopGate;
3026
+ this.beginStop();
2884
3027
  const stopPromise = this.performStop();
2885
- this.stopPromise = stopPromise;
2886
3028
  void stopPromise.then(
2887
3029
  () => {
2888
- if (this.stopPromise === stopPromise) this.stopPromise = null;
3030
+ if (this.stopPromise === stopGate) this.stopPromise = null;
3031
+ resolveGate();
2889
3032
  },
2890
3033
  () => {
2891
- if (this.stopPromise === stopPromise) this.stopPromise = null;
3034
+ if (this.stopPromise === stopGate) this.stopPromise = null;
3035
+ resolveGate();
2892
3036
  }
2893
3037
  );
2894
- return stopPromise;
3038
+ return stopGate;
2895
3039
  }
2896
- 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() {
2897
3048
  this.lifecycleEpoch += 1;
2898
3049
  this.stopping = true;
2899
- this.cancelScheduledRecovery();
3050
+ this.cancelScheduledRecovery(true);
2900
3051
  this.replayManager.suspend();
2901
3052
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
2902
3053
  this.trace.stop();
@@ -2904,6 +3055,8 @@ var CrossTabDataBus = class {
2904
3055
  this.topicHandlers.clear();
2905
3056
  this.replayManager.resetBuffers();
2906
3057
  this.cluster.stop();
3058
+ }
3059
+ async performStop() {
2907
3060
  try {
2908
3061
  await this.startPromise?.catch(() => void 0);
2909
3062
  const pendingStop = this.pendingStop;
@@ -2920,8 +3073,6 @@ var CrossTabDataBus = class {
2920
3073
  this.transportReady = false;
2921
3074
  this.startPromise = null;
2922
3075
  this.pendingStop = null;
2923
- this.lastError = null;
2924
- this.lastErrorAt = null;
2925
3076
  this.activeConfig = void 0;
2926
3077
  this.recoveryAttempt = 0;
2927
3078
  this.recoveryExhausted = false;
@@ -3018,7 +3169,9 @@ var CrossTabDataBus = class {
3018
3169
  const opening = this.reopenTransport(attempt);
3019
3170
  void opening.then(
3020
3171
  () => this.releaseRecoveryGate(),
3021
- () => 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)
3022
3175
  );
3023
3176
  }, this.recoveryCooldownMs);
3024
3177
  }
@@ -3121,10 +3274,13 @@ var CrossTabDataBus = class {
3121
3274
  * pageshow path and explicit start() must run this so an explicit resume
3122
3275
  * cannot leave trace metrics and periodic cleanup timers permanently off. */
3123
3276
  resumeSuspendedResources() {
3277
+ const lifecycleEpoch = this.lifecycleEpoch;
3124
3278
  this.trace.start();
3125
3279
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
3280
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return false;
3126
3281
  this.startDedupSweep();
3127
3282
  this.replayManager.start();
3283
+ return lifecycleEpoch === this.lifecycleEpoch && !this.stopping;
3128
3284
  }
3129
3285
  /**
3130
3286
  * Suspend the transport when the tab goes hidden. Stops the transport and
@@ -3132,12 +3288,13 @@ var CrossTabDataBus = class {
3132
3288
  */
3133
3289
  suspendTransport() {
3134
3290
  if (this.stopping) return;
3135
- this.lifecycleEpoch += 1;
3291
+ const suspensionEpoch = ++this.lifecycleEpoch;
3136
3292
  this.suspended = true;
3137
- this.cancelScheduledRecovery();
3293
+ this.cancelScheduledRecovery(true);
3138
3294
  this.transportReady = false;
3139
3295
  this.transportSubscribedTopics.clear();
3140
3296
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
3297
+ if (suspensionEpoch !== this.lifecycleEpoch || this.stopping || !this.suspended) return;
3141
3298
  if (this.pendingStop && (this.startPromise === null || this.startPromise === this.pendingStop)) {
3142
3299
  this.startPromise = this.pendingStop;
3143
3300
  return;
@@ -3172,13 +3329,18 @@ var CrossTabDataBus = class {
3172
3329
  if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
3173
3330
  const config = this.activeConfig;
3174
3331
  const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
3175
- this.started = true;
3176
- this.suspended = false;
3177
- this.updateStatus(WORKER_STATUS.CONNECTING);
3178
3332
  const lifecycleEpoch = ++this.lifecycleEpoch;
3179
3333
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3180
3334
  const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
3181
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
+ }
3182
3344
  void opening.then(
3183
3345
  () => {
3184
3346
  if (this.startPromise === opening) this.startPromise = null;
@@ -3208,17 +3370,13 @@ var CrossTabDataBus = class {
3208
3370
  runTransport(operation) {
3209
3371
  if (this.suspended) return;
3210
3372
  if (this.recoveryGate && !this.stopping) {
3211
- if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
3212
- this.recoveryDemandAllowed = false;
3213
- const opening = this.reopenTransport();
3214
- void opening.then(
3215
- () => this.releaseRecoveryGate(),
3216
- () => this.allowDemandRecovery()
3217
- );
3218
- }
3219
3373
  const gate = this.recoveryGate;
3374
+ const cancellationToken = this.recoveryCancellationToken;
3375
+ this.startDemandRecovery();
3376
+ this.recoveryWaiters += 1;
3220
3377
  void gate.then(() => {
3221
- if (this.stopping || this.suspended) return;
3378
+ this.recoveryWaiters -= 1;
3379
+ if (this.stopping || this.suspended || cancellationToken !== this.recoveryCancellationToken) return;
3222
3380
  this.runTransport(operation);
3223
3381
  });
3224
3382
  return;
@@ -3342,4 +3500,4 @@ export {
3342
3500
  parseDataBusPublication,
3343
3501
  selectWorkerBackend
3344
3502
  };
3345
- //# sourceMappingURL=chunk-L24ETFVK.js.map
3503
+ //# sourceMappingURL=chunk-QB74C4EH.js.map