cross-tab-worker-databus 0.20.91 → 0.20.93

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.
@@ -85,6 +85,7 @@ function createStorageEventChannel(options) {
85
85
  };
86
86
  }
87
87
  var tabIdentityInitialized = false;
88
+ var cachedTabId = null;
88
89
  function createBrowserEnvironment(options) {
89
90
  const channelFallback = options?.channelFallback ?? CHANNEL_FALLBACK.NONE;
90
91
  return {
@@ -130,8 +131,9 @@ function canUseStorage(storage, probeKey) {
130
131
  if (!storage) return false;
131
132
  try {
132
133
  storage.setItem(probeKey, "1");
134
+ const readable = storage.getItem(probeKey) === "1";
133
135
  storage.removeItem(probeKey);
134
- return true;
136
+ return readable;
135
137
  } catch {
136
138
  return false;
137
139
  }
@@ -143,14 +145,21 @@ function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
143
145
  const hasOpener = typeof window !== "undefined" && Boolean(window.opener);
144
146
  if (existing && (!hasOpener || tabIdentityInitialized)) {
145
147
  tabIdentityInitialized = true;
148
+ cachedTabId = existing;
146
149
  return existing;
147
150
  }
151
+ if (tabIdentityInitialized && cachedTabId) return cachedTabId;
148
152
  const created = `tab-${environment.randomId()}`;
149
153
  storage?.setItem(key, created);
150
154
  tabIdentityInitialized = true;
155
+ cachedTabId = created;
151
156
  return created;
152
157
  } catch {
153
- return `tab-${environment.randomId()}`;
158
+ if (cachedTabId) return cachedTabId;
159
+ const created = `tab-${environment.randomId()}`;
160
+ tabIdentityInitialized = true;
161
+ cachedTabId = created;
162
+ return created;
154
163
  }
155
164
  }
156
165
 
@@ -428,7 +437,17 @@ var BatchingStorageWriter = class {
428
437
  clear() {
429
438
  this.pending.clear();
430
439
  this.flushScheduled = false;
440
+ this.cancelRetry();
441
+ this.retryCount.clear();
442
+ this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
431
443
  this.storage.clear();
444
+ }
445
+ /** Drop queued mutations and cancel retry state without clearing storage.
446
+ * Used when the owning runtime is torn down: final best-effort writes have
447
+ * already been flushed, and failed writes must not keep timers alive. */
448
+ discardPending() {
449
+ this.pending.clear();
450
+ this.flushScheduled = false;
432
451
  this.cancelRetry();
433
452
  this.retryCount.clear();
434
453
  this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
@@ -612,6 +631,9 @@ var WorkerClusterRuntime = class {
612
631
  started = false;
613
632
  suspended = false;
614
633
  lifecycleListening = false;
634
+ /** Invalidates an in-flight pageshow resume when a synchronous onResume
635
+ * callback stops or pauses the cluster before activate() is reached. */
636
+ lifecycleGeneration = 0;
615
637
  currentRecord;
616
638
  constructor(options) {
617
639
  assertClusterOptions(options);
@@ -648,8 +670,14 @@ var WorkerClusterRuntime = class {
648
670
  /** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
649
671
  start() {
650
672
  if (this.started) return;
651
- this.suspended = false;
673
+ this.lifecycleGeneration += 1;
652
674
  this.addLifecycleListeners();
675
+ if (this.environment.getVisibilityState() === TAB_VISIBILITY.HIDDEN) {
676
+ this.suspended = true;
677
+ this.handlers.onSuspend?.();
678
+ return;
679
+ }
680
+ this.suspended = false;
653
681
  this.activate();
654
682
  }
655
683
  /**
@@ -660,6 +688,7 @@ var WorkerClusterRuntime = class {
660
688
  * stop() path where callers expect every Set/Map to be empty afterwards.
661
689
  */
662
690
  stop() {
691
+ this.lifecycleGeneration += 1;
663
692
  if (!this.started && !this.suspended) return;
664
693
  this.pause();
665
694
  this.flushStorage();
@@ -678,7 +707,15 @@ var WorkerClusterRuntime = class {
678
707
  activate() {
679
708
  if (this.started) return;
680
709
  this.started = true;
681
- this.channel = this.storage ? this.environment.createChannel(this.channelName) : null;
710
+ if (this.storage) {
711
+ try {
712
+ this.channel = this.environment.createChannel(this.channelName);
713
+ } catch {
714
+ this.channel = null;
715
+ }
716
+ } else {
717
+ this.channel = null;
718
+ }
682
719
  if (!this.channel) this.storage = null;
683
720
  this.channel?.addEventListener("message", this.handleMessage);
684
721
  const now = this.environment.now();
@@ -706,7 +743,11 @@ var WorkerClusterRuntime = class {
706
743
  * to other workers, remove our worker record, and close the channel.
707
744
  */
708
745
  pause() {
709
- if (!this.started) return;
746
+ this.lifecycleGeneration += 1;
747
+ if (!this.started) {
748
+ if (this.lifecycleListening) this.suspended = true;
749
+ return;
750
+ }
710
751
  this.started = false;
711
752
  this.suspended = true;
712
753
  if (this.heartbeatHandle !== null) this.environment.clearInterval(this.heartbeatHandle);
@@ -720,6 +761,7 @@ var WorkerClusterRuntime = class {
720
761
  this.removeStorage(this.workerStorageKey(this.workerId));
721
762
  this.flushStorage();
722
763
  this.notifyRegistry();
764
+ if (this.storage instanceof BatchingStorageWriter) this.storage.discardPending();
723
765
  const channel = this.channel;
724
766
  this.channel = null;
725
767
  this.handlers.onSuspend?.();
@@ -1021,8 +1063,10 @@ var WorkerClusterRuntime = class {
1021
1063
  handlePageHide = () => this.pause();
1022
1064
  handlePageShow = () => {
1023
1065
  if (!this.suspended) return;
1066
+ const generation = ++this.lifecycleGeneration;
1024
1067
  this.suspended = false;
1025
1068
  this.handlers.onResume?.();
1069
+ if (generation !== this.lifecycleGeneration) return;
1026
1070
  this.activate();
1027
1071
  };
1028
1072
  handleVisibilityChange = () => {
@@ -1077,10 +1121,16 @@ var WorkerClusterRuntime = class {
1077
1121
  if (message.targetWorkerId !== this.workerId) return;
1078
1122
  this.rememberTopic(message.topic);
1079
1123
  switch (message.action) {
1080
- case CONTROL_ACTION.SUBSCRIBE:
1124
+ case CONTROL_ACTION.SUBSCRIBE: {
1125
+ const route = this.readRoute(message.topicKey);
1126
+ if (route && route.workerId !== this.workerId) return;
1127
+ if (route?.workerId === this.workerId && route.handoffFromWorkerId !== void 0 && route.confirmedAt === void 0) {
1128
+ return;
1129
+ }
1081
1130
  this.assignedTopics.set(message.topicKey, message.topic);
1082
1131
  this.confirmRoute(message.topicKey);
1083
1132
  break;
1133
+ }
1084
1134
  case CONTROL_ACTION.UNSUBSCRIBE:
1085
1135
  if (this.releaseHandoffOnUnsubscribe(message)) return;
1086
1136
  break;
@@ -1141,8 +1191,8 @@ var WorkerClusterRuntime = class {
1141
1191
  }
1142
1192
  /**
1143
1193
  * 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.
1194
+ * the release comes from the recorded previous owner, and the generation
1195
+ * exactly matches ours. Any other ROUTE_RELEASED is stale and dropped.
1146
1196
  */
1147
1197
  handleRouteReleasedMessage(message) {
1148
1198
  if (message.targetWorkerId !== this.workerId) return;
@@ -1155,11 +1205,11 @@ var WorkerClusterRuntime = class {
1155
1205
  }
1156
1206
  /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
1157
1207
  * 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. */
1208
+ * the release generation exactly matches ours. A delayed ACK from either an
1209
+ * earlier or later handoff round belongs to a different route and must not
1210
+ * confirm the current round. */
1161
1211
  isStaleRouteRelease(route, message) {
1162
- return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation < route.generation;
1212
+ return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation !== route.generation;
1163
1213
  }
1164
1214
  /** True when an unconfirmed handoff route has been stuck longer than a
1165
1215
  * worker TTL. The ACK for a live handoff is posted synchronously with the
@@ -1786,7 +1836,7 @@ var ReplayManager = class {
1786
1836
  this.trace = deps.trace;
1787
1837
  this.onPersistenceError = deps.onPersistenceError;
1788
1838
  this.onDispatchError = deps.onDispatchError;
1789
- this.hydration = this.hydrate();
1839
+ void this.requestHydration();
1790
1840
  }
1791
1841
  deps;
1792
1842
  buffers;
@@ -1805,7 +1855,18 @@ var ReplayManager = class {
1805
1855
  retryGeneration = 0;
1806
1856
  pendingReplayPersistence = [];
1807
1857
  persistenceFlushScheduled = false;
1808
- hydration;
1858
+ /** Current hydration operation, if one belongs to the active lifecycle. */
1859
+ hydration = null;
1860
+ /** Invalidates a load from a superseded suspend/reset lifecycle. */
1861
+ hydrationEpoch = 0;
1862
+ /** Whether the active lifecycle has finished (or deliberately skipped) hydration. */
1863
+ hydrationComplete = false;
1864
+ /** A failed load is retried by the next explicit start(), not every replay request. */
1865
+ hydrationFailed = false;
1866
+ /** Mutations that must be applied to a load already in flight. */
1867
+ hydrationClearAll = false;
1868
+ hydrationClearedTopics = /* @__PURE__ */ new Set();
1869
+ hydrationClearBefore = null;
1809
1870
  /** Coalesced retention cleanup: the newest cutoff wins while one is running. */
1810
1871
  retentionCleanup = null;
1811
1872
  retentionCutoff = null;
@@ -1859,7 +1920,7 @@ var ReplayManager = class {
1859
1920
  if (!this.buffers) return;
1860
1921
  const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
1861
1922
  if (this.persistence) {
1862
- void this.hydration.then(() => {
1923
+ void this.requestHydration().then(() => {
1863
1924
  if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
1864
1925
  });
1865
1926
  return;
@@ -1871,22 +1932,24 @@ var ReplayManager = class {
1871
1932
  * clearTopic), and prune durable history. */
1872
1933
  onTopicUnsubscribed(topic) {
1873
1934
  if (!this.buffers) return;
1935
+ const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
1936
+ this.hydrationClearedTopics.add(topic);
1874
1937
  this.buffers.delete(topic);
1875
1938
  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
- }
1939
+ if (clearing) void clearing.catch((error) => this.onPersistenceError(error));
1879
1940
  }
1880
1941
  /** Clear all in-memory replay buffers and, when supported, durable history.
1881
1942
  * Reports persistence failures and rethrows, mirroring the public API
1882
1943
  * contract that callers can observe a failed clear. */
1883
1944
  async clearAll() {
1884
1945
  if (!this.buffers) return;
1946
+ const clearing = this.persistence?.clear ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear()) : null;
1947
+ this.hydrationClearAll = true;
1885
1948
  this.buffers.clear();
1886
1949
  this.pendingReplayPersistence = [];
1887
- if (this.persistence?.clear) {
1950
+ if (clearing) {
1888
1951
  try {
1889
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
1952
+ await clearing;
1890
1953
  } catch (error) {
1891
1954
  this.onPersistenceError(error);
1892
1955
  throw error;
@@ -1896,11 +1959,13 @@ var ReplayManager = class {
1896
1959
  /** Clear replay history for one exact topic, including durable storage. */
1897
1960
  async clearTopic(topic) {
1898
1961
  if (!this.buffers) return;
1962
+ const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
1963
+ this.hydrationClearedTopics.add(topic);
1899
1964
  this.buffers.delete(topic);
1900
1965
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1901
- if (this.persistence?.clearTopic) {
1966
+ if (clearing) {
1902
1967
  try {
1903
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
1968
+ await clearing;
1904
1969
  } catch (error) {
1905
1970
  this.onPersistenceError(error);
1906
1971
  throw error;
@@ -1910,7 +1975,9 @@ var ReplayManager = class {
1910
1975
  /** Remove replay entries older than an epoch-millisecond cutoff. */
1911
1976
  async clearBefore(timestamp) {
1912
1977
  if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1978
+ const clearing = this.persistence?.clearBefore ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp)) : null;
1913
1979
  if (this.buffers) {
1980
+ this.hydrationClearBefore = this.hydrationClearBefore === null ? timestamp : Math.max(this.hydrationClearBefore, timestamp);
1914
1981
  for (const [topic, messages] of this.buffers) {
1915
1982
  const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1916
1983
  if (kept.length) this.buffers.set(topic, kept);
@@ -1920,9 +1987,9 @@ var ReplayManager = class {
1920
1987
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
1921
1988
  (message) => message.timestamp === void 0 || message.timestamp >= timestamp
1922
1989
  );
1923
- if (this.persistence?.clearBefore) {
1990
+ if (clearing) {
1924
1991
  try {
1925
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
1992
+ await clearing;
1926
1993
  } catch (error) {
1927
1994
  this.onPersistenceError(error);
1928
1995
  throw error;
@@ -1932,6 +1999,11 @@ var ReplayManager = class {
1932
1999
  /** Start the periodic retention sweep. No-op when no durable retention
1933
2000
  * config makes it necessary. */
1934
2001
  start() {
2002
+ if (this.hydrationFailed) {
2003
+ this.hydrationComplete = false;
2004
+ this.hydrationFailed = false;
2005
+ }
2006
+ void this.requestHydration();
1935
2007
  if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
1936
2008
  this.retentionTimer = setInterval(() => {
1937
2009
  this.scheduleRetentionCleanup(this.now() - this.retentionMs);
@@ -1946,13 +2018,25 @@ var ReplayManager = class {
1946
2018
  * or stopped bus does not keep hammering the store) and stop the sweep. */
1947
2019
  suspend() {
1948
2020
  this.retryGeneration += 1;
2021
+ if (this.hydration) {
2022
+ this.hydrationEpoch += 1;
2023
+ this.hydration = null;
2024
+ this.hydrationComplete = false;
2025
+ }
1949
2026
  this.pendingReplayPersistence = [];
1950
2027
  this.retentionCutoff = null;
1951
2028
  this.stop();
1952
2029
  }
1953
- /** Drop all in-memory buffers (used on full teardown). */
2030
+ /** Drop all in-memory buffers and require hydration for the next lifecycle. */
1954
2031
  resetBuffers() {
1955
2032
  this.buffers?.clear();
2033
+ this.hydrationEpoch += 1;
2034
+ this.hydration = null;
2035
+ this.hydrationComplete = false;
2036
+ this.hydrationFailed = false;
2037
+ this.hydrationClearAll = false;
2038
+ this.hydrationClearedTopics.clear();
2039
+ this.hydrationClearBefore = null;
1956
2040
  }
1957
2041
  /** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
1958
2042
  * payload footprint (same heuristic as adaptive load weighting), computed on
@@ -2004,38 +2088,78 @@ var ReplayManager = class {
2004
2088
  void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
2005
2089
  });
2006
2090
  }
2007
- /** Load durable history into the in-memory rings once at startup, pruning
2091
+ /** Start the active lifecycle's one-shot hydration, if it has not completed
2092
+ * or deliberately skipped hydration already. */
2093
+ requestHydration() {
2094
+ if (!this.buffers || !this.persistence) {
2095
+ this.hydrationComplete = true;
2096
+ return Promise.resolve();
2097
+ }
2098
+ if (this.hydration) return this.hydration;
2099
+ if (this.hydrationComplete) return Promise.resolve();
2100
+ const epoch = this.hydrationEpoch;
2101
+ const generation = this.retryGeneration;
2102
+ const operation = this.hydrate(epoch, generation);
2103
+ this.hydration = operation;
2104
+ return operation;
2105
+ }
2106
+ /** Load durable history into the in-memory rings once per lifecycle, pruning
2008
2107
  * entries past the retention window first. Failures are reported but do not
2009
2108
  * block startup — the bus runs with whatever survived. */
2010
- async hydrate() {
2109
+ async hydrate(epoch, generation) {
2011
2110
  if (!this.buffers || !this.persistence) {
2012
2111
  return;
2013
2112
  }
2014
2113
  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);
2114
+ try {
2115
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2116
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2117
+ }
2118
+ const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2119
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2120
+ if (epoch !== this.hydrationEpoch) return;
2121
+ const loadedByTopic = /* @__PURE__ */ new Map();
2122
+ for (const message of loaded) {
2123
+ if (this.hydrationClearAll || this.hydrationClearedTopics.has(message.topic) || this.hydrationClearBefore !== null && message.timestamp !== void 0 && message.timestamp < this.hydrationClearBefore) continue;
2124
+ let buffer = loadedByTopic.get(message.topic);
2125
+ if (!buffer) {
2126
+ buffer = [];
2127
+ loadedByTopic.set(message.topic, buffer);
2128
+ }
2129
+ buffer.push(message);
2130
+ }
2131
+ for (const [topic, durableBuffer] of loadedByTopic) {
2132
+ const liveBuffer = this.buffers.get(topic);
2133
+ this.buffers.set(topic, liveBuffer ? [...durableBuffer, ...liveBuffer] : durableBuffer);
2134
+ }
2135
+ const hydrationNow = this.now();
2136
+ for (const [topic, buffer] of this.buffers) {
2137
+ const pruned = pruneReplayHistory(buffer, {
2138
+ maxPerTopic: this.maxPerTopic,
2139
+ pruneStrategy: this.pruneStrategy,
2140
+ retentionMs: this.retentionMs,
2141
+ now: hydrationNow
2142
+ });
2143
+ if (pruned !== buffer) this.buffers.set(topic, pruned);
2144
+ }
2145
+ this.hydrationComplete = true;
2146
+ this.hydrationFailed = false;
2147
+ } catch (error) {
2148
+ if (generation !== this.retryGeneration) {
2149
+ this.onPersistenceError(
2150
+ error instanceof PersistenceRetryCancelledError ? error : new PersistenceRetryCancelledError()
2151
+ );
2152
+ return;
2024
2153
  }
2025
- buffer.push(message);
2154
+ if (epoch !== this.hydrationEpoch) return;
2155
+ this.onPersistenceError(error);
2156
+ this.hydrationComplete = true;
2157
+ this.hydrationFailed = true;
2026
2158
  }
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);
2159
+ } finally {
2160
+ if (this.hydrationEpoch === epoch && this.retryGeneration === generation) {
2161
+ this.hydration = null;
2036
2162
  }
2037
- } catch (error) {
2038
- this.onPersistenceError(error);
2039
2163
  }
2040
2164
  }
2041
2165
  /** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
@@ -2059,7 +2183,7 @@ var ReplayManager = class {
2059
2183
  }
2060
2184
  })().finally(() => {
2061
2185
  this.retentionCleanup = null;
2062
- if (this.retentionCutoff !== null && generation === this.retryGeneration) {
2186
+ if (this.retentionCutoff !== null) {
2063
2187
  this.scheduleRetentionCleanup(this.retentionCutoff);
2064
2188
  }
2065
2189
  });
@@ -2212,7 +2336,7 @@ var DedupManager = class {
2212
2336
  };
2213
2337
 
2214
2338
  // src/core/version.ts
2215
- var SDK_VERSION = true ? "0.20.91" : "";
2339
+ var SDK_VERSION = true ? "0.20.93" : "";
2216
2340
 
2217
2341
  // src/core/data-bus.ts
2218
2342
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2288,11 +2412,21 @@ var CrossTabDataBus = class {
2288
2412
  recoveryGateRelease = null;
2289
2413
  recoveryTimer = null;
2290
2414
  recoveryTimerToken = 0;
2415
+ // Invalidates operations parked on the recovery gate when a hide/stop
2416
+ // supersedes the recovery cycle. Their microtask may run after an immediate
2417
+ // explicit start has cleared `suspended`, so a state check alone is not
2418
+ // enough to keep stale work from reaching the replacement transport.
2419
+ recoveryCancellationToken = 0;
2291
2420
  // Once an automatic attempt fails, an explicit transport operation may
2292
2421
  // recover immediately instead of waiting for the next paced attempt. The
2293
2422
  // gate still stays closed so the operation cannot reach the failed
2294
2423
  // transport; it is released by the successful on-demand reopen.
2295
2424
  recoveryDemandAllowed = false;
2425
+ // Number of transport operations currently parked behind `recoveryGate`.
2426
+ // When an automatic attempt fails, these already-parked operations are
2427
+ // themselves demand: the failure path starts an on-demand reopen instead of
2428
+ // stranding them until some unrelated future operation arrives.
2429
+ recoveryWaiters = 0;
2296
2430
  /** Monotonic generation incremented on every successful transport open.
2297
2431
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2298
2432
  * transport has been reopened even if the timestamp window is short. */
@@ -2407,7 +2541,7 @@ var CrossTabDataBus = class {
2407
2541
  this.suspendTransport();
2408
2542
  },
2409
2543
  onResume: () => {
2410
- this.resumeSuspendedResources();
2544
+ if (!this.resumeSuspendedResources()) return;
2411
2545
  this.resumeTransport();
2412
2546
  },
2413
2547
  onDiagnostic: (event) => {
@@ -2436,11 +2570,13 @@ var CrossTabDataBus = class {
2436
2570
  const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2437
2571
  if (!transportDown) return Promise.resolve();
2438
2572
  this.activeConfig = config;
2439
- this.resetFailureState();
2573
+ this.resetFailureState(true);
2440
2574
  const resumingFromSuspend = this.suspended;
2441
- if (resumingFromSuspend) this.resumeSuspendedResources();
2575
+ if (resumingFromSuspend && !this.resumeSuspendedResources()) {
2576
+ return this.stopPromise ?? Promise.resolve();
2577
+ }
2442
2578
  const opening2 = this.reopenTransport();
2443
- if (resumingFromSuspend) this.cluster.start();
2579
+ if (resumingFromSuspend && !this.stopping && !this.suspended) this.cluster.start();
2444
2580
  return opening2;
2445
2581
  }
2446
2582
  this.started = true;
@@ -2448,12 +2584,6 @@ var CrossTabDataBus = class {
2448
2584
  this.suspended = false;
2449
2585
  this.activeConfig = config;
2450
2586
  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
2587
  const lifecycleEpoch = ++this.lifecycleEpoch;
2458
2588
  const opening = this.openTransport(
2459
2589
  config,
@@ -2462,7 +2592,17 @@ var CrossTabDataBus = class {
2462
2592
  lifecycleEpoch
2463
2593
  );
2464
2594
  this.startPromise = opening;
2595
+ this.trace.start();
2596
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2597
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
2598
+ this.startDedupSweep();
2599
+ this.replayManager.start();
2600
+ this.updateStatus(WORKER_STATUS.CONNECTING);
2601
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
2602
+ this.cluster.start();
2603
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
2465
2604
  for (const topic of this.topicHandlers.keys()) {
2605
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) break;
2466
2606
  this.cluster.subscribe(topic);
2467
2607
  }
2468
2608
  void opening.then(
@@ -2524,27 +2664,49 @@ var CrossTabDataBus = class {
2524
2664
  /** Cancel a pending automatic retry when an explicit lifecycle transition
2525
2665
  * supersedes it. The released gate re-enters runTransport(), which then
2526
2666
  * follows the newest start/stop/suspend intent. */
2527
- cancelScheduledRecovery() {
2667
+ cancelScheduledRecovery(invalidateParkedOperations = false, releaseGate = true) {
2528
2668
  this.recoveryTimerToken += 1;
2669
+ if (invalidateParkedOperations) this.recoveryCancellationToken += 1;
2529
2670
  if (this.recoveryTimer !== null) {
2530
2671
  clearTimeout(this.recoveryTimer);
2531
2672
  this.recoveryTimer = null;
2532
2673
  }
2533
- this.releaseRecoveryGate();
2674
+ if (releaseGate) this.releaseRecoveryGate();
2534
2675
  }
2535
2676
  /** Keep the recovery gate closed after a failed attempt while allowing the
2536
2677
  * next explicit transport operation to start an immediate on-demand reopen.
2537
- * If no gate/successor retry remains, release any waiters. */
2538
- allowDemandRecovery() {
2678
+ * If no gate/successor retry remains, release any waiters.
2679
+ *
2680
+ * `kickParkedWaiters` is set only when the failure is an automatic attempt: an
2681
+ * operation that was already parked on the gate is itself demand, so it must
2682
+ * not wait for some unrelated future operation. A failed *on-demand* reopen
2683
+ * passes `false`, so it re-arms the flag for a later operation instead of
2684
+ * looping on its own failure. */
2685
+ allowDemandRecovery(kickParkedWaiters = false) {
2539
2686
  if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
2540
2687
  this.recoveryDemandAllowed = true;
2688
+ if (kickParkedWaiters && this.recoveryWaiters > 0) this.startDemandRecovery();
2541
2689
  return;
2542
2690
  }
2543
2691
  this.releaseRecoveryGate();
2544
2692
  }
2693
+ /** Start one on-demand reopen if a failed attempt has left parked operations
2694
+ * and enabled demand recovery. Consumes the demand token so at most one
2695
+ * reopen is issued; every waiter stays behind the gate until it succeeds. */
2696
+ startDemandRecovery() {
2697
+ if (!this.recoveryDemandAllowed) return;
2698
+ if (this.status !== WORKER_STATUS.ERROR || this.suspended || this.stopping) return;
2699
+ this.recoveryDemandAllowed = false;
2700
+ const opening = this.reopenTransport();
2701
+ void opening.then(
2702
+ () => this.releaseRecoveryGate(),
2703
+ () => this.allowDemandRecovery()
2704
+ );
2705
+ }
2545
2706
  /** Reset failure and recovery diagnostics for a new explicit start session. */
2546
- resetFailureState() {
2547
- this.cancelScheduledRecovery();
2707
+ resetFailureState(preserveRecoveryGate = false) {
2708
+ this.cancelScheduledRecovery(false, !preserveRecoveryGate);
2709
+ if (preserveRecoveryGate) this.recoveryDemandAllowed = false;
2548
2710
  this.lastError = null;
2549
2711
  this.lastErrorAt = null;
2550
2712
  this.lastFailure = null;
@@ -2884,22 +3046,36 @@ var CrossTabDataBus = class {
2884
3046
  if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
2885
3047
  return Promise.resolve();
2886
3048
  }
3049
+ let resolveGate;
3050
+ const stopGate = new Promise((resolve) => {
3051
+ resolveGate = resolve;
3052
+ });
3053
+ this.stopPromise = stopGate;
3054
+ this.beginStop();
2887
3055
  const stopPromise = this.performStop();
2888
- this.stopPromise = stopPromise;
2889
3056
  void stopPromise.then(
2890
3057
  () => {
2891
- if (this.stopPromise === stopPromise) this.stopPromise = null;
3058
+ if (this.stopPromise === stopGate) this.stopPromise = null;
3059
+ resolveGate();
2892
3060
  },
2893
3061
  () => {
2894
- if (this.stopPromise === stopPromise) this.stopPromise = null;
3062
+ if (this.stopPromise === stopGate) this.stopPromise = null;
3063
+ resolveGate();
2895
3064
  }
2896
3065
  );
2897
- return stopPromise;
3066
+ return stopGate;
2898
3067
  }
2899
- async performStop() {
3068
+ /**
3069
+ * Synchronous teardown prelude. Flips `stopping` (the authoritative
3070
+ * in-flight signal), cancels scheduled work, releases handlers, and emits the
3071
+ * observable STOP lifecycle event. stop() calls it after the shared gate is
3072
+ * installed but in the same tick, so the stop still takes effect immediately
3073
+ * while a re-entrant stop() from the STOP event shares the one teardown.
3074
+ */
3075
+ beginStop() {
2900
3076
  this.lifecycleEpoch += 1;
2901
3077
  this.stopping = true;
2902
- this.cancelScheduledRecovery();
3078
+ this.cancelScheduledRecovery(true);
2903
3079
  this.replayManager.suspend();
2904
3080
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
2905
3081
  this.trace.stop();
@@ -2907,6 +3083,8 @@ var CrossTabDataBus = class {
2907
3083
  this.topicHandlers.clear();
2908
3084
  this.replayManager.resetBuffers();
2909
3085
  this.cluster.stop();
3086
+ }
3087
+ async performStop() {
2910
3088
  try {
2911
3089
  await this.startPromise?.catch(() => void 0);
2912
3090
  const pendingStop = this.pendingStop;
@@ -2923,8 +3101,6 @@ var CrossTabDataBus = class {
2923
3101
  this.transportReady = false;
2924
3102
  this.startPromise = null;
2925
3103
  this.pendingStop = null;
2926
- this.lastError = null;
2927
- this.lastErrorAt = null;
2928
3104
  this.activeConfig = void 0;
2929
3105
  this.recoveryAttempt = 0;
2930
3106
  this.recoveryExhausted = false;
@@ -3021,7 +3197,9 @@ var CrossTabDataBus = class {
3021
3197
  const opening = this.reopenTransport(attempt);
3022
3198
  void opening.then(
3023
3199
  () => this.releaseRecoveryGate(),
3024
- () => this.allowDemandRecovery()
3200
+ // Operations already parked on the gate are demand: run one
3201
+ // on-demand reopen now instead of waiting for an unrelated event.
3202
+ () => this.allowDemandRecovery(true)
3025
3203
  );
3026
3204
  }, this.recoveryCooldownMs);
3027
3205
  }
@@ -3124,10 +3302,13 @@ var CrossTabDataBus = class {
3124
3302
  * pageshow path and explicit start() must run this so an explicit resume
3125
3303
  * cannot leave trace metrics and periodic cleanup timers permanently off. */
3126
3304
  resumeSuspendedResources() {
3305
+ const lifecycleEpoch = this.lifecycleEpoch;
3127
3306
  this.trace.start();
3128
3307
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
3308
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return false;
3129
3309
  this.startDedupSweep();
3130
3310
  this.replayManager.start();
3311
+ return lifecycleEpoch === this.lifecycleEpoch && !this.stopping;
3131
3312
  }
3132
3313
  /**
3133
3314
  * Suspend the transport when the tab goes hidden. Stops the transport and
@@ -3135,12 +3316,13 @@ var CrossTabDataBus = class {
3135
3316
  */
3136
3317
  suspendTransport() {
3137
3318
  if (this.stopping) return;
3138
- this.lifecycleEpoch += 1;
3319
+ const suspensionEpoch = ++this.lifecycleEpoch;
3139
3320
  this.suspended = true;
3140
- this.cancelScheduledRecovery();
3321
+ this.cancelScheduledRecovery(true);
3141
3322
  this.transportReady = false;
3142
3323
  this.transportSubscribedTopics.clear();
3143
3324
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
3325
+ if (suspensionEpoch !== this.lifecycleEpoch || this.stopping || !this.suspended) return;
3144
3326
  if (this.pendingStop && (this.startPromise === null || this.startPromise === this.pendingStop)) {
3145
3327
  this.startPromise = this.pendingStop;
3146
3328
  return;
@@ -3175,13 +3357,18 @@ var CrossTabDataBus = class {
3175
3357
  if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
3176
3358
  const config = this.activeConfig;
3177
3359
  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
3360
  const lifecycleEpoch = ++this.lifecycleEpoch;
3182
3361
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3183
3362
  const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
3184
3363
  this.startPromise = opening;
3364
+ this.started = true;
3365
+ this.suspended = false;
3366
+ this.transportReady = false;
3367
+ this.updateStatus(WORKER_STATUS.CONNECTING);
3368
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping || this.suspended) {
3369
+ void opening.catch(() => void 0);
3370
+ return opening;
3371
+ }
3185
3372
  void opening.then(
3186
3373
  () => {
3187
3374
  if (this.startPromise === opening) this.startPromise = null;
@@ -3211,17 +3398,13 @@ var CrossTabDataBus = class {
3211
3398
  runTransport(operation) {
3212
3399
  if (this.suspended) return;
3213
3400
  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
3401
  const gate = this.recoveryGate;
3402
+ const cancellationToken = this.recoveryCancellationToken;
3403
+ this.startDemandRecovery();
3404
+ this.recoveryWaiters += 1;
3223
3405
  void gate.then(() => {
3224
- if (this.stopping || this.suspended) return;
3406
+ this.recoveryWaiters -= 1;
3407
+ if (this.stopping || this.suspended || cancellationToken !== this.recoveryCancellationToken) return;
3225
3408
  this.runTransport(operation);
3226
3409
  });
3227
3410
  return;
@@ -3345,4 +3528,4 @@ export {
3345
3528
  parseDataBusPublication,
3346
3529
  selectWorkerBackend
3347
3530
  };
3348
- //# sourceMappingURL=chunk-DMM5KBNG.js.map
3531
+ //# sourceMappingURL=chunk-OXEPZGXR.js.map