@catalyst-cloud/sdk 0.6.0 → 0.8.0

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.
Files changed (74) hide show
  1. package/README.md +92 -0
  2. package/dist/browser.d.ts +9 -0
  3. package/dist/browser.d.ts.map +1 -0
  4. package/dist/browser.js +24 -0
  5. package/dist/browser.js.map +1 -0
  6. package/dist/live-sync-client.d.ts +203 -13
  7. package/dist/live-sync-client.d.ts.map +1 -1
  8. package/dist/live-sync-client.js +605 -50
  9. package/dist/live-sync-client.js.map +1 -1
  10. package/dist/replica/browser/apply.d.ts +29 -0
  11. package/dist/replica/browser/apply.d.ts.map +1 -0
  12. package/dist/replica/browser/apply.js +59 -0
  13. package/dist/replica/browser/apply.js.map +1 -0
  14. package/dist/replica/browser/browser-lock.d.ts +25 -0
  15. package/dist/replica/browser/browser-lock.d.ts.map +1 -0
  16. package/dist/replica/browser/browser-lock.js +91 -0
  17. package/dist/replica/browser/browser-lock.js.map +1 -0
  18. package/dist/replica/browser/browser-replica.d.ts +237 -0
  19. package/dist/replica/browser/browser-replica.d.ts.map +1 -0
  20. package/dist/replica/browser/browser-replica.js +952 -0
  21. package/dist/replica/browser/browser-replica.js.map +1 -0
  22. package/dist/replica/browser/db.worker.d.ts +2 -0
  23. package/dist/replica/browser/db.worker.d.ts.map +1 -0
  24. package/dist/replica/browser/db.worker.js +40 -0
  25. package/dist/replica/browser/db.worker.js.map +1 -0
  26. package/dist/replica/browser/delta-queue.d.ts +187 -0
  27. package/dist/replica/browser/delta-queue.d.ts.map +1 -0
  28. package/dist/replica/browser/delta-queue.js +328 -0
  29. package/dist/replica/browser/delta-queue.js.map +1 -0
  30. package/dist/replica/browser/ports.d.ts +39 -0
  31. package/dist/replica/browser/ports.d.ts.map +1 -0
  32. package/dist/replica/browser/ports.js +144 -0
  33. package/dist/replica/browser/ports.js.map +1 -0
  34. package/dist/replica/browser/protocol.d.ts +129 -0
  35. package/dist/replica/browser/protocol.d.ts.map +1 -0
  36. package/dist/replica/browser/protocol.js +12 -0
  37. package/dist/replica/browser/protocol.js.map +1 -0
  38. package/dist/replica/browser/seed-read-gate.d.ts +13 -0
  39. package/dist/replica/browser/seed-read-gate.d.ts.map +1 -0
  40. package/dist/replica/browser/seed-read-gate.js +36 -0
  41. package/dist/replica/browser/seed-read-gate.js.map +1 -0
  42. package/dist/replica/browser/seed-session.d.ts +23 -0
  43. package/dist/replica/browser/seed-session.d.ts.map +1 -0
  44. package/dist/replica/browser/seed-session.js +57 -0
  45. package/dist/replica/browser/seed-session.js.map +1 -0
  46. package/dist/replica/browser/snapshot-stream.d.ts +24 -0
  47. package/dist/replica/browser/snapshot-stream.d.ts.map +1 -0
  48. package/dist/replica/browser/snapshot-stream.js +113 -0
  49. package/dist/replica/browser/snapshot-stream.js.map +1 -0
  50. package/dist/replica/browser/sqlite-db.d.ts +9 -0
  51. package/dist/replica/browser/sqlite-db.d.ts.map +1 -0
  52. package/dist/replica/browser/sqlite-db.js +28 -0
  53. package/dist/replica/browser/sqlite-db.js.map +1 -0
  54. package/dist/replica/browser/support.d.ts +2 -0
  55. package/dist/replica/browser/support.d.ts.map +1 -0
  56. package/dist/replica/browser/support.js +30 -0
  57. package/dist/replica/browser/support.js.map +1 -0
  58. package/dist/replica/browser/validate.d.ts +17 -0
  59. package/dist/replica/browser/validate.d.ts.map +1 -0
  60. package/dist/replica/browser/validate.js +47 -0
  61. package/dist/replica/browser/validate.js.map +1 -0
  62. package/dist/replica/browser/worker-core.d.ts +10 -0
  63. package/dist/replica/browser/worker-core.d.ts.map +1 -0
  64. package/dist/replica/browser/worker-core.js +181 -0
  65. package/dist/replica/browser/worker-core.js.map +1 -0
  66. package/dist/replica/catalyst-replica.d.ts +34 -10
  67. package/dist/replica/catalyst-replica.d.ts.map +1 -1
  68. package/dist/replica/catalyst-replica.js +195 -83
  69. package/dist/replica/catalyst-replica.js.map +1 -1
  70. package/dist/replica/migration-shape.d.ts +11 -0
  71. package/dist/replica/migration-shape.d.ts.map +1 -0
  72. package/dist/replica/migration-shape.js +25 -0
  73. package/dist/replica/migration-shape.js.map +1 -0
  74. package/package.json +16 -3
@@ -30,8 +30,9 @@
30
30
  // silence it sends the pinned `{"type":"ping"}` frame; the mirror answers via `setWebSocketAutoResponse`
31
31
  // (which replies WITHOUT waking a hibernated DO — ADR-0009's cost model is preserved). If no frame
32
32
  // arrives within `pongTimeoutMs`, the socket is force-reconnected through the existing backoff path.
33
- // Traffic postpones pings (no keepalive on a busy stream), and a 3-probe feature-detect disables the
34
- // watchdog against an old server that never pongs — so it degrades to exactly today's behavior.
33
+ // Traffic postpones pings (no keepalive on a busy stream), and a 3-probe feature-detect DEGRADES the
34
+ // watchdog against an old server that never pongs — re-probing at 10x the interval instead of never
35
+ // (CTC-281: detection can be slowed, but never permanently lost).
35
36
  //
36
37
  // Gap detection (CTL-1402): the server's live push (`broadcastChange`) is at-most-once — a send into a
37
38
  // half-open socket is silently swallowed, and the dropped frame used to be sealed over permanently the
@@ -44,7 +45,30 @@
44
45
  // paginated and answers `{type:"resync"}` on underflow). Bounded: after `gapRetryLimit` no-PROGRESS
45
46
  // windows (the heal deadline re-arms on every delivered frame, so a big-but-advancing heal never
46
47
  // escalates) the client escalates to the full re-seed path rather than spinning — a gap is never
47
- // silently accepted. Every transition emits the `catalyst.replica.gap` log/counter signal, the
48
+ // silently accepted.
49
+ //
50
+ // Wedge-proofing (CTC-281): the Jul 17-23 fleet incident (6 windows of server-side half-opens with no
51
+ // FIN/RST; cursors frozen 28-215 min while clients said "live") exposed four restart-only states this
52
+ // client could reach. The invariant now enforced: EVERY state that is not "stopped" holds either a
53
+ // pending timer or a socket whose events re-enter the machine — there is no state only a process
54
+ // restart clears. Concretely: (1) a connect attempt whose ws impl never fires open/close/error is
55
+ // bounded by `openTimeoutMs`; (2) `onerror` without a follow-up `onclose` (real undici bugs #3697/
56
+ // #3546) arms a one-shot fallback reconnect; (3) the watchdog feature-detect can only DEGRADE itself
57
+ // (a slow re-probe every DEGRADED_PROBE_MULTIPLIER x pingIntervalMs), never disable itself outright —
58
+ // and only while pong capability is UNPROVEN; once ANY pong has ever been observed, silence is always
59
+ // treated as a liveness failure (during the incident, 3 open-then-silent sockets used to disable
60
+ // detection for the client's lifetime ~6 min into a window — and because the pong latch is per-process,
61
+ // a client RESTARTED mid-window would have re-latched the disable, so the degrade-not-disable shape is
62
+ // what actually guarantees convergence); (4) a FAILED reseed re-enters the backoff path instead
63
+ // of hot-reopening — and the reseed await itself is bounded by `reseedTimeoutMs` (the injected
64
+ // callback is a trust boundary like the ws impl: both first-party reseeds self-bound (the node
65
+ // replica's seedFromSnapshot and the browser replica's OPFS seed both abort on an idle body), but an
66
+ // arbitrary consumer-supplied reseed can still hang, and "resyncing" holds no socket and suppresses
67
+ // scheduleReconnect, so without this bound
68
+ // it was the one remaining zero-timer state; a timed-out reseed is ABANDONED, its late settle
69
+ // discarded, and the client re-enters backoff); (5) closeSocket() escalates past `close()` to a duck-typed `terminate()` (Bun /
70
+ // the 'ws' package expose one; undici does not — its close-handshake wait is why teardown must not
71
+ // depend on a graceful close against a half-open peer). Every transition emits the `catalyst.replica.gap` log/counter signal, the
48
72
  // detector the per-frame apply telemetry is structurally blind to (an undelivered frame lands in no
49
73
  // apply bucket). Gaps are the STEADY-STATE path here — the mirror's reconcile pass appends change_log
50
74
  // rows it never broadcasts, so every pass punches a hole that heals via re-request — hence `detected`
@@ -54,6 +78,16 @@
54
78
  // beyond-gap change frame (re-request the hole `deliveredSeq+1..head`) but never applies it.
55
79
  import { PING_FRAME } from "./types.js";
56
80
  import { NOOP_TELEMETRY, createTelemetry, CATALYST_ATTR, REPLICA_LOG, REPLICA_METRIC, REPLICA_SPAN, DEFAULT_SCOPE_NAME, } from "./otel.js";
81
+ /**
82
+ * How long a CANCELLED reseed gets to unwind before the transport settles anyway.
83
+ *
84
+ * Cancelling is asynchronous on the consumer's side — the browser seed aborts a fetch, trips its
85
+ * supersede guard, posts `seedAbort` and resumes its delta queue — and the transport reconnects the
86
+ * moment it settles, so it must wait for that unwind or it reconnects into a still-paused consumer.
87
+ * Bounded because the deadline that triggered this exists precisely for an unresponsive callback: a
88
+ * cleanup that also hangs must not wedge the transport (CTC-114 review round 12).
89
+ */
90
+ const DEFAULT_CANCEL_CLEANUP_GRACE_MS = 250;
57
91
  /** Resolve the runtime global WebSocket, or fail with an actionable message. */
58
92
  function defaultWsFactory(url) {
59
93
  const Ctor = globalThis.WebSocket;
@@ -83,15 +117,43 @@ export function buildConnectUrl(opts) {
83
117
  const params = new URLSearchParams();
84
118
  if (opts.auth.kind === "token")
85
119
  params.set("token", opts.auth.token);
86
- params.set("account", opts.accountId);
87
- return `${origin}${opts.connectPath}?${params.toString()}`;
120
+ // Only when a tenant was actually named. `?account=` is NOT the same as no account: the server's
121
+ // consumers are truthiness checks, so empty takes the omitted path anyway — but it would freeze a
122
+ // contract in which "" is a legal mirror name, and it puts `catalyst.tenant=""` on every span.
123
+ if (opts.accountId)
124
+ params.set("account", opts.accountId);
125
+ const query = params.toString();
126
+ // Guard the dangling "?" — with cookie auth and no account there are no params at all.
127
+ return `${origin}${opts.connectPath}${query ? `?${query}` : ""}`;
88
128
  }
89
129
  /**
90
- * Consecutive opened-then-never-ponged connections after which the watchdog disables itself for the
91
- * client's lifetime (feature-detect for a server without auto-pong). Bounds worst-case reconnect
92
- * churn against an old server to exactly this many attempts, making mirror/SDK deploy order harmless.
130
+ * Consecutive opened-then-never-ponged connections after which the watchdog DEGRADES itself
131
+ * (feature-detect for a server without auto-pong). Bounds worst-case reconnect churn against an old
132
+ * server, making mirror/SDK deploy order harmless.
93
133
  */
94
134
  const PROBE_FAILURE_LIMIT = 3;
135
+ /**
136
+ * Degraded-watchdog probe interval, as a multiple of `pingIntervalMs` (CTC-281). After
137
+ * {@link PROBE_FAILURE_LIMIT} never-ponged connections the watchdog does NOT turn off — it re-probes
138
+ * at this heavily backed-off cadence (stock: every 15 min instead of 90 s). A hard lifetime disable
139
+ * was the incident's restart-only residual: the `pongEverObserved` latch is per-PROCESS, so a client
140
+ * (re)started inside an incident window (supervisors restarted processes mid-window) came up
141
+ * unproven, burned its 3 probes against open-but-silent sockets, and went permanently blind — the
142
+ * next half-open then froze it as "live" forever, and only another restart (which repeats the cycle)
143
+ * cleared it. Degrading instead keeps a probe pending in EVERY non-stopped state: a half-open socket
144
+ * under a degraded watchdog is still detected within ~this multiple of the interval, and the first
145
+ * pong after recovery re-arms full-speed detection (and latches capability as proven). Against a
146
+ * genuinely old server the cost is one bounded reconnect per degraded window — churn, never wedge.
147
+ */
148
+ const DEGRADED_PROBE_MULTIPLIER = 10;
149
+ /**
150
+ * How long (ms) after `onerror` to wait for the spec-mandated follow-up `onclose` before forcing the
151
+ * reconnect ourselves (CTC-281). WHATWG requires close-after-error, but real impls have shipped
152
+ * violations (undici #3697 "close not emitted on error", #3546 "close not fired if the connection
153
+ * failed to be established") — and `WebSocketLike` is structural, so an injected impl is trusted
154
+ * blindly. Pre-open, a missing onclose used to be a ZERO-timer permanent-"error" wedge.
155
+ */
156
+ const ERROR_CLOSE_GRACE_MS = 5_000;
95
157
  export class LiveSyncClient {
96
158
  baseUrl;
97
159
  accountId;
@@ -106,6 +168,9 @@ export class LiveSyncClient {
106
168
  maxBackoffMs;
107
169
  pingIntervalMs;
108
170
  pongTimeoutMs;
171
+ openTimeoutMs;
172
+ reseedTimeoutMs;
173
+ cancelCleanupGraceMs;
109
174
  gapTimeoutMs;
110
175
  gapRetryLimit;
111
176
  wsFactory;
@@ -113,6 +178,36 @@ export class LiveSyncClient {
113
178
  telemetryConfig;
114
179
  ws = null;
115
180
  stopped = false;
181
+ /**
182
+ * Has start() been entered? `stopped` alone cannot answer this — it is false BEFORE the first
183
+ * start() as well as during a run, so every "am I running?" guard read true on a client that had
184
+ * never booted. Only the public `requestResync()` can reach that window (CTC-114 review round 5):
185
+ * it would reseed and open a socket with no lifecycle deferred and no telemetry resolved, and the
186
+ * later real start() would then openSocket() again — overwriting `this.ws`, so the first socket
187
+ * kept delivering duplicate frames and could no longer be closed through the stored reference.
188
+ */
189
+ started = false;
190
+ /** The in-flight boot task, or null once it settles — `requestResync()` serializes behind it. */
191
+ bootTask = null;
192
+ /** Settles an in-flight `boundedReseed` wrapper on stop(), so an awaited resync cannot hang. */
193
+ abandonReseed = null;
194
+ /**
195
+ * The in-flight resync, so a concurrent caller AWAITS it instead of being handed an
196
+ * already-resolved promise and acting on a store still being rebuilt (round 10).
197
+ */
198
+ activeResync = null;
199
+ /** Did the boot this request waited on perform a COLD re-seed? Only then may it be absorbed. */
200
+ bootColdSeeded = false;
201
+ /**
202
+ * Did the last boot REJECT? Latched until the next `start()` (CTC-114 review round 9).
203
+ *
204
+ * Round 8 read the failure from the awaited `bootTask`, but that handle is nulled once the boot
205
+ * settles — so a `requestResync()` arriving after that microtask found no record of the failure,
206
+ * with `started` still true and `stopped` still false, and sailed past the guard into a reseed that
207
+ * could open a live socket under an application already told startup had failed. The outcome has to
208
+ * outlive the handle.
209
+ */
210
+ bootFailed = false;
116
211
  resyncing = false;
117
212
  backoff;
118
213
  reconnectTimer = null;
@@ -148,12 +243,44 @@ export class LiveSyncClient {
148
243
  pingSentAt = 0;
149
244
  /** Client-lifetime: consecutive opened-then-never-ponged connections. Reset to 0 by ANY pong. */
150
245
  probeFailures = 0;
151
- /** Client-lifetime: after PROBE_FAILURE_LIMIT never-ponged connections we stop pinging for good
152
- * (an old server without auto-pong), degrading to close/error-only detection. */
153
- watchdogDisabled = false;
246
+ /** Client-lifetime until a pong: after PROBE_FAILURE_LIMIT never-ponged connections the watchdog
247
+ * DEGRADES to a {@link DEGRADED_PROBE_MULTIPLIER}x-slower re-probe (an old server without auto-pong)
248
+ * — it never turns off outright, so detection is never a restart-only casualty (CTC-281). Only
249
+ * reachable while pong capability is UNPROVEN ({@link pongEverObserved}); the first pong clears it. */
250
+ watchdogDegraded = false;
251
+ /** Client-lifetime pong latch (CTC-281): has ANY connection EVER answered a ping? Once true, the
252
+ * server's auto-pong capability is PROVEN for good — a later never-ponged connection is a liveness
253
+ * failure (the incident's open-but-silent socket), never feature-detect evidence, so the watchdog
254
+ * can no longer even degrade itself. PER-PROCESS by design — which is exactly why the degrade must
255
+ * be soft (see {@link DEGRADED_PROBE_MULTIPLIER}): a restart mid-incident resets this latch. */
256
+ pongEverObserved = false;
154
257
  pingTimer = null;
155
258
  pongDeadline = null;
259
+ /** Per-CONNECTION connect/open deadline (CTC-281): armed when the socket is constructed, cleared on
260
+ * open/close/teardown. The only timer pending between openSocket() and onopen — the guarantee that
261
+ * a never-firing ws impl cannot leave the client wedged in "connecting" with nothing scheduled. */
262
+ connectTimer = null;
263
+ /** Per-CONNECTION onerror→onclose fallback (CTC-281): armed by onerror, fires forceReconnect once
264
+ * if the impl never follows error with close (undici #3697/#3546). Cleared on open/close/teardown. */
265
+ errorFallbackTimer = null;
266
+ /** Per-CONNECTION: has THIS socket fired onopen? The connect-deadline's late-timer guard (a
267
+ * throttled background tab can fire the deadline after onopen already ran and cleared it). */
268
+ socketOpened = false;
269
+ /** The pending reseed deadline (CTC-281) — the timer that makes "resyncing" (no socket, reconnect
270
+ * suppressed) a bounded state instead of a restart-only wedge. Cleared when the reseed settles in
271
+ * time and by stop() (ask 4: stop() leaves NOTHING pending). At most one reseed is ever in flight:
272
+ * `resyncing` guards the resync path, and the boot cold seed SETS that same flag for its duration.
273
+ * (It used to rely on "the boot seed runs before any socket exists" — true only while a resync
274
+ * needed a server frame. The public `requestResync()` added in 0.8.0 needs no socket.) */
275
+ reseedTimer = null;
156
276
  constructor(opts) {
277
+ // Fail fast, and fail HERE. A token-authed client has no session to fall back to, so an omitted
278
+ // account is a misconfiguration, not a default. It is checked in the constructor rather than in
279
+ // buildConnectUrl because `connectUrl()` is called from `openSocket()` OUTSIDE its try/catch — a
280
+ // throw down there escapes the reconnect machinery entirely instead of surfacing to the caller.
281
+ if (opts.auth.kind === "token" && !opts.accountId) {
282
+ throw new Error("LiveSyncClient: accountId is required with token auth (only cookie auth can fall back to the session's own tenant)");
283
+ }
157
284
  this.baseUrl = stripTrailingSlashes(opts.baseUrl);
158
285
  this.accountId = opts.accountId;
159
286
  this.connectPath = opts.connectPath ?? "/connect";
@@ -167,6 +294,10 @@ export class LiveSyncClient {
167
294
  this.maxBackoffMs = opts.maxBackoffMs ?? 30_000;
168
295
  this.pingIntervalMs = opts.pingIntervalMs ?? 90_000;
169
296
  this.pongTimeoutMs = opts.pongTimeoutMs ?? 15_000;
297
+ this.openTimeoutMs = opts.openTimeoutMs ?? 20_000;
298
+ this.reseedTimeoutMs = opts.reseedTimeoutMs ?? 600_000;
299
+ this.cancelCleanupGraceMs =
300
+ opts.cancelCleanupGraceMs ?? DEFAULT_CANCEL_CLEANUP_GRACE_MS;
170
301
  this.gapTimeoutMs = opts.gapTimeoutMs ?? 10_000;
171
302
  this.gapRetryLimit = opts.gapRetryLimit ?? 3;
172
303
  this.wsFactory = opts.wsFactory ?? defaultWsFactory;
@@ -182,32 +313,106 @@ export class LiveSyncClient {
182
313
  * forever" contract) — the open WebSocket keeps the process alive between deltas. In a browser the
183
314
  * returned Promise is simply never awaited; call stop() on teardown.
184
315
  */
185
- async start() {
316
+ start() {
186
317
  this.stopped = false;
187
- // Resolve the OTel seam ONCE up front (before the first reseed, so the seed span exists on the
188
- // cold-start path too). Keep the OFF path FULLY SYNCHRONOUS no `await`, so a caller that opens
189
- // the socket and inspects it in the same tick still sees it; only pay the async resolution (guarded
190
- // dynamic import, or a CatalystReplica passing its already-resolved instance) when telemetry is on.
191
- this.telemetry =
192
- this.telemetryConfig === undefined || this.telemetryConfig === false
193
- ? NOOP_TELEMETRY
194
- : await createTelemetry(this.telemetryConfig, {
195
- tracerName: DEFAULT_SCOPE_NAME,
196
- meterName: DEFAULT_SCOPE_NAME,
197
- });
198
- this.gapCounter = this.telemetry.counter(REPLICA_METRIC.gaps, {
199
- description: "Change-feed seq-gap lifecycle events (detected/healed/escalated).",
200
- unit: "{gap}",
201
- });
202
- const saved = this.getCursor();
203
- if (saved == null) {
204
- this.setStatus("resyncing");
205
- await this.reseed();
206
- }
207
- this.openSocket();
208
- return new Promise((resolve) => {
318
+ this.started = true;
319
+ // RESET per boot. `start()` is restartable after `stop()`, and a stale `true` from a previous
320
+ // cold boot would make the NEXT boot warm, and therefore re-seeding nothing absorb a resync
321
+ // it should have honoured. Found while re-reading this path rather than reported; the same class
322
+ // of staleness as the `bootTask` handle being nulled when it settles.
323
+ this.bootColdSeeded = false;
324
+ this.bootFailed = false;
325
+ // The done deferred is created BEFORE the boot body runs (CTC-281 N2): stop() during the cold-seed
326
+ // await used to find resolveDone still null and leave the returned promise pending forever — a
327
+ // contract violation for a consumer awaiting start(). The boot body below is deliberately its OWN
328
+ // async task raced against this deferred, because an `async start()` suspended at `await reseed()`
329
+ // can never reach a `return done` — stop() must be able to resolve the caller regardless of the
330
+ // boot phase (openSocket() already no-ops on stopped, so a late-settling seed is harmless).
331
+ const done = new Promise((resolve) => {
209
332
  this.resolveDone = resolve;
210
333
  });
334
+ const boot = (async () => {
335
+ // The WHOLE boot is an in-flight resync, not just the cold seed (CTC-114 review rounds 4 + 6).
336
+ //
337
+ // `requestResync()` — public as of 0.8.0 — is callable the moment start() returns its promise,
338
+ // which is before ANY of this settles. Without the latch, `handleResync()`'s re-entrancy guard
339
+ // read false and started a SECOND concurrent reseed: two seeds interleaving writes through a
340
+ // non-reentrant consumer callback, then each completion calling openSocket() — and since
341
+ // openSocket() overwrites `this.ws`, the first socket was orphaned, still delivering duplicate
342
+ // frames and unreachable by stop().
343
+ //
344
+ // Round 4 latched only the cold seed. That was not enough: `createTelemetry()` below is awaited
345
+ // BEFORE the seed, so with telemetry enabled the boot suspends in a window where `started` is
346
+ // already true and the latch is not yet set. The latch therefore has to cover the entire body.
347
+ //
348
+ // Until this release the invariant held for free — a resync could only be driven by a server
349
+ // frame, and a frame needs a socket, which does not exist until openSocket() below.
350
+ //
351
+ // The latch makes the request WAIT; whether it is then absorbed or honoured is decided in
352
+ // requestResync() from `bootColdSeeded`, once this task has settled.
353
+ //
354
+ // Round 6 absorbed it on BOTH arms, arguing that a warm boot's `{type:"sync", after:<cursor>}`
355
+ // is itself the catch-up. That was wrong (round 7), and wrong against this method's whole
356
+ // reason for existing: a consumer calls requestResync() when it has discovered ON ITS OWN SIDE
357
+ // that deltas can no longer catch its store up — the browser replica's dropped overflow buffer
358
+ // is the motivating case. Replaying from the cursor cannot rebuild rows the consumer already
359
+ // lost, so silently swallowing the request left it permanently inconsistent. Only a COLD boot
360
+ // may absorb it, because that boot really is a full re-seed from /snapshot.
361
+ this.resyncing = true;
362
+ try {
363
+ // Resolve the OTel seam ONCE up front (before the first reseed, so the seed span exists on the
364
+ // cold-start path too). Keep the OFF path FULLY SYNCHRONOUS — no `await`, so a caller that opens
365
+ // the socket and inspects it in the same tick still sees it (the boot body runs synchronously up
366
+ // to its first await); only pay the async resolution (guarded dynamic import, or a
367
+ // CatalystReplica passing its already-resolved instance) when telemetry is on.
368
+ this.telemetry =
369
+ this.telemetryConfig === undefined || this.telemetryConfig === false
370
+ ? NOOP_TELEMETRY
371
+ : await createTelemetry(this.telemetryConfig, {
372
+ tracerName: DEFAULT_SCOPE_NAME,
373
+ meterName: DEFAULT_SCOPE_NAME,
374
+ });
375
+ this.gapCounter = this.telemetry.counter(REPLICA_METRIC.gaps, {
376
+ description: "Change-feed seq-gap lifecycle events (detected/healed/escalated).",
377
+ unit: "{gap}",
378
+ });
379
+ const saved = this.getCursor();
380
+ if (saved == null) {
381
+ this.setStatus("resyncing");
382
+ // Bounded like the resync-path reseed (CTC-281): a hanging COLD seed surfaces as a start()
383
+ // rejection (the boot arm rejects) instead of a silent forever-"resyncing" start().
384
+ await this.boundedReseed();
385
+ // Only NOW may a request that waited on this boot be absorbed — this really was a full
386
+ // re-seed from /snapshot. A warm boot sets nothing, so the waiter is honoured instead.
387
+ this.bootColdSeeded = true;
388
+ }
389
+ }
390
+ finally {
391
+ // Must clear on the FAILURE arm too, or a failed boot latches the client into a state where
392
+ // every later resync — and scheduleReconnect — is suppressed forever.
393
+ this.resyncing = false;
394
+ }
395
+ this.openSocket();
396
+ })();
397
+ this.bootTask = boot;
398
+ // Clear the handle once boot settles, so a resync arriving LONG after startup is never mistaken
399
+ // for one that raced it — otherwise `bootColdSeeded` would absorb legitimate later requests
400
+ // forever. The catch keeps a boot rejection from surfacing as an unhandled one on this arm; the
401
+ // race below is what actually reports it.
402
+ void boot
403
+ .catch(() => {
404
+ // LATCH the failure before the handle is dropped — `bootTask` is the transient record, this is
405
+ // the durable one, and requestResync() has to be able to see it afterwards (round 9).
406
+ this.bootFailed = true;
407
+ })
408
+ .then(() => {
409
+ if (this.bootTask === boot)
410
+ this.bootTask = null;
411
+ });
412
+ // Settles when stop() resolves the deferred, OR rejects if the boot (cold seed) fails — a boot
413
+ // SUCCESS deliberately keeps waiting on `done` (the "runs forever" contract). Promise.race
414
+ // attaches handlers to both arms, so a boot rejection after stop() is never an unhandled one.
415
+ return Promise.race([done, boot.then(() => done)]);
211
416
  }
212
417
  /** Stop the client: close the socket, cancel any pending reconnect, resolve start(). Idempotent. */
213
418
  stop() {
@@ -216,11 +421,21 @@ export class LiveSyncClient {
216
421
  clearTimeout(this.reconnectTimer);
217
422
  this.reconnectTimer = null;
218
423
  }
424
+ // Ask 4 (bounded teardown): the reseed deadline must not hold process exit for up to
425
+ // reseedTimeoutMs. With it cleared a still-hanging reseed simply never settles its (now
426
+ // irrelevant) await — every post-await path in handleResync/boot checks `stopped` first.
427
+ this.clearReseedTimer();
219
428
  this.closeSocket();
220
429
  this.setStatus("stopped");
221
430
  const done = this.resolveDone;
222
431
  this.resolveDone = null;
223
432
  done?.();
433
+ // AFTER resolving start()'s deferred, so the race below settles on `done` and a boot arm that
434
+ // rejects from this abandon lands on an already-settled race rather than surfacing as the
435
+ // outcome of start(). Settles a `requestResync()` a consumer is awaiting — see boundedReseed.
436
+ const abandon = this.abandonReseed;
437
+ this.abandonReseed = null;
438
+ abandon?.();
224
439
  }
225
440
  /** The ws(s):// URL this client opens, for diagnostics/tests. Re-derived from the options. */
226
441
  connectUrl() {
@@ -231,6 +446,15 @@ export class LiveSyncClient {
231
446
  auth: this.auth,
232
447
  });
233
448
  }
449
+ /**
450
+ * The tenant label for telemetry. Attributes are `Record<string, string>`, and an empty string reads
451
+ * as a MISSING attribute in Loki/Tempo — which silently merges every session-scoped browser client
452
+ * into one unlabelled bucket. `"session"` names the case instead: this client is scoped to whatever
453
+ * tenant the cookie resolves to.
454
+ */
455
+ get tenantAttr() {
456
+ return this.accountId ?? "session";
457
+ }
234
458
  setStatus(status) {
235
459
  try {
236
460
  this.onStatus?.(status);
@@ -243,10 +467,19 @@ export class LiveSyncClient {
243
467
  if (this.stopped)
244
468
  return;
245
469
  this.setStatus("connecting");
470
+ // RE-CHECK after the status callback (CTC-114 review round 13). `setStatus` calls into consumer
471
+ // code, and a consumer may synchronously tear down from it — the browser replica's very first
472
+ // "reconnecting" notification is a documented place to do so. `stop()` then ran while `this.ws`
473
+ // was still null, so it had nothing to close; we resumed here, constructed a socket, stored it in
474
+ // an already-stopped client, and left it open processing frames with the teardown long finished.
475
+ // Every guarded entry point that calls out and then continues needs this; this is the one that
476
+ // creates a resource afterwards.
477
+ if (this.stopped)
478
+ return;
246
479
  // One span per connect attempt: started here, ended OK in onopen, ERROR on construct-fail / a close
247
480
  // before open. Manual (not active) because the lifecycle spans onopen…onclose callbacks.
248
481
  this.connectSpan = this.telemetry.startSpan(REPLICA_SPAN.reconnect, {
249
- [CATALYST_ATTR.tenant]: this.accountId,
482
+ [CATALYST_ATTR.tenant]: this.tenantAttr,
250
483
  });
251
484
  const wsUrl = this.connectUrl();
252
485
  let ws;
@@ -261,7 +494,28 @@ export class LiveSyncClient {
261
494
  return;
262
495
  }
263
496
  this.ws = ws;
497
+ this.socketOpened = false;
498
+ this.clearConnectTimers(); // never stack deadlines across attempts (every teardown clears too)
499
+ // Connect/open deadline (CTC-281): from here until onopen, THIS timer is the client's only
500
+ // guaranteed pending work (the reconnectTimer that led here was already nulled). If the impl
501
+ // never fires open/close/error — a stalled upgrade with no FIN/RST, or a buggy injected ws —
502
+ // this converts the dead attempt into an ordinary backoff retry instead of a permanent
503
+ // "connecting" wedge.
504
+ if (this.openTimeoutMs > 0) {
505
+ this.connectTimer = setTimeout(() => {
506
+ this.connectTimer = null;
507
+ // Late-timer guard (same discipline as onPongDeadline): a throttled tab can fire this after
508
+ // onopen already ran, or after this socket was already replaced/torn down.
509
+ if (this.stopped || this.ws !== ws || this.socketOpened)
510
+ return;
511
+ this.log("warn", `ws open timed out after ${this.openTimeoutMs}ms; forcing reconnect`);
512
+ this.endConnectSpan(new Error("open timeout"));
513
+ this.forceReconnect();
514
+ }, this.openTimeoutMs);
515
+ }
264
516
  ws.onopen = () => {
517
+ this.socketOpened = true;
518
+ this.clearConnectTimers(); // the attempt succeeded — the open deadline + error fallback die here
265
519
  this.backoff = this.backoffMs; // a successful open resets the backoff ramp
266
520
  this.setStatus("live");
267
521
  this.endConnectSpan();
@@ -302,6 +556,19 @@ export class LiveSyncClient {
302
556
  catch {
303
557
  // already closing/closed
304
558
  }
559
+ // Fallback (CTC-281): if the impl violates the spec and never follows error with close (undici
560
+ // #3697/#3546), force the reconnect ourselves after a short grace. One-shot per socket, guarded
561
+ // on identity — a spec-conforming onclose lands first, clears this timer, and reconnects
562
+ // normally (scheduleReconnect's reconnectTimer check also prevents any double-schedule).
563
+ if (this.errorFallbackTimer == null) {
564
+ this.errorFallbackTimer = setTimeout(() => {
565
+ this.errorFallbackTimer = null;
566
+ if (this.stopped || this.ws !== ws)
567
+ return; // onclose (or a teardown) already handled it
568
+ this.log("warn", "ws error was never followed by close; forcing reconnect (CTC-281)");
569
+ this.forceReconnect();
570
+ }, ERROR_CLOSE_GRACE_MS);
571
+ }
305
572
  };
306
573
  }
307
574
  /** End the in-flight connect span exactly once (idempotent — nulls the handle). */
@@ -333,6 +600,23 @@ export class LiveSyncClient {
333
600
  catch {
334
601
  // already closed
335
602
  }
603
+ // Escalate past the graceful close (CTC-281): against a half-open peer the Close frame goes into
604
+ // a black hole, and undici waits on the never-answered handshake with NO timeout — the ref'd TCP
605
+ // handle then holds a supervised process's exit hostage for up to the OS retransmission timeout
606
+ // (~minutes). Bun's WebSocket and the node 'ws' package both expose a non-standard `terminate()`
607
+ // that destroys the connection immediately; duck-type it (structurally, never `as any`) and call
608
+ // it when present. Handlers are already detached above, so a hard kill is behaviorally safe;
609
+ // native/undici sockets simply lack the member and keep today's behavior (documented gap — on
610
+ // Node, inject a 'ws'-package wsFactory if bounded process exit matters).
611
+ const t = ws;
612
+ if (typeof t.terminate === "function") {
613
+ try {
614
+ t.terminate();
615
+ }
616
+ catch {
617
+ // best-effort — already destroyed
618
+ }
619
+ }
336
620
  }
337
621
  scheduleReconnect() {
338
622
  if (this.stopped || this.resyncing || this.reconnectTimer != null)
@@ -364,7 +648,15 @@ export class LiveSyncClient {
364
648
  // capability and reset the feature-detect counter; a pong is NEVER surfaced to onFrame/onChange.
365
649
  // (lastFrameAt + the pending-deadline clear already happened synchronously in onInboundFrame.)
366
650
  this.pongObserved = true;
651
+ this.pongEverObserved = true; // CTC-281: capability proven for the client's LIFETIME
367
652
  this.probeFailures = 0;
653
+ if (this.watchdogDegraded) {
654
+ // The degraded slow re-probe just paid off (the server pongs after all — recovered mid-window
655
+ // or upgraded): re-arm full-speed detection immediately (CTC-281).
656
+ this.watchdogDegraded = false;
657
+ this.log("info", "pong observed on a degraded watchdog; full-speed liveness detection re-armed (CTC-281)");
658
+ this.armPing();
659
+ }
368
660
  return;
369
661
  }
370
662
  if (frame.type === "head") {
@@ -516,7 +808,7 @@ export class LiveSyncClient {
516
808
  * KEY ON `escalated` ONLY (logged at ERROR); a gap that heals is routine and boring. */
517
809
  recordGap(event, gap) {
518
810
  this.gapCounter.add(1, {
519
- [CATALYST_ATTR.tenant]: this.accountId,
811
+ [CATALYST_ATTR.tenant]: this.tenantAttr,
520
812
  [CATALYST_ATTR.gapEvent]: event,
521
813
  });
522
814
  this.log(event === "escalated" ? "error" : "info", REPLICA_LOG.gap, {
@@ -539,6 +831,186 @@ export class LiveSyncClient {
539
831
  this.gap = null;
540
832
  this.clearGapTimer();
541
833
  }
834
+ clearReseedTimer() {
835
+ if (this.reseedTimer != null) {
836
+ clearTimeout(this.reseedTimer);
837
+ this.reseedTimer = null;
838
+ }
839
+ }
840
+ /**
841
+ * Run the injected reseed() bounded by {@link LiveSyncClientOptions.reseedTimeoutMs} (CTC-281).
842
+ * The injected callback is a trust boundary like the ws impl: while it runs there is NO socket and
843
+ * scheduleReconnect is suppressed, so an unbounded await here was the last zero-timer wedge — the
844
+ * deadline below is the pending timer that upholds the header invariant for the "resyncing" state.
845
+ * On timeout the attempt is both CANCELLED and abandoned (CTC-114 review round 10). It used to be
846
+ * abandoned only, justified as "the callback owns its own I/O bounds" — but the callback owning
847
+ * bounds is exactly what makes a SECOND, independent deadline here dangerous. The browser seed is
848
+ * bounded by network idleness and by per-RPC worker deadlines, both of which a legitimately slow
849
+ * ~100 MB snapshot satisfies indefinitely; this total deadline could therefore fire on a seed that
850
+ * was making honest progress, whereupon the transport reconnected while the callback kept writing.
851
+ * Frames past the abandoned seed's cursor were then accepted by the socket and discarded by the
852
+ * replica's paused queue, and a late seed completion left the socket advanced over a hole that
853
+ * later deltas sealed for good.
854
+ *
855
+ * So the deadline now fires an AbortSignal FIRST and rejects second: whoever is told the attempt is
856
+ * over is also told to stop. A late settle is still discarded via the `settled` latch, and a late
857
+ * REJECTION is still swallowed so it can never surface as an unhandled rejection.
858
+ */
859
+ boundedReseed() {
860
+ // Cancellation is scoped to THIS attempt. Aborting it must not disturb a successor.
861
+ const cancel = new AbortController();
862
+ const seed = this.reseed(cancel.signal);
863
+ void seed.catch(() => { }); // an abandoned attempt's late rejection must never go unhandled
864
+ // ALWAYS wrapped, even with the deadline disabled (CTC-114 review round 8). This used to
865
+ // early-return the raw seed promise when `reseedTimeoutMs <= 0` — the documented way to turn the
866
+ // deadline off — which skipped installing `abandonReseed` and so bypassed round 7's stop() fix
867
+ // entirely on that path. Disabling the DEADLINE must not also disable teardown: the two are
868
+ // independent, and `stop()` has to be able to settle an awaited `requestResync()` either way.
869
+ return new Promise((resolve, reject) => {
870
+ let settled = false;
871
+ let timer = null;
872
+ const finish = (fn) => {
873
+ if (settled)
874
+ return; // stale settle: the deadline or stop() already took this attempt
875
+ settled = true;
876
+ if (timer !== null) {
877
+ clearTimeout(timer);
878
+ if (this.reseedTimer === timer)
879
+ this.reseedTimer = null;
880
+ }
881
+ this.abandonReseed = null;
882
+ fn();
883
+ };
884
+ /**
885
+ * Give up on this attempt: tell it to stop, WAIT for it to unwind, then settle.
886
+ *
887
+ * Signalling alone was not enough (CTC-114 review round 12, P1). `abort()` only *initiates* the
888
+ * consumer's cleanup — the browser seed still has to abort its fetch, let the supersede guard
889
+ * trip, post its `seedAbort`, and run the `finally` that resumes its delta queue. Settling
890
+ * immediately let `runResync()` reconnect after one backoff while that queue was still PAUSED,
891
+ * so arriving frames were counted as delivered and then discarded: exactly the hole the queue's
892
+ * discard/rollback pairing exists to prevent, re-opened from the other side.
893
+ *
894
+ * Bounded, because the whole point of this deadline is that the callback may be unresponsive: a
895
+ * cleanup that itself hangs must not wedge the transport, so we settle anyway after a grace.
896
+ */
897
+ const giveUp = (err) => {
898
+ if (settled)
899
+ return;
900
+ cancel.abort();
901
+ // ALWAYS wait for the unwind, bounded by the grace (CTC-114 review round 14).
902
+ //
903
+ // Round 12 skipped the wait when `reseed.length === 0`, reasoning that a zero-arg callback
904
+ // cannot observe the signal. `Function.length` does not support that inference: it counts only
905
+ // parameters before the first default or rest, so `reseed: (signal = undefined) => …` reports
906
+ // 0 while receiving and honouring the signal — and that consumer got no wait at all, which is
907
+ // exactly the hazard the wait exists for. There is no sound way to ask a function whether it
908
+ // will act on a signal, so stop trying: wait for everyone, and let the grace bound it. A
909
+ // consumer that ignores the signal simply hits the grace, which is the pre-round-12 behaviour
910
+ // delayed by `cancelCleanupGraceMs` — the reason that default is small and this is the knob
911
+ // the browser replica raises.
912
+ // 0 DISABLES the wait outright rather than scheduling a zero-delay timer — the same
913
+ // disable-by-zero convention the other bounds use, and the honest meaning of "this consumer
914
+ // has nothing to unwind".
915
+ if (this.cancelCleanupGraceMs <= 0) {
916
+ finish(() => reject(err));
917
+ return;
918
+ }
919
+ const grace = setTimeout(() => finish(() => reject(err)), this.cancelCleanupGraceMs);
920
+ void seed
921
+ .catch(() => undefined)
922
+ .then(() => {
923
+ clearTimeout(grace);
924
+ finish(() => reject(err));
925
+ });
926
+ };
927
+ if (this.reseedTimeoutMs > 0) {
928
+ timer = setTimeout(() => giveUp(new Error(`reseed did not settle within ${this.reseedTimeoutMs}ms; cancelled (CTC-281)`)), this.reseedTimeoutMs);
929
+ this.reseedTimer = timer;
930
+ }
931
+ // stop() settles this wrapper (CTC-114 review round 7). Clearing the deadline is not enough:
932
+ // if the injected reseed() never settles, nothing else ever settles THIS promise, and while
933
+ // that was merely "an irrelevant await nobody holds" when boundedReseed was internal, the
934
+ // public `requestResync()` is now awaited by consumers — so teardown or recovery code holding
935
+ // that await hung forever on stop(). Rejecting rather than resolving keeps the outcome honest;
936
+ // requestResync() catches it and still upholds its never-rejects contract.
937
+ this.abandonReseed = () => giveUp(new Error("client stopped while re-seeding"));
938
+ seed.then((cursor) => finish(() => resolve(cursor)), (err) => finish(() => reject(err instanceof Error ? err : new Error(String(err)))));
939
+ });
940
+ }
941
+ /**
942
+ * Ask the client to drop the socket, re-seed, and reconnect from the fresh cursor — the same path a
943
+ * server `{type:"resync"}` frame drives, exposed for a consumer that discovers ON ITS OWN SIDE that
944
+ * its store can no longer be caught up by deltas.
945
+ *
946
+ * The browser replica is the motivating caller: when its delta queue overflows (or its applies keep
947
+ * rejecting) the buffered frames are dropped, so the transport's notion of what has been delivered is
948
+ * now ahead of what the store actually holds. Re-seeding through here — rather than calling the
949
+ * `reseed` callback directly — is what makes that safe: this CLOSES THE SOCKET FIRST, so no live
950
+ * frame interleaves with the snapshot and lands in the window that is in neither the snapshot nor the
951
+ * store.
952
+ *
953
+ * Delegates to the existing resync path in full — same `resyncing` re-entrancy guard, same
954
+ * `boundedReseed` deadline, same span, same failure→backoff behaviour. NEVER rejects: a failed
955
+ * re-seed is already handled internally by re-entering the reconnect path, and this is called from
956
+ * event handlers and `void` contexts where a rejection would surface as an unhandled promise.
957
+ */
958
+ async requestResync() {
959
+ if (this.stopped)
960
+ return;
961
+ // BEFORE start() there is nothing to resync (CTC-114 review round 5). `stopped` is false on a
962
+ // never-started client, so it cannot carry this guard by itself. Reseeding here would run with no
963
+ // lifecycle deferred and no telemetry resolved, and — worse — open a socket that the later real
964
+ // start() would orphan: openSocket() overwrites `this.ws`, so the first socket keeps delivering
965
+ // frames and stop() can no longer reach it. Ignore rather than throw: the contract above is that
966
+ // this never rejects, and it is called from `void` contexts.
967
+ if (!this.started) {
968
+ this.log("warn", "requestResync() before start() — ignored");
969
+ return;
970
+ }
971
+ // SERIALIZE behind an in-flight boot (CTC-114 review rounds 6 + 7). Running concurrently with it
972
+ // meant two reseeds and two openSocket() calls, the second overwriting `this.ws` and orphaning the
973
+ // first socket. Waiting — rather than dropping — is what keeps the WARM path correct: a warm boot
974
+ // performs no re-seed, and this method's whole purpose is a consumer that has discovered its store
975
+ // can no longer be caught up by deltas, which replaying from the cursor cannot fix.
976
+ const boot = this.bootTask;
977
+ if (boot) {
978
+ await boot.catch(() => undefined); // the outcome is read from `bootFailed`, latched below
979
+ if (this.stopped)
980
+ return;
981
+ }
982
+ // A FAILED boot must not be recovered from here (CTC-114 review rounds 8 + 9). Round 7 swallowed
983
+ // the rejection and carried straight on into handleResync — so a cold start whose /snapshot failed
984
+ // would reject the caller's start(), sending the application into its boot-error path, and then a
985
+ // later successful reseed here would quietly open a live socket underneath it. Round 8 read the
986
+ // failure from the awaited task, which round 9 showed is not enough: `bootTask` is nulled when the
987
+ // boot settles, so a request arriving after that microtask saw no failure at all. Checked OUTSIDE
988
+ // the `if (boot)` for exactly that reason — the latch outlives the handle, until the next start().
989
+ if (this.bootFailed) {
990
+ this.log("warn", "requestResync() ignored — startup failed");
991
+ return;
992
+ }
993
+ // A COLD boot re-seeded from /snapshot while we waited, which IS what was being asked for.
994
+ if (boot && this.bootColdSeeded) {
995
+ this.log("info", "requestResync() absorbed by the boot's cold seed");
996
+ return;
997
+ }
998
+ // Unlike the frame path, this entry point can be called MID-BACKOFF: the queue overflowed while the
999
+ // client was already waiting to reconnect. handleResync would then reopen the socket itself and the
1000
+ // pending timer would open a second one on top of it.
1001
+ if (this.reconnectTimer != null) {
1002
+ clearTimeout(this.reconnectTimer);
1003
+ this.reconnectTimer = null;
1004
+ }
1005
+ try {
1006
+ await this.handleResync();
1007
+ }
1008
+ catch (err) {
1009
+ // handleResync already catches its own reseed failure; this is the belt-and-braces guard for the
1010
+ // contract above (never reject) against a throw from a consumer callback it invokes.
1011
+ this.log("error", "requestResync failed", err);
1012
+ }
1013
+ }
542
1014
  /**
543
1015
  * Cursor underflow: the deltas we need were evicted from the service's retained change buffer. Close the socket
544
1016
  * (so no live frame interleaves with the re-seed), re-seed via the injected callback, then reconnect
@@ -546,6 +1018,45 @@ export class LiveSyncClient {
546
1018
  * frame and suppresses scheduleReconnect for the duration so we reopen exactly once.
547
1019
  */
548
1020
  async handleResync() {
1021
+ // RETURN THE RUNNING ONE, do not resolve immediately (CTC-114 review round 10). The re-entrancy
1022
+ // guard used to `return` bare, so a caller awaiting the public `requestResync()` while a
1023
+ // server-driven resync was already re-seeding got a promise that resolved AT ONCE — and then read
1024
+ // the store while the replacement snapshot was still being written into it. Awaiting recovery has
1025
+ // to mean recovery finished, whoever started it.
1026
+ //
1027
+ // This is the same seam as the boot serialization, which covered only `bootTask`: one handle for
1028
+ // "a re-seed is in flight", awaited by everything that needs it to be done.
1029
+ // `return await`, not `return` (CodeQL, round 11). Behaviour is identical today, but returning a
1030
+ // bare promise from an async function means a later `try`/`finally` added around this line would
1031
+ // settle BEFORE the awaited work — a foot-gun this file has been bitten by often enough to be
1032
+ // worth foreclosing, and it keeps this frame in the stack trace when the resync rejects.
1033
+ if (this.activeResync)
1034
+ return await this.activeResync;
1035
+ const run = this.runResync();
1036
+ this.activeResync = run;
1037
+ try {
1038
+ await run;
1039
+ }
1040
+ finally {
1041
+ if (this.activeResync === run)
1042
+ this.activeResync = null;
1043
+ }
1044
+ }
1045
+ /**
1046
+ * The resync body. Never call directly — `handleResync()` owns the in-flight handle.
1047
+ *
1048
+ * TWO FLAGS, ONE FACT — documented rather than consolidated here, deliberately. `resyncing` and
1049
+ * `activeResync` both mean "a re-seed is in flight", and they can disagree: the boot path sets
1050
+ * `resyncing` directly (so a resync cannot race startup) without ever creating an `activeResync`
1051
+ * handle. In that window a call landing here would hit the guard below and resolve silently, which
1052
+ * is the very shape round 10 fixed for the live path.
1053
+ *
1054
+ * It is NOT reachable today, and both reasons are load-bearing: `requestResync()` serialises behind
1055
+ * `bootTask` before it can get here, and the server-frame path needs a socket, which does not exist
1056
+ * until the boot opens one. Consolidating the two into a single handle is the right fix and belongs
1057
+ * with the wider lifecycle rework — not in a release candidate at round eleven.
1058
+ */
1059
+ async runResync() {
549
1060
  if (this.resyncing)
550
1061
  return;
551
1062
  this.resyncing = true;
@@ -554,13 +1065,15 @@ export class LiveSyncClient {
554
1065
  this.clearGapState();
555
1066
  this.setStatus("resyncing");
556
1067
  this.closeSocket();
1068
+ let reseeded = false;
557
1069
  try {
558
1070
  // The reseed runs inside an ACTIVE span so the replica's seed span (the injected reseed IS
559
1071
  // seedFromSnapshot) auto-parents under this resync span.
560
- await this.telemetry.withActiveSpan(REPLICA_SPAN.resync, { [CATALYST_ATTR.tenant]: this.accountId }, async () => {
561
- const cursor = await this.reseed();
1072
+ await this.telemetry.withActiveSpan(REPLICA_SPAN.resync, { [CATALYST_ATTR.tenant]: this.tenantAttr }, async () => {
1073
+ const cursor = await this.boundedReseed();
562
1074
  this.log("info", `resynced, cursor=${cursor}`);
563
1075
  });
1076
+ reseeded = true;
564
1077
  }
565
1078
  catch (err) {
566
1079
  this.log("error", "resync reseed failed; will retry on reconnect", err);
@@ -568,8 +1081,19 @@ export class LiveSyncClient {
568
1081
  finally {
569
1082
  this.resyncing = false;
570
1083
  }
571
- if (!this.stopped)
1084
+ if (this.stopped)
1085
+ return;
1086
+ if (reseeded) {
1087
+ // A completed re-seed reopens immediately — the store is fresh and the endpoint just served us.
572
1088
  this.openSocket();
1089
+ return;
1090
+ }
1091
+ // A FAILED reseed re-enters the BACKOFF path (CTC-281): the old unconditional reopen made each
1092
+ // gap-escalate → /snapshot-fail → reopen cycle run hot (~30-40s of upgrade + replays + /snapshot
1093
+ // per client, fleet-wide, backoff reset on every open) against exactly the sick server the ticket
1094
+ // covers. scheduleReconnect converges identically once the endpoint recovers — just politely.
1095
+ this.setStatus("reconnecting");
1096
+ this.scheduleReconnect();
573
1097
  }
574
1098
  // ── Liveness watchdog (CTC-135) ──
575
1099
  /** Epoch ms of the last inbound frame (change, pong, or malformed) — null before the first frame.
@@ -594,19 +1118,24 @@ export class LiveSyncClient {
594
1118
  this.clearPongDeadline();
595
1119
  this.armPing();
596
1120
  }
597
- /** (Re)arm the idle-ping timer. No-op when the watchdog is disabled/off or there is no live socket,
598
- * so it is safe to call on every frame. A setTimeout chain (not setInterval): each frame resets it. */
1121
+ /** (Re)arm the idle-ping timer. No-op when the watchdog is off or there is no live socket, so it is
1122
+ * safe to call on every frame. A setTimeout chain (not setInterval): each frame resets it. A
1123
+ * DEGRADED watchdog still arms — at {@link DEGRADED_PROBE_MULTIPLIER}x the interval — so detection
1124
+ * is never permanently off (CTC-281): every live socket always has a probe pending. */
599
1125
  armPing() {
600
1126
  this.clearPingTimer();
601
- if (this.watchdogDisabled || this.pingIntervalMs <= 0 || this.stopped || !this.ws)
1127
+ if (this.pingIntervalMs <= 0 || this.stopped || !this.ws)
602
1128
  return;
603
- this.pingTimer = setTimeout(() => this.sendPing(), this.pingIntervalMs);
1129
+ const interval = this.watchdogDegraded
1130
+ ? this.pingIntervalMs * DEGRADED_PROBE_MULTIPLIER
1131
+ : this.pingIntervalMs;
1132
+ this.pingTimer = setTimeout(() => this.sendPing(), interval);
604
1133
  }
605
1134
  /** The feed has been idle for a full interval: send one liveness ping and start the pong deadline. A
606
1135
  * synchronous send throw means the socket is already dead — treat it as an unanswered probe now. */
607
1136
  sendPing() {
608
1137
  this.pingTimer = null; // this timer just fired
609
- if (this.stopped || this.watchdogDisabled || !this.ws)
1138
+ if (this.stopped || !this.ws)
610
1139
  return;
611
1140
  this.pingSentAt = Date.now();
612
1141
  try {
@@ -633,18 +1162,30 @@ export class LiveSyncClient {
633
1162
  this.onProbeUnanswered();
634
1163
  }
635
1164
  /** A ping went unanswered (deadline elapsed or the send threw). If this connection had already proven
636
- * pong capability it is a genuine liveness timeout; otherwise it counts toward the feature-detect
637
- * after PROBE_FAILURE_LIMIT never-ponged connections the watchdog disables itself for good (an old
638
- * server without auto-pong). Either way, force-reconnect through the existing backoff path. */
1165
+ * pong capability it is a genuine liveness timeout. If pong capability was proven EARLIER in this
1166
+ * client's lifetime (CTC-281), a never-ponged connection is the incident signature a half-open
1167
+ * socket against a server we KNOW auto-pongs so it too is a liveness failure and must NEVER count
1168
+ * toward the feature-detect (during the Jul 17-23 windows, 3 such sockets permanently disabled
1169
+ * detection). Only while capability is UNPROVEN does the failure count toward the DEGRADE — after
1170
+ * PROBE_FAILURE_LIMIT never-ponged connections the watchdog backs its probes off to
1171
+ * DEGRADED_PROBE_MULTIPLIER x pingIntervalMs (an old server without auto-pong costs one bounded
1172
+ * reconnect per degraded window; a mid-incident restart — per-process latch reset — still detects
1173
+ * the next half-open within one degraded window, never restart-only; CTC-281). Every path
1174
+ * force-reconnects through the existing backoff. */
639
1175
  onProbeUnanswered() {
640
1176
  if (this.pongObserved) {
641
1177
  this.log("warn", "liveness timeout: no frame within the pong deadline; reconnecting");
642
1178
  }
1179
+ else if (this.pongEverObserved) {
1180
+ // A distinct signal from the plain liveness timeout: a PROVEN-pong server delivered zero frames
1181
+ // on a whole connection — the fleet-incident shape (server accepts upgrades, feed is dead).
1182
+ this.log("warn", "liveness timeout on a never-ponged connection against a proven-pong server (half-open or dead feed); reconnecting — watchdog stays armed (CTC-281)");
1183
+ }
643
1184
  else {
644
1185
  this.probeFailures += 1;
645
- if (this.probeFailures >= PROBE_FAILURE_LIMIT) {
646
- this.watchdogDisabled = true;
647
- this.log("warn", `liveness watchdog disabled after ${PROBE_FAILURE_LIMIT} unanswered probes (server lacks auto-pong); relying on close/error detection`);
1186
+ if (this.probeFailures >= PROBE_FAILURE_LIMIT && !this.watchdogDegraded) {
1187
+ this.watchdogDegraded = true;
1188
+ this.log("warn", `liveness watchdog degraded after ${PROBE_FAILURE_LIMIT} unanswered probes (server may lack auto-pong); re-probing every ${DEGRADED_PROBE_MULTIPLIER}x pingIntervalMs (CTC-281)`);
648
1189
  }
649
1190
  }
650
1191
  this.forceReconnect();
@@ -676,6 +1217,20 @@ export class LiveSyncClient {
676
1217
  // A pending gap re-request dies with its socket: the timer must not fire against the next one
677
1218
  // (whose onopen re-baselines and re-requests from the durable cursor anyway).
678
1219
  this.clearGapTimer();
1220
+ // The connect deadline + onerror fallback are per-connection too (CTC-281) — they die with the
1221
+ // socket on both teardown routes (closeSocket and the server-close path), same as the pair above.
1222
+ this.clearConnectTimers();
1223
+ }
1224
+ /** Clear the per-connection connect/open deadline + onerror→onclose fallback (CTC-281). */
1225
+ clearConnectTimers() {
1226
+ if (this.connectTimer != null) {
1227
+ clearTimeout(this.connectTimer);
1228
+ this.connectTimer = null;
1229
+ }
1230
+ if (this.errorFallbackTimer != null) {
1231
+ clearTimeout(this.errorFallbackTimer);
1232
+ this.errorFallbackTimer = null;
1233
+ }
679
1234
  }
680
1235
  }
681
1236
  /** Parse a WS frame (string or ArrayBuffer) into a known server frame, or null for anything malformed. */