cross-tab-worker-databus 0.20.85 → 0.20.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,21 @@
1
- ## [Unreleased]
1
+ ## [0.20.86] - 2026-09-16
2
2
 
3
3
  ### Added
4
4
  - The configuration reference now documents the full public option surface: `replay.pruneStrategy` (previously absent entirely) and the replay options table (`maxPerTopic`, `persistence`, `retentionMs`, `pruneStrategy`, `retentionSweepMs`, `persistenceRetry`), plus a deduplication options table (`maxEntries`, `ttlMs`, `sweepMs`, `now`, `adaptiveTtl`) — in both languages. A guard derives the field list from the built declarations and fails when a `DataBusReplayOptions`/`DataBusDedupOptions` field is undocumented in either configuration reference. The `pruneStrategy` JSDoc no longer claims a default of `'both'` (the actual default is `'count'`).
5
5
  - Property invariants for `selectActiveWorkers` / `selectRebalanceTarget`: the returned worker set is always a subset of the input, active selection stays within `maxActiveWorkers` and non-empty for a non-empty input, and neither helper throws on arbitrary corrupt worker records.
6
6
 
7
+ ### Fixed
8
+ - `ready()` no longer reports readiness for a restart that a later `stop()` canceled. The queued `start()` promise retains its documented resolve-on-cancellation behavior, but readiness now rejects with a clear lifecycle error instead of resolving against a stopped bus.
9
+ - A failed restart queued behind an in-flight `stop()` now remains observable through `ready()`. Previously the failure cleared `started`, so a later `ready()` without `initialConfig` was masked by the generic "requires initialConfig" error; it now resurfaces the actual transport startup failure and preserves the explicit manual-retry path.
10
+ - `publish()` and non-empty `publishBatch()` calls issued while `stop()` is in flight now report through `onError` instead of silently returning once `runTransport()` refuses to touch the stopping transport. Empty batches remain no-ops, publications already queued behind an in-flight open are still canceled by the stop (latest lifecycle intent wins), and page-hide suspension keeps its documented drop-without-defer behavior.
11
+ - `subscribe()` and `ready()` now honor the explicit-stop barrier. A subscription requested while `stop()` is settling reports through `onError` and returns a no-op cleanup instead of being erased by teardown or leaking into a later restart; `ready()` rejects rather than resolving against the stopping transport. A `start()` already queued behind that stop remains the newest lifecycle intent, so `ready()` continues to follow its restart promise.
12
+ - A `stop()` that arrives before a restart queued behind an earlier in-flight `stop()` could run now cancels that queued restart instead of being swallowed by the in-flight stop gate. The queued continuation is invalidated with a monotonic token, so the latest lifecycle intent always wins: `stop → start → stop` ends stopped with no extra transport open, while a later `start()` still queues a fresh restart with a higher token.
13
+ - Superseded asynchronous transport opens can no longer tear down or detach a newer page-hide/pageshow reopen. Each open now carries a lifecycle epoch, so callbacks, failures, success telemetry, and cleanup from an older open are ignored once a newer suspend/resume/stop transition owns the lifecycle; `stop()` also waits for any pending open or reopen even when `started` has already been cleared. Previously an initial open failing after a queued resume reset `started` to false and nulled `startPromise`, making `stop()` resolve immediately while the queued transport could still start afterwards.
14
+ - `start()` now serialises correctly with both page-hide suspension and an in-flight explicit stop. A hidden bus whose asynchronous `transport.stop()` was still settling returned that cleanup promise from `start()` and never reopened the transport; an in-flight `stop()` likewise allowed `start()` to observe the old ready state and resolve as a no-op before the bus finished stopping. Both paths now queue one fresh start after cleanup, share concurrent start/stop promises, and preserve the existing degraded manual-recovery behavior.
15
+ - Explicit `start()` now performs a manual transport recovery when the bus has already started but its automatic recovery budget is exhausted; it preserves the cluster, subscriptions, and replay state while resetting the failure/recovery ledger before reopening. Previously `start()` returned a resolved no-op in the degraded state even though the public health contract documents it as the manual retry path. Covered by a regression that exhausts automatic recovery, succeeds through `start()`, and verifies the health summary returns to healthy.
16
+ - IndexedDB replay persistence now settles every mutation when a transaction aborts, including connection-loss aborts that fire `onabort` without a preceding request error. Previously the serialized mutation queue could remain blocked forever, preventing later appends and clears from running. Loads now resolve only after `transaction.oncomplete`, so a request that succeeds before a later abort cannot be reported as a successful read. Request/transaction failures without an `error` object use operation-specific fallback messages.
17
+ - Replay age pruning is now position-independent and uses one shared policy for the in-memory rings and IndexedDB adapter. Previously an expired timestamped entry after a timestamp-less legacy entry (or after a non-expired entry) was never removed, and hydrated history was always truncated to `maxPerTopic` even when `pruneStrategy: 'age'` was configured. Timestamp-less entries are still preserved for compatibility, but are now capped by `maxPerTopic` under AGE so they cannot grow without bound; timestamped entries remain bounded by the retention window. Covered across live recording, hydration without a `clearBefore` adapter, and durable append paths.
18
+
7
19
  ## [0.20.85] - 2026-09-13
8
20
 
9
21
  ### Added
@@ -5,7 +5,7 @@ import {
5
5
  parseDataBusPublication,
6
6
  publicationMetadata,
7
7
  selectWorkerBackend
8
- } from "./chunk-PW63EWIK.js";
8
+ } from "./chunk-SDOV3UHG.js";
9
9
  import {
10
10
  CENTRIFUGE_INPUT_TYPE,
11
11
  CENTRIFUGE_OUTPUT_TYPE,
@@ -1729,6 +1729,36 @@ function roundMs(value) {
1729
1729
  return Math.round(value * 10) / 10;
1730
1730
  }
1731
1731
 
1732
+ // src/core/replay-pruning.ts
1733
+ function pruneReplayHistory(messages, options) {
1734
+ const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
1735
+ const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
1736
+ if (!ageEnabled) {
1737
+ return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
1738
+ }
1739
+ const cutoff = now - retentionMs;
1740
+ let hasExpired = false;
1741
+ let timestamplessCount = 0;
1742
+ for (const message of messages) {
1743
+ if (message.timestamp === void 0) timestamplessCount += 1;
1744
+ else if (message.timestamp < cutoff) hasExpired = true;
1745
+ }
1746
+ let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
1747
+ if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
1748
+ return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
1749
+ }
1750
+ if (timestamplessCount <= maxPerTopic) return pruned;
1751
+ let timestamplessToDrop = timestamplessCount - maxPerTopic;
1752
+ pruned = pruned.filter((message) => {
1753
+ if (message.timestamp === void 0 && timestamplessToDrop > 0) {
1754
+ timestamplessToDrop -= 1;
1755
+ return false;
1756
+ }
1757
+ return true;
1758
+ });
1759
+ return pruned;
1760
+ }
1761
+
1732
1762
  // src/core/replay-manager.ts
1733
1763
  var PersistenceRetryCancelledError = class extends Error {
1734
1764
  constructor() {
@@ -1790,17 +1820,15 @@ var ReplayManager = class {
1790
1820
  this.buffers.set(message.topic, buffer);
1791
1821
  }
1792
1822
  buffer.push(message);
1793
- const ageBounded = this.pruneStrategy !== PRUNE_STRATEGY.COUNT && this.retentionMs !== void 0;
1794
- if (this.pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) {
1795
- while (buffer.length > this.maxPerTopic) buffer.shift();
1796
- }
1797
- if (ageBounded) {
1798
- const cutoff = this.now() - this.retentionMs;
1799
- while (buffer.length > 0) {
1800
- const first = buffer[0];
1801
- if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
1802
- buffer.shift();
1803
- }
1823
+ const pruned = pruneReplayHistory(buffer, {
1824
+ maxPerTopic: this.maxPerTopic,
1825
+ pruneStrategy: this.pruneStrategy,
1826
+ retentionMs: this.retentionMs,
1827
+ now: this.now()
1828
+ });
1829
+ if (pruned !== buffer) {
1830
+ buffer = pruned;
1831
+ this.buffers.set(message.topic, buffer);
1804
1832
  }
1805
1833
  if (!this.persistence) return;
1806
1834
  if (this.persistence.appendBatch) {
@@ -1981,14 +2009,24 @@ var ReplayManager = class {
1981
2009
  if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
1982
2010
  await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
1983
2011
  }
1984
- for (const message of await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load())) {
2012
+ const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2013
+ for (const message of loaded) {
1985
2014
  let buffer = this.buffers.get(message.topic);
1986
2015
  if (!buffer) {
1987
2016
  buffer = [];
1988
2017
  this.buffers.set(message.topic, buffer);
1989
2018
  }
1990
2019
  buffer.push(message);
1991
- if (buffer.length > this.maxPerTopic) buffer.shift();
2020
+ }
2021
+ const hydrationNow = this.now();
2022
+ for (const [topic, buffer] of this.buffers) {
2023
+ const pruned = pruneReplayHistory(buffer, {
2024
+ maxPerTopic: this.maxPerTopic,
2025
+ pruneStrategy: this.pruneStrategy,
2026
+ retentionMs: this.retentionMs,
2027
+ now: hydrationNow
2028
+ });
2029
+ if (pruned !== buffer) this.buffers.set(topic, pruned);
1992
2030
  }
1993
2031
  } catch (error) {
1994
2032
  this.onPersistenceError(error);
@@ -2165,7 +2203,7 @@ var DedupManager = class {
2165
2203
  };
2166
2204
 
2167
2205
  // src/core/version.ts
2168
- var SDK_VERSION = true ? "0.20.85" : "";
2206
+ var SDK_VERSION = true ? "0.20.86" : "";
2169
2207
 
2170
2208
  // src/core/data-bus.ts
2171
2209
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2203,6 +2241,24 @@ var CrossTabDataBus = class {
2203
2241
  // Gate that serialises start/stop/suspend/resume — only one lifecycle
2204
2242
  // transition at a time. Resets to null once the operation settles.
2205
2243
  startPromise = null;
2244
+ // Gate for an explicit stop(). Concurrent stop() calls share it, and a
2245
+ // start() received while stopping chains a fresh start after it.
2246
+ stopPromise = null;
2247
+ // A start() requested while an explicit stop() is still settling. Kept
2248
+ // separate from startPromise because stop()'s finally block clears the
2249
+ // ordinary lifecycle gate before the queued start is allowed to run.
2250
+ queuedStart = null;
2251
+ // Lazy readiness view of queuedStart. start() keeps its documented
2252
+ // resolve-on-cancellation contract, while ready() must reject when the
2253
+ // queued intent was superseded by a later stop().
2254
+ queuedStartReady = null;
2255
+ queuedStartReadyToken = 0;
2256
+ // The queued continuation is chained to the stop promise and cannot be
2257
+ // un-scheduled once scheduled. A later stop() therefore invalidates the
2258
+ // current intent by recording its token; a subsequent start() issues a
2259
+ // higher token so the latest lifecycle request still wins.
2260
+ queuedStartToken = 0;
2261
+ canceledQueuedStartToken = 0;
2206
2262
  // Timestamp of the last automatic transport recovery attempt.
2207
2263
  // Used to avoid a tight retry loop when the transport fails repeatedly.
2208
2264
  lastRecoveryAt = 0;
@@ -2225,6 +2281,9 @@ var CrossTabDataBus = class {
2225
2281
  // surfaces a failure while later opens and automatic recovery wait for the
2226
2282
  // stop to settle.
2227
2283
  pendingStop = null;
2284
+ // Ownership token for asynchronous transport opens. Every lifecycle
2285
+ // transition invalidates callbacks and failure cleanup from older opens.
2286
+ lifecycleEpoch = 0;
2228
2287
  // Minimum interval in ms between automatic recovery attempts.
2229
2288
  recoveryCooldownMs;
2230
2289
  recoveryMaxAttempts;
@@ -2337,37 +2396,51 @@ var CrossTabDataBus = class {
2337
2396
  * Start the DataBus with the given transport config.
2338
2397
  *
2339
2398
  * The first call starts the cluster and opens the transport. Concurrent calls
2340
- * during an in-flight start return the same promise. Once the operation
2341
- * settles (success or failure) the promise gate is cleared so a subsequent
2342
- * start() or resumeTransport() can open a fresh lifecycle.
2399
+ * during an in-flight open return the same promise. A call received while an
2400
+ * explicit stop() is settling queues one fresh start after cleanup; a later
2401
+ * stop() before that queued start runs cancels it, so the latest lifecycle
2402
+ * intent wins. Once an operation settles (success or failure) its promise
2403
+ * gate is cleared so a subsequent start() or resumeTransport() can open a
2404
+ * fresh lifecycle.
2343
2405
  */
2344
2406
  start(config) {
2345
- if (this.startPromise) return this.startPromise;
2346
- if (this.started) return Promise.resolve();
2407
+ if (this.queuedStart) return this.queuedStart;
2408
+ if (this.stopping) return this.queueStartAfterStop(config);
2409
+ if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
2410
+ if (this.started) {
2411
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2412
+ if (!transportDown) return Promise.resolve();
2413
+ this.activeConfig = config;
2414
+ this.resetFailureState();
2415
+ return this.reopenTransport();
2416
+ }
2347
2417
  this.started = true;
2348
2418
  this.stopping = false;
2349
2419
  this.suspended = false;
2350
2420
  this.activeConfig = config;
2351
- this.lastError = null;
2352
- this.lastFailure = null;
2353
- this.persistenceFailureCount = 0;
2354
- this.persistenceLastFailureAt = null;
2355
- this.persistenceLastErrorMessage = null;
2421
+ this.resetFailureState();
2356
2422
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2357
2423
  this.trace.start();
2358
2424
  this.startDedupSweep();
2359
2425
  this.replayManager.start();
2360
2426
  this.updateStatus(WORKER_STATUS.CONNECTING);
2361
2427
  this.cluster.start();
2362
- const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
2428
+ const lifecycleEpoch = ++this.lifecycleEpoch;
2429
+ const opening = this.openTransport(
2430
+ config,
2431
+ this.pendingStop ?? Promise.resolve(),
2432
+ true,
2433
+ lifecycleEpoch
2434
+ );
2363
2435
  this.startPromise = opening;
2364
2436
  for (const topic of this.topicHandlers.keys()) {
2365
2437
  this.cluster.subscribe(topic);
2366
2438
  }
2367
2439
  void opening.then(
2368
2440
  () => {
2441
+ if (this.startPromise !== opening) return;
2369
2442
  this.emitCoordinationTrace();
2370
- if (this.startPromise === opening) this.startPromise = null;
2443
+ this.startPromise = null;
2371
2444
  },
2372
2445
  () => {
2373
2446
  if (this.startPromise === opening) this.startPromise = null;
@@ -2375,24 +2448,80 @@ var CrossTabDataBus = class {
2375
2448
  );
2376
2449
  return opening;
2377
2450
  }
2451
+ /** Return a cancellation-aware readiness view of the current queued start. */
2452
+ getQueuedStartReady() {
2453
+ const queued = this.queuedStart;
2454
+ if (!queued) {
2455
+ return Promise.reject(new Error("No queued start is in flight."));
2456
+ }
2457
+ const token = this.queuedStartToken;
2458
+ if (this.queuedStartReady && this.queuedStartReadyToken === token) {
2459
+ return this.queuedStartReady;
2460
+ }
2461
+ this.queuedStartReadyToken = token;
2462
+ this.queuedStartReady = queued.then(() => {
2463
+ if (token <= this.canceledQueuedStartToken) {
2464
+ throw new Error(
2465
+ "CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
2466
+ );
2467
+ }
2468
+ if (!this.started || !this.transportReady) {
2469
+ throw new Error("CrossTabDataBus restart completed without a ready transport.");
2470
+ }
2471
+ });
2472
+ return this.queuedStartReady;
2473
+ }
2474
+ /** Queue exactly one fresh start after an in-flight explicit stop settles. */
2475
+ queueStartAfterStop(config) {
2476
+ if (this.queuedStart) return this.queuedStart;
2477
+ const stop = this.stopPromise ?? Promise.resolve();
2478
+ const token = ++this.queuedStartToken;
2479
+ const queued = stop.catch(() => void 0).then(() => {
2480
+ if (this.queuedStart === queued) this.queuedStart = null;
2481
+ if (token <= this.canceledQueuedStartToken) return;
2482
+ return this.start(config);
2483
+ });
2484
+ this.queuedStart = queued;
2485
+ return queued;
2486
+ }
2487
+ /** Reset failure and recovery diagnostics for a new explicit start session. */
2488
+ resetFailureState() {
2489
+ this.lastError = null;
2490
+ this.lastErrorAt = null;
2491
+ this.lastFailure = null;
2492
+ this.persistenceFailureCount = 0;
2493
+ this.persistenceLastFailureAt = null;
2494
+ this.persistenceLastErrorMessage = null;
2495
+ this.recoveryAttempt = 0;
2496
+ this.recoveryExhausted = false;
2497
+ this.lastRecoveryAt = 0;
2498
+ }
2378
2499
  /**
2379
2500
  * Open the transport, chained after `before` to ensure lifecycle ordering.
2380
2501
  * When `stopClusterOnFailure` is true (initial start), a transport failure
2381
2502
  * tears down the cluster as well.
2382
2503
  */
2383
- openTransport(config, before, stopClusterOnFailure) {
2504
+ openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
2384
2505
  this.transportReady = false;
2385
2506
  const chainedPendingStop = this.pendingStop;
2507
+ const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
2386
2508
  return before.catch(() => void 0).then(() => {
2387
- if (this.stopping || this.suspended) return;
2509
+ if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
2388
2510
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
2389
2511
  return Promise.resolve(
2390
2512
  this.transport.start(config, {
2391
- onMessage: (message) => this.handleTransportMessage(message),
2392
- onStatus: (status) => this.updateStatus(status),
2393
- onError: (error) => this.reportError(error)
2513
+ onMessage: (message) => {
2514
+ if (isCurrentLifecycle()) this.handleTransportMessage(message);
2515
+ },
2516
+ onStatus: (status) => {
2517
+ if (isCurrentLifecycle()) this.updateStatus(status);
2518
+ },
2519
+ onError: (error) => {
2520
+ if (isCurrentLifecycle()) this.reportError(error);
2521
+ }
2394
2522
  })
2395
2523
  ).then(() => {
2524
+ if (!isCurrentLifecycle()) return;
2396
2525
  if (this.status === WORKER_STATUS.ERROR) {
2397
2526
  throw new Error("Transport failed during startup.");
2398
2527
  }
@@ -2403,6 +2532,7 @@ var CrossTabDataBus = class {
2403
2532
  }
2404
2533
  });
2405
2534
  }).catch((error) => {
2535
+ if (!isCurrentLifecycle()) throw error;
2406
2536
  if (stopClusterOnFailure) this.started = false;
2407
2537
  if (!this.pendingStop) {
2408
2538
  this.pendingStop = this.createStopPromise();
@@ -2417,16 +2547,26 @@ var CrossTabDataBus = class {
2417
2547
  this.cluster.stop();
2418
2548
  this.stopping = false;
2419
2549
  }
2420
- this.startPromise = null;
2421
2550
  throw error;
2422
2551
  });
2423
2552
  }
2424
2553
  /**
2425
2554
  * Await the DataBus to be fully started (lazy init when using initialConfig).
2426
2555
  * Returns a rejected promise when the transport has failed and no start is in
2427
- * flight — the caller can retry by calling start() or ready() again.
2556
+ * flight — the caller can retry by calling start() or ready() again. While an
2557
+ * explicit stop() is settling, this rejects unless a restart is queued behind
2558
+ * it; false readiness during teardown is never reported.
2428
2559
  */
2429
2560
  ready() {
2561
+ if (this.queuedStart) return this.getQueuedStartReady();
2562
+ if (this.stopping) {
2563
+ return Promise.reject(new Error(
2564
+ "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2565
+ ));
2566
+ }
2567
+ if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2568
+ return Promise.reject(this.lastError);
2569
+ }
2430
2570
  try {
2431
2571
  this.ensureStarted();
2432
2572
  } catch (error) {
@@ -2442,9 +2582,18 @@ var CrossTabDataBus = class {
2442
2582
  /**
2443
2583
  * Register a handler for `topic`. The handler fires on every publication
2444
2584
  * delivered to this tab, regardless of which tab published it. Returns an
2445
- * unsubscribe function for convenience.
2585
+ * unsubscribe function for convenience. During an explicit stop() the
2586
+ * registration is rejected through onError and a no-op cleanup is returned,
2587
+ * so a late subscriber cannot leak into a future restart.
2446
2588
  */
2447
2589
  subscribe(topic, handler, options) {
2590
+ if (this.stopping) {
2591
+ this.reportError(new Error(
2592
+ "CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
2593
+ ));
2594
+ return () => {
2595
+ };
2596
+ }
2448
2597
  this.ensureStarted();
2449
2598
  const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
2450
2599
  const wasUnused = handlers.size === 0;
@@ -2498,6 +2647,7 @@ var CrossTabDataBus = class {
2498
2647
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
2499
2648
  publish(topic, data, options) {
2500
2649
  this.ensureStarted();
2650
+ if (this.rejectPublishDuringStop("publish")) return;
2501
2651
  if (!this.cluster.publish(topic, data, options)) {
2502
2652
  this.reportError(
2503
2653
  new Error("Failed to send the publish control message to the owning worker.")
@@ -2514,6 +2664,7 @@ var CrossTabDataBus = class {
2514
2664
  publishBatch(topic, items) {
2515
2665
  this.ensureStarted();
2516
2666
  if (items.length === 0) return;
2667
+ if (this.rejectPublishDuringStop("publishBatch")) return;
2517
2668
  if (items.length === 1) {
2518
2669
  const first = items[0];
2519
2670
  this.publish(topic, first.data, first.options);
@@ -2642,10 +2793,33 @@ var CrossTabDataBus = class {
2642
2793
  }
2643
2794
  /**
2644
2795
  * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
2645
- * and close the transport. Idempotent.
2796
+ * and close the transport. Concurrent and repeated calls share the in-flight
2797
+ * stop promise. A start() received while stopping runs after this completes,
2798
+ * unless another stop() arrives first and cancels that queued restart.
2646
2799
  */
2647
- async stop() {
2648
- if (!this.started) return;
2800
+ stop() {
2801
+ if (this.queuedStart) {
2802
+ this.canceledQueuedStartToken = this.queuedStartToken;
2803
+ this.queuedStart = null;
2804
+ }
2805
+ if (this.stopPromise) return this.stopPromise;
2806
+ if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
2807
+ return Promise.resolve();
2808
+ }
2809
+ const stopPromise = this.performStop();
2810
+ this.stopPromise = stopPromise;
2811
+ void stopPromise.then(
2812
+ () => {
2813
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2814
+ },
2815
+ () => {
2816
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2817
+ }
2818
+ );
2819
+ return stopPromise;
2820
+ }
2821
+ async performStop() {
2822
+ this.lifecycleEpoch += 1;
2649
2823
  this.stopping = true;
2650
2824
  this.replayManager.suspend();
2651
2825
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
@@ -2837,6 +3011,7 @@ var CrossTabDataBus = class {
2837
3011
  */
2838
3012
  suspendTransport() {
2839
3013
  if (this.stopping) return;
3014
+ this.lifecycleEpoch += 1;
2840
3015
  this.suspended = true;
2841
3016
  this.transportReady = false;
2842
3017
  this.transportSubscribedTopics.clear();
@@ -2875,19 +3050,23 @@ var CrossTabDataBus = class {
2875
3050
  this.started = true;
2876
3051
  this.suspended = false;
2877
3052
  this.updateStatus(WORKER_STATUS.CONNECTING);
3053
+ const lifecycleEpoch = ++this.lifecycleEpoch;
2878
3054
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
2879
- const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
3055
+ const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
2880
3056
  this.startPromise = opening;
2881
3057
  void opening.then(
2882
3058
  () => {
3059
+ if (this.startPromise === opening) this.startPromise = null;
3060
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
2883
3061
  if (traceAttempt !== void 0) {
2884
3062
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
2885
3063
  this.recoveryAttempt = 0;
2886
3064
  this.recoveryExhausted = false;
2887
3065
  }
2888
- if (this.startPromise === opening) this.startPromise = null;
2889
3066
  },
2890
3067
  () => {
3068
+ if (this.startPromise === opening) this.startPromise = null;
3069
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
2891
3070
  if (traceAttempt !== void 0) {
2892
3071
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
2893
3072
  }
@@ -2921,6 +3100,19 @@ var CrossTabDataBus = class {
2921
3100
  return operation();
2922
3101
  }).catch((error) => this.reportError(error));
2923
3102
  }
3103
+ /**
3104
+ * Publications started after teardown begins cannot reach any transport.
3105
+ * Surface that as a normal asynchronous API failure instead of letting
3106
+ * runTransport() return silently. Empty publishBatch() calls remain a no-op
3107
+ * and are filtered by the caller before this check.
3108
+ */
3109
+ rejectPublishDuringStop(operation) {
3110
+ if (!this.stopping) return false;
3111
+ this.reportError(new Error(
3112
+ `CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
3113
+ ));
3114
+ return true;
3115
+ }
2924
3116
  /**
2925
3117
  * Ensure the DataBus is started, throwing if no initialConfig was provided.
2926
3118
  * Called automatically by subscribe/publish/ready when autoStart is true.
@@ -2996,8 +3188,9 @@ export {
2996
3188
  assertHeartbeatInterval,
2997
3189
  assertStructuredCloneable,
2998
3190
  WorkerClusterRuntime,
3191
+ pruneReplayHistory,
2999
3192
  CrossTabDataBus,
3000
3193
  parseDataBusPublication,
3001
3194
  selectWorkerBackend
3002
3195
  };
3003
- //# sourceMappingURL=chunk-PW63EWIK.js.map
3196
+ //# sourceMappingURL=chunk-SDOV3UHG.js.map