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.
@@ -224,6 +224,7 @@ function createStorageEventChannel(options) {
224
224
  };
225
225
  }
226
226
  var tabIdentityInitialized = false;
227
+ var cachedTabId = null;
227
228
  function createBrowserEnvironment(options) {
228
229
  const channelFallback = options?.channelFallback ?? CHANNEL_FALLBACK.NONE;
229
230
  return {
@@ -269,8 +270,9 @@ function canUseStorage(storage, probeKey) {
269
270
  if (!storage) return false;
270
271
  try {
271
272
  storage.setItem(probeKey, "1");
273
+ const readable = storage.getItem(probeKey) === "1";
272
274
  storage.removeItem(probeKey);
273
- return true;
275
+ return readable;
274
276
  } catch {
275
277
  return false;
276
278
  }
@@ -282,14 +284,21 @@ function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
282
284
  const hasOpener = typeof window !== "undefined" && Boolean(window.opener);
283
285
  if (existing && (!hasOpener || tabIdentityInitialized)) {
284
286
  tabIdentityInitialized = true;
287
+ cachedTabId = existing;
285
288
  return existing;
286
289
  }
290
+ if (tabIdentityInitialized && cachedTabId) return cachedTabId;
287
291
  const created = `tab-${environment.randomId()}`;
288
292
  storage?.setItem(key, created);
289
293
  tabIdentityInitialized = true;
294
+ cachedTabId = created;
290
295
  return created;
291
296
  } catch {
292
- return `tab-${environment.randomId()}`;
297
+ if (cachedTabId) return cachedTabId;
298
+ const created = `tab-${environment.randomId()}`;
299
+ tabIdentityInitialized = true;
300
+ cachedTabId = created;
301
+ return created;
293
302
  }
294
303
  }
295
304
 
@@ -439,7 +448,17 @@ var BatchingStorageWriter = class {
439
448
  clear() {
440
449
  this.pending.clear();
441
450
  this.flushScheduled = false;
451
+ this.cancelRetry();
452
+ this.retryCount.clear();
453
+ this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
442
454
  this.storage.clear();
455
+ }
456
+ /** Drop queued mutations and cancel retry state without clearing storage.
457
+ * Used when the owning runtime is torn down: final best-effort writes have
458
+ * already been flushed, and failed writes must not keep timers alive. */
459
+ discardPending() {
460
+ this.pending.clear();
461
+ this.flushScheduled = false;
443
462
  this.cancelRetry();
444
463
  this.retryCount.clear();
445
464
  this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
@@ -740,6 +759,9 @@ var WorkerClusterRuntime = class {
740
759
  started = false;
741
760
  suspended = false;
742
761
  lifecycleListening = false;
762
+ /** Invalidates an in-flight pageshow resume when a synchronous onResume
763
+ * callback stops or pauses the cluster before activate() is reached. */
764
+ lifecycleGeneration = 0;
743
765
  currentRecord;
744
766
  constructor(options) {
745
767
  assertClusterOptions(options);
@@ -776,8 +798,14 @@ var WorkerClusterRuntime = class {
776
798
  /** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
777
799
  start() {
778
800
  if (this.started) return;
779
- this.suspended = false;
801
+ this.lifecycleGeneration += 1;
780
802
  this.addLifecycleListeners();
803
+ if (this.environment.getVisibilityState() === TAB_VISIBILITY.HIDDEN) {
804
+ this.suspended = true;
805
+ this.handlers.onSuspend?.();
806
+ return;
807
+ }
808
+ this.suspended = false;
781
809
  this.activate();
782
810
  }
783
811
  /**
@@ -788,6 +816,7 @@ var WorkerClusterRuntime = class {
788
816
  * stop() path where callers expect every Set/Map to be empty afterwards.
789
817
  */
790
818
  stop() {
819
+ this.lifecycleGeneration += 1;
791
820
  if (!this.started && !this.suspended) return;
792
821
  this.pause();
793
822
  this.flushStorage();
@@ -806,7 +835,15 @@ var WorkerClusterRuntime = class {
806
835
  activate() {
807
836
  if (this.started) return;
808
837
  this.started = true;
809
- this.channel = this.storage ? this.environment.createChannel(this.channelName) : null;
838
+ if (this.storage) {
839
+ try {
840
+ this.channel = this.environment.createChannel(this.channelName);
841
+ } catch {
842
+ this.channel = null;
843
+ }
844
+ } else {
845
+ this.channel = null;
846
+ }
810
847
  if (!this.channel) this.storage = null;
811
848
  this.channel?.addEventListener("message", this.handleMessage);
812
849
  const now = this.environment.now();
@@ -834,7 +871,11 @@ var WorkerClusterRuntime = class {
834
871
  * to other workers, remove our worker record, and close the channel.
835
872
  */
836
873
  pause() {
837
- if (!this.started) return;
874
+ this.lifecycleGeneration += 1;
875
+ if (!this.started) {
876
+ if (this.lifecycleListening) this.suspended = true;
877
+ return;
878
+ }
838
879
  this.started = false;
839
880
  this.suspended = true;
840
881
  if (this.heartbeatHandle !== null) this.environment.clearInterval(this.heartbeatHandle);
@@ -848,6 +889,7 @@ var WorkerClusterRuntime = class {
848
889
  this.removeStorage(this.workerStorageKey(this.workerId));
849
890
  this.flushStorage();
850
891
  this.notifyRegistry();
892
+ if (this.storage instanceof BatchingStorageWriter) this.storage.discardPending();
851
893
  const channel = this.channel;
852
894
  this.channel = null;
853
895
  this.handlers.onSuspend?.();
@@ -1149,8 +1191,10 @@ var WorkerClusterRuntime = class {
1149
1191
  handlePageHide = () => this.pause();
1150
1192
  handlePageShow = () => {
1151
1193
  if (!this.suspended) return;
1194
+ const generation = ++this.lifecycleGeneration;
1152
1195
  this.suspended = false;
1153
1196
  this.handlers.onResume?.();
1197
+ if (generation !== this.lifecycleGeneration) return;
1154
1198
  this.activate();
1155
1199
  };
1156
1200
  handleVisibilityChange = () => {
@@ -1205,10 +1249,16 @@ var WorkerClusterRuntime = class {
1205
1249
  if (message.targetWorkerId !== this.workerId) return;
1206
1250
  this.rememberTopic(message.topic);
1207
1251
  switch (message.action) {
1208
- case CONTROL_ACTION.SUBSCRIBE:
1252
+ case CONTROL_ACTION.SUBSCRIBE: {
1253
+ const route = this.readRoute(message.topicKey);
1254
+ if (route && route.workerId !== this.workerId) return;
1255
+ if (route?.workerId === this.workerId && route.handoffFromWorkerId !== void 0 && route.confirmedAt === void 0) {
1256
+ return;
1257
+ }
1209
1258
  this.assignedTopics.set(message.topicKey, message.topic);
1210
1259
  this.confirmRoute(message.topicKey);
1211
1260
  break;
1261
+ }
1212
1262
  case CONTROL_ACTION.UNSUBSCRIBE:
1213
1263
  if (this.releaseHandoffOnUnsubscribe(message)) return;
1214
1264
  break;
@@ -1269,8 +1319,8 @@ var WorkerClusterRuntime = class {
1269
1319
  }
1270
1320
  /**
1271
1321
  * Accept a graceful handoff only when the route still points to this worker,
1272
- * the release comes from the recorded previous owner, and the generation is
1273
- * at least as new as ours. Any other ROUTE_RELEASED is stale and dropped.
1322
+ * the release comes from the recorded previous owner, and the generation
1323
+ * exactly matches ours. Any other ROUTE_RELEASED is stale and dropped.
1274
1324
  */
1275
1325
  handleRouteReleasedMessage(message) {
1276
1326
  if (message.targetWorkerId !== this.workerId) return;
@@ -1283,11 +1333,11 @@ var WorkerClusterRuntime = class {
1283
1333
  }
1284
1334
  /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
1285
1335
  * points to us, the release comes from the recorded previous owner, and
1286
- * the release generation is at least as new as ours. A replayed ACK from an
1287
- * earlier handoff round (e.g. an a↔b ping-pong) carries an older generation
1288
- * and must not confirm the current round. */
1336
+ * the release generation exactly matches ours. A delayed ACK from either an
1337
+ * earlier or later handoff round belongs to a different route and must not
1338
+ * confirm the current round. */
1289
1339
  isStaleRouteRelease(route, message) {
1290
- return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation < route.generation;
1340
+ return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation !== route.generation;
1291
1341
  }
1292
1342
  /** True when an unconfirmed handoff route has been stuck longer than a
1293
1343
  * worker TTL. The ACK for a live handoff is posted synchronously with the
@@ -1914,7 +1964,7 @@ var ReplayManager = class {
1914
1964
  this.trace = deps.trace;
1915
1965
  this.onPersistenceError = deps.onPersistenceError;
1916
1966
  this.onDispatchError = deps.onDispatchError;
1917
- this.hydration = this.hydrate();
1967
+ void this.requestHydration();
1918
1968
  }
1919
1969
  deps;
1920
1970
  buffers;
@@ -1933,7 +1983,18 @@ var ReplayManager = class {
1933
1983
  retryGeneration = 0;
1934
1984
  pendingReplayPersistence = [];
1935
1985
  persistenceFlushScheduled = false;
1936
- hydration;
1986
+ /** Current hydration operation, if one belongs to the active lifecycle. */
1987
+ hydration = null;
1988
+ /** Invalidates a load from a superseded suspend/reset lifecycle. */
1989
+ hydrationEpoch = 0;
1990
+ /** Whether the active lifecycle has finished (or deliberately skipped) hydration. */
1991
+ hydrationComplete = false;
1992
+ /** A failed load is retried by the next explicit start(), not every replay request. */
1993
+ hydrationFailed = false;
1994
+ /** Mutations that must be applied to a load already in flight. */
1995
+ hydrationClearAll = false;
1996
+ hydrationClearedTopics = /* @__PURE__ */ new Set();
1997
+ hydrationClearBefore = null;
1937
1998
  /** Coalesced retention cleanup: the newest cutoff wins while one is running. */
1938
1999
  retentionCleanup = null;
1939
2000
  retentionCutoff = null;
@@ -1987,7 +2048,7 @@ var ReplayManager = class {
1987
2048
  if (!this.buffers) return;
1988
2049
  const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
1989
2050
  if (this.persistence) {
1990
- void this.hydration.then(() => {
2051
+ void this.requestHydration().then(() => {
1991
2052
  if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
1992
2053
  });
1993
2054
  return;
@@ -1999,22 +2060,24 @@ var ReplayManager = class {
1999
2060
  * clearTopic), and prune durable history. */
2000
2061
  onTopicUnsubscribed(topic) {
2001
2062
  if (!this.buffers) return;
2063
+ const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
2064
+ this.hydrationClearedTopics.add(topic);
2002
2065
  this.buffers.delete(topic);
2003
2066
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
2004
- if (this.persistence?.clearTopic) {
2005
- void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
2006
- }
2067
+ if (clearing) void clearing.catch((error) => this.onPersistenceError(error));
2007
2068
  }
2008
2069
  /** Clear all in-memory replay buffers and, when supported, durable history.
2009
2070
  * Reports persistence failures and rethrows, mirroring the public API
2010
2071
  * contract that callers can observe a failed clear. */
2011
2072
  async clearAll() {
2012
2073
  if (!this.buffers) return;
2074
+ const clearing = this.persistence?.clear ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear()) : null;
2075
+ this.hydrationClearAll = true;
2013
2076
  this.buffers.clear();
2014
2077
  this.pendingReplayPersistence = [];
2015
- if (this.persistence?.clear) {
2078
+ if (clearing) {
2016
2079
  try {
2017
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
2080
+ await clearing;
2018
2081
  } catch (error) {
2019
2082
  this.onPersistenceError(error);
2020
2083
  throw error;
@@ -2024,11 +2087,13 @@ var ReplayManager = class {
2024
2087
  /** Clear replay history for one exact topic, including durable storage. */
2025
2088
  async clearTopic(topic) {
2026
2089
  if (!this.buffers) return;
2090
+ const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
2091
+ this.hydrationClearedTopics.add(topic);
2027
2092
  this.buffers.delete(topic);
2028
2093
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
2029
- if (this.persistence?.clearTopic) {
2094
+ if (clearing) {
2030
2095
  try {
2031
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
2096
+ await clearing;
2032
2097
  } catch (error) {
2033
2098
  this.onPersistenceError(error);
2034
2099
  throw error;
@@ -2038,7 +2103,9 @@ var ReplayManager = class {
2038
2103
  /** Remove replay entries older than an epoch-millisecond cutoff. */
2039
2104
  async clearBefore(timestamp) {
2040
2105
  if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
2106
+ const clearing = this.persistence?.clearBefore ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp)) : null;
2041
2107
  if (this.buffers) {
2108
+ this.hydrationClearBefore = this.hydrationClearBefore === null ? timestamp : Math.max(this.hydrationClearBefore, timestamp);
2042
2109
  for (const [topic, messages] of this.buffers) {
2043
2110
  const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
2044
2111
  if (kept.length) this.buffers.set(topic, kept);
@@ -2048,9 +2115,9 @@ var ReplayManager = class {
2048
2115
  this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
2049
2116
  (message) => message.timestamp === void 0 || message.timestamp >= timestamp
2050
2117
  );
2051
- if (this.persistence?.clearBefore) {
2118
+ if (clearing) {
2052
2119
  try {
2053
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
2120
+ await clearing;
2054
2121
  } catch (error) {
2055
2122
  this.onPersistenceError(error);
2056
2123
  throw error;
@@ -2060,6 +2127,11 @@ var ReplayManager = class {
2060
2127
  /** Start the periodic retention sweep. No-op when no durable retention
2061
2128
  * config makes it necessary. */
2062
2129
  start() {
2130
+ if (this.hydrationFailed) {
2131
+ this.hydrationComplete = false;
2132
+ this.hydrationFailed = false;
2133
+ }
2134
+ void this.requestHydration();
2063
2135
  if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
2064
2136
  this.retentionTimer = setInterval(() => {
2065
2137
  this.scheduleRetentionCleanup(this.now() - this.retentionMs);
@@ -2074,13 +2146,25 @@ var ReplayManager = class {
2074
2146
  * or stopped bus does not keep hammering the store) and stop the sweep. */
2075
2147
  suspend() {
2076
2148
  this.retryGeneration += 1;
2149
+ if (this.hydration) {
2150
+ this.hydrationEpoch += 1;
2151
+ this.hydration = null;
2152
+ this.hydrationComplete = false;
2153
+ }
2077
2154
  this.pendingReplayPersistence = [];
2078
2155
  this.retentionCutoff = null;
2079
2156
  this.stop();
2080
2157
  }
2081
- /** Drop all in-memory buffers (used on full teardown). */
2158
+ /** Drop all in-memory buffers and require hydration for the next lifecycle. */
2082
2159
  resetBuffers() {
2083
2160
  this.buffers?.clear();
2161
+ this.hydrationEpoch += 1;
2162
+ this.hydration = null;
2163
+ this.hydrationComplete = false;
2164
+ this.hydrationFailed = false;
2165
+ this.hydrationClearAll = false;
2166
+ this.hydrationClearedTopics.clear();
2167
+ this.hydrationClearBefore = null;
2084
2168
  }
2085
2169
  /** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
2086
2170
  * payload footprint (same heuristic as adaptive load weighting), computed on
@@ -2132,38 +2216,78 @@ var ReplayManager = class {
2132
2216
  void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
2133
2217
  });
2134
2218
  }
2135
- /** Load durable history into the in-memory rings once at startup, pruning
2219
+ /** Start the active lifecycle's one-shot hydration, if it has not completed
2220
+ * or deliberately skipped hydration already. */
2221
+ requestHydration() {
2222
+ if (!this.buffers || !this.persistence) {
2223
+ this.hydrationComplete = true;
2224
+ return Promise.resolve();
2225
+ }
2226
+ if (this.hydration) return this.hydration;
2227
+ if (this.hydrationComplete) return Promise.resolve();
2228
+ const epoch = this.hydrationEpoch;
2229
+ const generation = this.retryGeneration;
2230
+ const operation = this.hydrate(epoch, generation);
2231
+ this.hydration = operation;
2232
+ return operation;
2233
+ }
2234
+ /** Load durable history into the in-memory rings once per lifecycle, pruning
2136
2235
  * entries past the retention window first. Failures are reported but do not
2137
2236
  * block startup — the bus runs with whatever survived. */
2138
- async hydrate() {
2237
+ async hydrate(epoch, generation) {
2139
2238
  if (!this.buffers || !this.persistence) {
2140
2239
  return;
2141
2240
  }
2142
2241
  try {
2143
- if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2144
- await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2145
- }
2146
- const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2147
- for (const message of loaded) {
2148
- let buffer = this.buffers.get(message.topic);
2149
- if (!buffer) {
2150
- buffer = [];
2151
- this.buffers.set(message.topic, buffer);
2242
+ try {
2243
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2244
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2245
+ }
2246
+ const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2247
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2248
+ if (epoch !== this.hydrationEpoch) return;
2249
+ const loadedByTopic = /* @__PURE__ */ new Map();
2250
+ for (const message of loaded) {
2251
+ if (this.hydrationClearAll || this.hydrationClearedTopics.has(message.topic) || this.hydrationClearBefore !== null && message.timestamp !== void 0 && message.timestamp < this.hydrationClearBefore) continue;
2252
+ let buffer = loadedByTopic.get(message.topic);
2253
+ if (!buffer) {
2254
+ buffer = [];
2255
+ loadedByTopic.set(message.topic, buffer);
2256
+ }
2257
+ buffer.push(message);
2258
+ }
2259
+ for (const [topic, durableBuffer] of loadedByTopic) {
2260
+ const liveBuffer = this.buffers.get(topic);
2261
+ this.buffers.set(topic, liveBuffer ? [...durableBuffer, ...liveBuffer] : durableBuffer);
2262
+ }
2263
+ const hydrationNow = this.now();
2264
+ for (const [topic, buffer] of this.buffers) {
2265
+ const pruned = pruneReplayHistory(buffer, {
2266
+ maxPerTopic: this.maxPerTopic,
2267
+ pruneStrategy: this.pruneStrategy,
2268
+ retentionMs: this.retentionMs,
2269
+ now: hydrationNow
2270
+ });
2271
+ if (pruned !== buffer) this.buffers.set(topic, pruned);
2272
+ }
2273
+ this.hydrationComplete = true;
2274
+ this.hydrationFailed = false;
2275
+ } catch (error) {
2276
+ if (generation !== this.retryGeneration) {
2277
+ this.onPersistenceError(
2278
+ error instanceof PersistenceRetryCancelledError ? error : new PersistenceRetryCancelledError()
2279
+ );
2280
+ return;
2152
2281
  }
2153
- buffer.push(message);
2282
+ if (epoch !== this.hydrationEpoch) return;
2283
+ this.onPersistenceError(error);
2284
+ this.hydrationComplete = true;
2285
+ this.hydrationFailed = true;
2154
2286
  }
2155
- const hydrationNow = this.now();
2156
- for (const [topic, buffer] of this.buffers) {
2157
- const pruned = pruneReplayHistory(buffer, {
2158
- maxPerTopic: this.maxPerTopic,
2159
- pruneStrategy: this.pruneStrategy,
2160
- retentionMs: this.retentionMs,
2161
- now: hydrationNow
2162
- });
2163
- if (pruned !== buffer) this.buffers.set(topic, pruned);
2287
+ } finally {
2288
+ if (this.hydrationEpoch === epoch && this.retryGeneration === generation) {
2289
+ this.hydration = null;
2164
2290
  }
2165
- } catch (error) {
2166
- this.onPersistenceError(error);
2167
2291
  }
2168
2292
  }
2169
2293
  /** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
@@ -2187,7 +2311,7 @@ var ReplayManager = class {
2187
2311
  }
2188
2312
  })().finally(() => {
2189
2313
  this.retentionCleanup = null;
2190
- if (this.retentionCutoff !== null && generation === this.retryGeneration) {
2314
+ if (this.retentionCutoff !== null) {
2191
2315
  this.scheduleRetentionCleanup(this.retentionCutoff);
2192
2316
  }
2193
2317
  });
@@ -2340,7 +2464,7 @@ var DedupManager = class {
2340
2464
  };
2341
2465
 
2342
2466
  // src/core/version.ts
2343
- var SDK_VERSION = true ? "0.20.91" : "";
2467
+ var SDK_VERSION = true ? "0.20.93" : "";
2344
2468
 
2345
2469
  // src/core/data-bus.ts
2346
2470
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2416,11 +2540,21 @@ var CrossTabDataBus = class {
2416
2540
  recoveryGateRelease = null;
2417
2541
  recoveryTimer = null;
2418
2542
  recoveryTimerToken = 0;
2543
+ // Invalidates operations parked on the recovery gate when a hide/stop
2544
+ // supersedes the recovery cycle. Their microtask may run after an immediate
2545
+ // explicit start has cleared `suspended`, so a state check alone is not
2546
+ // enough to keep stale work from reaching the replacement transport.
2547
+ recoveryCancellationToken = 0;
2419
2548
  // Once an automatic attempt fails, an explicit transport operation may
2420
2549
  // recover immediately instead of waiting for the next paced attempt. The
2421
2550
  // gate still stays closed so the operation cannot reach the failed
2422
2551
  // transport; it is released by the successful on-demand reopen.
2423
2552
  recoveryDemandAllowed = false;
2553
+ // Number of transport operations currently parked behind `recoveryGate`.
2554
+ // When an automatic attempt fails, these already-parked operations are
2555
+ // themselves demand: the failure path starts an on-demand reopen instead of
2556
+ // stranding them until some unrelated future operation arrives.
2557
+ recoveryWaiters = 0;
2424
2558
  /** Monotonic generation incremented on every successful transport open.
2425
2559
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2426
2560
  * transport has been reopened even if the timestamp window is short. */
@@ -2535,7 +2669,7 @@ var CrossTabDataBus = class {
2535
2669
  this.suspendTransport();
2536
2670
  },
2537
2671
  onResume: () => {
2538
- this.resumeSuspendedResources();
2672
+ if (!this.resumeSuspendedResources()) return;
2539
2673
  this.resumeTransport();
2540
2674
  },
2541
2675
  onDiagnostic: (event) => {
@@ -2564,11 +2698,13 @@ var CrossTabDataBus = class {
2564
2698
  const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2565
2699
  if (!transportDown) return Promise.resolve();
2566
2700
  this.activeConfig = config;
2567
- this.resetFailureState();
2701
+ this.resetFailureState(true);
2568
2702
  const resumingFromSuspend = this.suspended;
2569
- if (resumingFromSuspend) this.resumeSuspendedResources();
2703
+ if (resumingFromSuspend && !this.resumeSuspendedResources()) {
2704
+ return this.stopPromise ?? Promise.resolve();
2705
+ }
2570
2706
  const opening2 = this.reopenTransport();
2571
- if (resumingFromSuspend) this.cluster.start();
2707
+ if (resumingFromSuspend && !this.stopping && !this.suspended) this.cluster.start();
2572
2708
  return opening2;
2573
2709
  }
2574
2710
  this.started = true;
@@ -2576,12 +2712,6 @@ var CrossTabDataBus = class {
2576
2712
  this.suspended = false;
2577
2713
  this.activeConfig = config;
2578
2714
  this.resetFailureState();
2579
- this.trace.start();
2580
- this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2581
- this.startDedupSweep();
2582
- this.replayManager.start();
2583
- this.updateStatus(WORKER_STATUS.CONNECTING);
2584
- this.cluster.start();
2585
2715
  const lifecycleEpoch = ++this.lifecycleEpoch;
2586
2716
  const opening = this.openTransport(
2587
2717
  config,
@@ -2590,7 +2720,17 @@ var CrossTabDataBus = class {
2590
2720
  lifecycleEpoch
2591
2721
  );
2592
2722
  this.startPromise = opening;
2723
+ this.trace.start();
2724
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2725
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
2726
+ this.startDedupSweep();
2727
+ this.replayManager.start();
2728
+ this.updateStatus(WORKER_STATUS.CONNECTING);
2729
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
2730
+ this.cluster.start();
2731
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
2593
2732
  for (const topic of this.topicHandlers.keys()) {
2733
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) break;
2594
2734
  this.cluster.subscribe(topic);
2595
2735
  }
2596
2736
  void opening.then(
@@ -2652,27 +2792,49 @@ var CrossTabDataBus = class {
2652
2792
  /** Cancel a pending automatic retry when an explicit lifecycle transition
2653
2793
  * supersedes it. The released gate re-enters runTransport(), which then
2654
2794
  * follows the newest start/stop/suspend intent. */
2655
- cancelScheduledRecovery() {
2795
+ cancelScheduledRecovery(invalidateParkedOperations = false, releaseGate = true) {
2656
2796
  this.recoveryTimerToken += 1;
2797
+ if (invalidateParkedOperations) this.recoveryCancellationToken += 1;
2657
2798
  if (this.recoveryTimer !== null) {
2658
2799
  clearTimeout(this.recoveryTimer);
2659
2800
  this.recoveryTimer = null;
2660
2801
  }
2661
- this.releaseRecoveryGate();
2802
+ if (releaseGate) this.releaseRecoveryGate();
2662
2803
  }
2663
2804
  /** Keep the recovery gate closed after a failed attempt while allowing the
2664
2805
  * next explicit transport operation to start an immediate on-demand reopen.
2665
- * If no gate/successor retry remains, release any waiters. */
2666
- allowDemandRecovery() {
2806
+ * If no gate/successor retry remains, release any waiters.
2807
+ *
2808
+ * `kickParkedWaiters` is set only when the failure is an automatic attempt: an
2809
+ * operation that was already parked on the gate is itself demand, so it must
2810
+ * not wait for some unrelated future operation. A failed *on-demand* reopen
2811
+ * passes `false`, so it re-arms the flag for a later operation instead of
2812
+ * looping on its own failure. */
2813
+ allowDemandRecovery(kickParkedWaiters = false) {
2667
2814
  if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
2668
2815
  this.recoveryDemandAllowed = true;
2816
+ if (kickParkedWaiters && this.recoveryWaiters > 0) this.startDemandRecovery();
2669
2817
  return;
2670
2818
  }
2671
2819
  this.releaseRecoveryGate();
2672
2820
  }
2821
+ /** Start one on-demand reopen if a failed attempt has left parked operations
2822
+ * and enabled demand recovery. Consumes the demand token so at most one
2823
+ * reopen is issued; every waiter stays behind the gate until it succeeds. */
2824
+ startDemandRecovery() {
2825
+ if (!this.recoveryDemandAllowed) return;
2826
+ if (this.status !== WORKER_STATUS.ERROR || this.suspended || this.stopping) return;
2827
+ this.recoveryDemandAllowed = false;
2828
+ const opening = this.reopenTransport();
2829
+ void opening.then(
2830
+ () => this.releaseRecoveryGate(),
2831
+ () => this.allowDemandRecovery()
2832
+ );
2833
+ }
2673
2834
  /** Reset failure and recovery diagnostics for a new explicit start session. */
2674
- resetFailureState() {
2675
- this.cancelScheduledRecovery();
2835
+ resetFailureState(preserveRecoveryGate = false) {
2836
+ this.cancelScheduledRecovery(false, !preserveRecoveryGate);
2837
+ if (preserveRecoveryGate) this.recoveryDemandAllowed = false;
2676
2838
  this.lastError = null;
2677
2839
  this.lastErrorAt = null;
2678
2840
  this.lastFailure = null;
@@ -3012,22 +3174,36 @@ var CrossTabDataBus = class {
3012
3174
  if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
3013
3175
  return Promise.resolve();
3014
3176
  }
3177
+ let resolveGate;
3178
+ const stopGate = new Promise((resolve) => {
3179
+ resolveGate = resolve;
3180
+ });
3181
+ this.stopPromise = stopGate;
3182
+ this.beginStop();
3015
3183
  const stopPromise = this.performStop();
3016
- this.stopPromise = stopPromise;
3017
3184
  void stopPromise.then(
3018
3185
  () => {
3019
- if (this.stopPromise === stopPromise) this.stopPromise = null;
3186
+ if (this.stopPromise === stopGate) this.stopPromise = null;
3187
+ resolveGate();
3020
3188
  },
3021
3189
  () => {
3022
- if (this.stopPromise === stopPromise) this.stopPromise = null;
3190
+ if (this.stopPromise === stopGate) this.stopPromise = null;
3191
+ resolveGate();
3023
3192
  }
3024
3193
  );
3025
- return stopPromise;
3194
+ return stopGate;
3026
3195
  }
3027
- async performStop() {
3196
+ /**
3197
+ * Synchronous teardown prelude. Flips `stopping` (the authoritative
3198
+ * in-flight signal), cancels scheduled work, releases handlers, and emits the
3199
+ * observable STOP lifecycle event. stop() calls it after the shared gate is
3200
+ * installed but in the same tick, so the stop still takes effect immediately
3201
+ * while a re-entrant stop() from the STOP event shares the one teardown.
3202
+ */
3203
+ beginStop() {
3028
3204
  this.lifecycleEpoch += 1;
3029
3205
  this.stopping = true;
3030
- this.cancelScheduledRecovery();
3206
+ this.cancelScheduledRecovery(true);
3031
3207
  this.replayManager.suspend();
3032
3208
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
3033
3209
  this.trace.stop();
@@ -3035,6 +3211,8 @@ var CrossTabDataBus = class {
3035
3211
  this.topicHandlers.clear();
3036
3212
  this.replayManager.resetBuffers();
3037
3213
  this.cluster.stop();
3214
+ }
3215
+ async performStop() {
3038
3216
  try {
3039
3217
  await this.startPromise?.catch(() => void 0);
3040
3218
  const pendingStop = this.pendingStop;
@@ -3051,8 +3229,6 @@ var CrossTabDataBus = class {
3051
3229
  this.transportReady = false;
3052
3230
  this.startPromise = null;
3053
3231
  this.pendingStop = null;
3054
- this.lastError = null;
3055
- this.lastErrorAt = null;
3056
3232
  this.activeConfig = void 0;
3057
3233
  this.recoveryAttempt = 0;
3058
3234
  this.recoveryExhausted = false;
@@ -3149,7 +3325,9 @@ var CrossTabDataBus = class {
3149
3325
  const opening = this.reopenTransport(attempt);
3150
3326
  void opening.then(
3151
3327
  () => this.releaseRecoveryGate(),
3152
- () => this.allowDemandRecovery()
3328
+ // Operations already parked on the gate are demand: run one
3329
+ // on-demand reopen now instead of waiting for an unrelated event.
3330
+ () => this.allowDemandRecovery(true)
3153
3331
  );
3154
3332
  }, this.recoveryCooldownMs);
3155
3333
  }
@@ -3252,10 +3430,13 @@ var CrossTabDataBus = class {
3252
3430
  * pageshow path and explicit start() must run this so an explicit resume
3253
3431
  * cannot leave trace metrics and periodic cleanup timers permanently off. */
3254
3432
  resumeSuspendedResources() {
3433
+ const lifecycleEpoch = this.lifecycleEpoch;
3255
3434
  this.trace.start();
3256
3435
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
3436
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return false;
3257
3437
  this.startDedupSweep();
3258
3438
  this.replayManager.start();
3439
+ return lifecycleEpoch === this.lifecycleEpoch && !this.stopping;
3259
3440
  }
3260
3441
  /**
3261
3442
  * Suspend the transport when the tab goes hidden. Stops the transport and
@@ -3263,12 +3444,13 @@ var CrossTabDataBus = class {
3263
3444
  */
3264
3445
  suspendTransport() {
3265
3446
  if (this.stopping) return;
3266
- this.lifecycleEpoch += 1;
3447
+ const suspensionEpoch = ++this.lifecycleEpoch;
3267
3448
  this.suspended = true;
3268
- this.cancelScheduledRecovery();
3449
+ this.cancelScheduledRecovery(true);
3269
3450
  this.transportReady = false;
3270
3451
  this.transportSubscribedTopics.clear();
3271
3452
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
3453
+ if (suspensionEpoch !== this.lifecycleEpoch || this.stopping || !this.suspended) return;
3272
3454
  if (this.pendingStop && (this.startPromise === null || this.startPromise === this.pendingStop)) {
3273
3455
  this.startPromise = this.pendingStop;
3274
3456
  return;
@@ -3303,13 +3485,18 @@ var CrossTabDataBus = class {
3303
3485
  if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
3304
3486
  const config = this.activeConfig;
3305
3487
  const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
3306
- this.started = true;
3307
- this.suspended = false;
3308
- this.updateStatus(WORKER_STATUS.CONNECTING);
3309
3488
  const lifecycleEpoch = ++this.lifecycleEpoch;
3310
3489
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3311
3490
  const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
3312
3491
  this.startPromise = opening;
3492
+ this.started = true;
3493
+ this.suspended = false;
3494
+ this.transportReady = false;
3495
+ this.updateStatus(WORKER_STATUS.CONNECTING);
3496
+ if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping || this.suspended) {
3497
+ void opening.catch(() => void 0);
3498
+ return opening;
3499
+ }
3313
3500
  void opening.then(
3314
3501
  () => {
3315
3502
  if (this.startPromise === opening) this.startPromise = null;
@@ -3339,17 +3526,13 @@ var CrossTabDataBus = class {
3339
3526
  runTransport(operation) {
3340
3527
  if (this.suspended) return;
3341
3528
  if (this.recoveryGate && !this.stopping) {
3342
- if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
3343
- this.recoveryDemandAllowed = false;
3344
- const opening = this.reopenTransport();
3345
- void opening.then(
3346
- () => this.releaseRecoveryGate(),
3347
- () => this.allowDemandRecovery()
3348
- );
3349
- }
3350
3529
  const gate = this.recoveryGate;
3530
+ const cancellationToken = this.recoveryCancellationToken;
3531
+ this.startDemandRecovery();
3532
+ this.recoveryWaiters += 1;
3351
3533
  void gate.then(() => {
3352
- if (this.stopping || this.suspended) return;
3534
+ this.recoveryWaiters -= 1;
3535
+ if (this.stopping || this.suspended || cancellationToken !== this.recoveryCancellationToken) return;
3353
3536
  this.runTransport(operation);
3354
3537
  });
3355
3538
  return;