@catalyst-cloud/sdk 0.5.0 → 0.7.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.
@@ -30,10 +30,53 @@
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).
36
+ //
37
+ // Gap detection (CTL-1402): the server's live push (`broadcastChange`) is at-most-once — a send into a
38
+ // half-open socket is silently swallowed, and the dropped frame used to be sealed over permanently the
39
+ // moment the next delivered frame advanced the cursor. The change_log itself is durable, so every lost
40
+ // frame is recoverable — the client just never asked again. This client now tracks the contiguity of
41
+ // delivered seqs: a frame arriving at `seq > deliveredSeq + 1` is NOT delivered to `onChange`; instead
42
+ // the client re-requests the hole with the SAME `{type:"sync", after:<deliveredSeq>}` control frame it
43
+ // already sends on every (re)connect, and the mirror replays the missing rows as ordinary change
44
+ // frames through this same path (server support has existed since CTC-63 — `replaySince` is keyset-
45
+ // paginated and answers `{type:"resync"}` on underflow). Bounded: after `gapRetryLimit` no-PROGRESS
46
+ // windows (the heal deadline re-arms on every delivered frame, so a big-but-advancing heal never
47
+ // escalates) the client escalates to the full re-seed path rather than spinning — a gap is never
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: the replica's seedFromSnapshot self-bounds via its
65
+ // idle abort, but a consumer-supplied reseed — the browser's OPFS seed() over an unbounded fetch —
66
+ // can hang, and "resyncing" holds no socket and suppresses scheduleReconnect, so without this bound
67
+ // it was the one remaining zero-timer state; a timed-out reseed is ABANDONED, its late settle
68
+ // discarded, and the client re-enters backoff); (5) closeSocket() escalates past `close()` to a duck-typed `terminate()` (Bun /
69
+ // the 'ws' package expose one; undici does not — its close-handshake wait is why teardown must not
70
+ // depend on a graceful close against a half-open peer). Every transition emits the `catalyst.replica.gap` log/counter signal, the
71
+ // detector the per-frame apply telemetry is structurally blind to (an undelivered frame lands in no
72
+ // apply bucket). Gaps are the STEADY-STATE path here — the mirror's reconcile pass appends change_log
73
+ // rows it never broadcasts, so every pass punches a hole that heals via re-request — hence `detected`
74
+ // /`healed` log at INFO and only `escalated` alerts. A reconcile pass with NO webhook frame after it
75
+ // would leave nothing to detect the hole FROM, so the mirror also broadcasts one end-of-pass
76
+ // `{type:"head", seq:<feed head>}` nudge; the client treats a head beyond its baseline exactly like a
77
+ // beyond-gap change frame (re-request the hole `deliveredSeq+1..head`) but never applies it.
35
78
  import { PING_FRAME } from "./types.js";
36
- import { NOOP_TELEMETRY, createTelemetry, CATALYST_ATTR, REPLICA_SPAN, DEFAULT_SCOPE_NAME, } from "./otel.js";
79
+ import { NOOP_TELEMETRY, createTelemetry, CATALYST_ATTR, REPLICA_LOG, REPLICA_METRIC, REPLICA_SPAN, DEFAULT_SCOPE_NAME, } from "./otel.js";
37
80
  /** Resolve the runtime global WebSocket, or fail with an actionable message. */
38
81
  function defaultWsFactory(url) {
39
82
  const Ctor = globalThis.WebSocket;
@@ -67,11 +110,33 @@ export function buildConnectUrl(opts) {
67
110
  return `${origin}${opts.connectPath}?${params.toString()}`;
68
111
  }
69
112
  /**
70
- * Consecutive opened-then-never-ponged connections after which the watchdog disables itself for the
71
- * client's lifetime (feature-detect for a server without auto-pong). Bounds worst-case reconnect
72
- * churn against an old server to exactly this many attempts, making mirror/SDK deploy order harmless.
113
+ * Consecutive opened-then-never-ponged connections after which the watchdog DEGRADES itself
114
+ * (feature-detect for a server without auto-pong). Bounds worst-case reconnect churn against an old
115
+ * server, making mirror/SDK deploy order harmless.
73
116
  */
74
117
  const PROBE_FAILURE_LIMIT = 3;
118
+ /**
119
+ * Degraded-watchdog probe interval, as a multiple of `pingIntervalMs` (CTC-281). After
120
+ * {@link PROBE_FAILURE_LIMIT} never-ponged connections the watchdog does NOT turn off — it re-probes
121
+ * at this heavily backed-off cadence (stock: every 15 min instead of 90 s). A hard lifetime disable
122
+ * was the incident's restart-only residual: the `pongEverObserved` latch is per-PROCESS, so a client
123
+ * (re)started inside an incident window (supervisors restarted processes mid-window) came up
124
+ * unproven, burned its 3 probes against open-but-silent sockets, and went permanently blind — the
125
+ * next half-open then froze it as "live" forever, and only another restart (which repeats the cycle)
126
+ * cleared it. Degrading instead keeps a probe pending in EVERY non-stopped state: a half-open socket
127
+ * under a degraded watchdog is still detected within ~this multiple of the interval, and the first
128
+ * pong after recovery re-arms full-speed detection (and latches capability as proven). Against a
129
+ * genuinely old server the cost is one bounded reconnect per degraded window — churn, never wedge.
130
+ */
131
+ const DEGRADED_PROBE_MULTIPLIER = 10;
132
+ /**
133
+ * How long (ms) after `onerror` to wait for the spec-mandated follow-up `onclose` before forcing the
134
+ * reconnect ourselves (CTC-281). WHATWG requires close-after-error, but real impls have shipped
135
+ * violations (undici #3697 "close not emitted on error", #3546 "close not fired if the connection
136
+ * failed to be established") — and `WebSocketLike` is structural, so an injected impl is trusted
137
+ * blindly. Pre-open, a missing onclose used to be a ZERO-timer permanent-"error" wedge.
138
+ */
139
+ const ERROR_CLOSE_GRACE_MS = 5_000;
75
140
  export class LiveSyncClient {
76
141
  baseUrl;
77
142
  accountId;
@@ -86,6 +151,10 @@ export class LiveSyncClient {
86
151
  maxBackoffMs;
87
152
  pingIntervalMs;
88
153
  pongTimeoutMs;
154
+ openTimeoutMs;
155
+ reseedTimeoutMs;
156
+ gapTimeoutMs;
157
+ gapRetryLimit;
89
158
  wsFactory;
90
159
  log;
91
160
  telemetryConfig;
@@ -99,6 +168,21 @@ export class LiveSyncClient {
99
168
  telemetry = NOOP_TELEMETRY;
100
169
  /** The in-flight connect-attempt span (ended OK on open, ERROR on construct-fail / close-before-open). */
101
170
  connectSpan = null;
171
+ // ── Gap-detection state (CTL-1402) ──
172
+ /** Transport high-water: the highest seq DELIVERED to onChange — the contiguity baseline. Re-seeded
173
+ * from getCursor() on every (re)open (the durable cursor never advanced past an undelivered frame,
174
+ * so the on-open `{type:"sync"}` replay covers any previously-pending hole). `-1` = no baseline yet
175
+ * (fresh cursor / cursorless store) — gap checks are suspended until a baseline exists. */
176
+ deliveredSeq = -1;
177
+ /** The gap currently being re-requested, or null when the stream is contiguous. `seqFrom..seqTo` is
178
+ * the detected hole (fixed at detection); `retries` counts the sync re-requests spent on it. */
179
+ gap = null;
180
+ gapTimer = null;
181
+ /** Epoch ms of the last inbound CHANGE frame (live or replayed, delivered or gap-dropped). Unlike
182
+ * {@link lastFrameAt} this ignores pongs, so it only moves when the feed actually pushes data. */
183
+ _lastChangeFrameAt = null;
184
+ /** The `catalyst.replica.gaps` counter (no-op until telemetry resolves in start()). */
185
+ gapCounter = NOOP_TELEMETRY.counter(REPLICA_METRIC.gaps);
102
186
  // ── Liveness watchdog state (CTC-135) ──
103
187
  /** Epoch ms of the last inbound frame (any bytes: change, pong, even malformed). Null before the
104
188
  * first frame. Public via {@link lastFrameAt} — the per-frame timestamp the catalyst daemon's
@@ -111,11 +195,34 @@ export class LiveSyncClient {
111
195
  pingSentAt = 0;
112
196
  /** Client-lifetime: consecutive opened-then-never-ponged connections. Reset to 0 by ANY pong. */
113
197
  probeFailures = 0;
114
- /** Client-lifetime: after PROBE_FAILURE_LIMIT never-ponged connections we stop pinging for good
115
- * (an old server without auto-pong), degrading to close/error-only detection. */
116
- watchdogDisabled = false;
198
+ /** Client-lifetime until a pong: after PROBE_FAILURE_LIMIT never-ponged connections the watchdog
199
+ * DEGRADES to a {@link DEGRADED_PROBE_MULTIPLIER}x-slower re-probe (an old server without auto-pong)
200
+ * — it never turns off outright, so detection is never a restart-only casualty (CTC-281). Only
201
+ * reachable while pong capability is UNPROVEN ({@link pongEverObserved}); the first pong clears it. */
202
+ watchdogDegraded = false;
203
+ /** Client-lifetime pong latch (CTC-281): has ANY connection EVER answered a ping? Once true, the
204
+ * server's auto-pong capability is PROVEN for good — a later never-ponged connection is a liveness
205
+ * failure (the incident's open-but-silent socket), never feature-detect evidence, so the watchdog
206
+ * can no longer even degrade itself. PER-PROCESS by design — which is exactly why the degrade must
207
+ * be soft (see {@link DEGRADED_PROBE_MULTIPLIER}): a restart mid-incident resets this latch. */
208
+ pongEverObserved = false;
117
209
  pingTimer = null;
118
210
  pongDeadline = null;
211
+ /** Per-CONNECTION connect/open deadline (CTC-281): armed when the socket is constructed, cleared on
212
+ * open/close/teardown. The only timer pending between openSocket() and onopen — the guarantee that
213
+ * a never-firing ws impl cannot leave the client wedged in "connecting" with nothing scheduled. */
214
+ connectTimer = null;
215
+ /** Per-CONNECTION onerror→onclose fallback (CTC-281): armed by onerror, fires forceReconnect once
216
+ * if the impl never follows error with close (undici #3697/#3546). Cleared on open/close/teardown. */
217
+ errorFallbackTimer = null;
218
+ /** Per-CONNECTION: has THIS socket fired onopen? The connect-deadline's late-timer guard (a
219
+ * throttled background tab can fire the deadline after onopen already ran and cleared it). */
220
+ socketOpened = false;
221
+ /** The pending reseed deadline (CTC-281) — the timer that makes "resyncing" (no socket, reconnect
222
+ * suppressed) a bounded state instead of a restart-only wedge. Cleared when the reseed settles in
223
+ * time and by stop() (ask 4: stop() leaves NOTHING pending). At most one reseed is ever in flight
224
+ * (`resyncing` guards the resync path; the boot seed runs before any socket exists). */
225
+ reseedTimer = null;
119
226
  constructor(opts) {
120
227
  this.baseUrl = stripTrailingSlashes(opts.baseUrl);
121
228
  this.accountId = opts.accountId;
@@ -130,6 +237,10 @@ export class LiveSyncClient {
130
237
  this.maxBackoffMs = opts.maxBackoffMs ?? 30_000;
131
238
  this.pingIntervalMs = opts.pingIntervalMs ?? 90_000;
132
239
  this.pongTimeoutMs = opts.pongTimeoutMs ?? 15_000;
240
+ this.openTimeoutMs = opts.openTimeoutMs ?? 20_000;
241
+ this.reseedTimeoutMs = opts.reseedTimeoutMs ?? 600_000;
242
+ this.gapTimeoutMs = opts.gapTimeoutMs ?? 10_000;
243
+ this.gapRetryLimit = opts.gapRetryLimit ?? 3;
133
244
  this.wsFactory = opts.wsFactory ?? defaultWsFactory;
134
245
  this.log =
135
246
  opts.log ??
@@ -143,28 +254,47 @@ export class LiveSyncClient {
143
254
  * forever" contract) — the open WebSocket keeps the process alive between deltas. In a browser the
144
255
  * returned Promise is simply never awaited; call stop() on teardown.
145
256
  */
146
- async start() {
257
+ start() {
147
258
  this.stopped = false;
148
- // Resolve the OTel seam ONCE up front (before the first reseed, so the seed span exists on the
149
- // cold-start path too). Keep the OFF path FULLY SYNCHRONOUS no `await`, so a caller that opens
150
- // the socket and inspects it in the same tick still sees it; only pay the async resolution (guarded
151
- // dynamic import, or a CatalystReplica passing its already-resolved instance) when telemetry is on.
152
- this.telemetry =
153
- this.telemetryConfig === undefined || this.telemetryConfig === false
154
- ? NOOP_TELEMETRY
155
- : await createTelemetry(this.telemetryConfig, {
156
- tracerName: DEFAULT_SCOPE_NAME,
157
- meterName: DEFAULT_SCOPE_NAME,
158
- });
159
- const saved = this.getCursor();
160
- if (saved == null) {
161
- this.setStatus("resyncing");
162
- await this.reseed();
163
- }
164
- this.openSocket();
165
- return new Promise((resolve) => {
259
+ // The done deferred is created BEFORE the boot body runs (CTC-281 N2): stop() during the cold-seed
260
+ // await used to find resolveDone still null and leave the returned promise pending forever a
261
+ // contract violation for a consumer awaiting start(). The boot body below is deliberately its OWN
262
+ // async task raced against this deferred, because an `async start()` suspended at `await reseed()`
263
+ // can never reach a `return done` — stop() must be able to resolve the caller regardless of the
264
+ // boot phase (openSocket() already no-ops on stopped, so a late-settling seed is harmless).
265
+ const done = new Promise((resolve) => {
166
266
  this.resolveDone = resolve;
167
267
  });
268
+ const boot = (async () => {
269
+ // Resolve the OTel seam ONCE up front (before the first reseed, so the seed span exists on the
270
+ // cold-start path too). Keep the OFF path FULLY SYNCHRONOUS — no `await`, so a caller that opens
271
+ // the socket and inspects it in the same tick still sees it (the boot body runs synchronously up
272
+ // to its first await); only pay the async resolution (guarded dynamic import, or a
273
+ // CatalystReplica passing its already-resolved instance) when telemetry is on.
274
+ this.telemetry =
275
+ this.telemetryConfig === undefined || this.telemetryConfig === false
276
+ ? NOOP_TELEMETRY
277
+ : await createTelemetry(this.telemetryConfig, {
278
+ tracerName: DEFAULT_SCOPE_NAME,
279
+ meterName: DEFAULT_SCOPE_NAME,
280
+ });
281
+ this.gapCounter = this.telemetry.counter(REPLICA_METRIC.gaps, {
282
+ description: "Change-feed seq-gap lifecycle events (detected/healed/escalated).",
283
+ unit: "{gap}",
284
+ });
285
+ const saved = this.getCursor();
286
+ if (saved == null) {
287
+ this.setStatus("resyncing");
288
+ // Bounded like the resync-path reseed (CTC-281): a hanging COLD seed surfaces as a start()
289
+ // rejection (the boot arm rejects) instead of a silent forever-"resyncing" start().
290
+ await this.boundedReseed();
291
+ }
292
+ this.openSocket();
293
+ })();
294
+ // Settles when stop() resolves the deferred, OR rejects if the boot (cold seed) fails — a boot
295
+ // SUCCESS deliberately keeps waiting on `done` (the "runs forever" contract). Promise.race
296
+ // attaches handlers to both arms, so a boot rejection after stop() is never an unhandled one.
297
+ return Promise.race([done, boot.then(() => done)]);
168
298
  }
169
299
  /** Stop the client: close the socket, cancel any pending reconnect, resolve start(). Idempotent. */
170
300
  stop() {
@@ -173,6 +303,10 @@ export class LiveSyncClient {
173
303
  clearTimeout(this.reconnectTimer);
174
304
  this.reconnectTimer = null;
175
305
  }
306
+ // Ask 4 (bounded teardown): the reseed deadline must not hold process exit for up to
307
+ // reseedTimeoutMs. With it cleared a still-hanging reseed simply never settles its (now
308
+ // irrelevant) await — every post-await path in handleResync/boot checks `stopped` first.
309
+ this.clearReseedTimer();
176
310
  this.closeSocket();
177
311
  this.setStatus("stopped");
178
312
  const done = this.resolveDone;
@@ -218,13 +352,38 @@ export class LiveSyncClient {
218
352
  return;
219
353
  }
220
354
  this.ws = ws;
355
+ this.socketOpened = false;
356
+ this.clearConnectTimers(); // never stack deadlines across attempts (every teardown clears too)
357
+ // Connect/open deadline (CTC-281): from here until onopen, THIS timer is the client's only
358
+ // guaranteed pending work (the reconnectTimer that led here was already nulled). If the impl
359
+ // never fires open/close/error — a stalled upgrade with no FIN/RST, or a buggy injected ws —
360
+ // this converts the dead attempt into an ordinary backoff retry instead of a permanent
361
+ // "connecting" wedge.
362
+ if (this.openTimeoutMs > 0) {
363
+ this.connectTimer = setTimeout(() => {
364
+ this.connectTimer = null;
365
+ // Late-timer guard (same discipline as onPongDeadline): a throttled tab can fire this after
366
+ // onopen already ran, or after this socket was already replaced/torn down.
367
+ if (this.stopped || this.ws !== ws || this.socketOpened)
368
+ return;
369
+ this.log("warn", `ws open timed out after ${this.openTimeoutMs}ms; forcing reconnect`);
370
+ this.endConnectSpan(new Error("open timeout"));
371
+ this.forceReconnect();
372
+ }, this.openTimeoutMs);
373
+ }
221
374
  ws.onopen = () => {
375
+ this.socketOpened = true;
376
+ this.clearConnectTimers(); // the attempt succeeded — the open deadline + error fallback die here
222
377
  this.backoff = this.backoffMs; // a successful open resets the backoff ramp
223
378
  this.setStatus("live");
224
379
  this.endConnectSpan();
225
380
  // Fresh connection: reset per-connection watchdog state, then start the idle-ping countdown.
226
381
  this.pongObserved = false;
227
382
  this.pingSentAt = 0;
383
+ // Re-baseline gap detection from the durable cursor: it never advanced past an undelivered
384
+ // frame, so the sync we are about to send re-requests any previously-pending hole anyway.
385
+ this.clearGapState();
386
+ this.deliveredSeq = this.getCursor() ?? -1;
228
387
  this.sendSync();
229
388
  this.armPing();
230
389
  };
@@ -255,6 +414,19 @@ export class LiveSyncClient {
255
414
  catch {
256
415
  // already closing/closed
257
416
  }
417
+ // Fallback (CTC-281): if the impl violates the spec and never follows error with close (undici
418
+ // #3697/#3546), force the reconnect ourselves after a short grace. One-shot per socket, guarded
419
+ // on identity — a spec-conforming onclose lands first, clears this timer, and reconnects
420
+ // normally (scheduleReconnect's reconnectTimer check also prevents any double-schedule).
421
+ if (this.errorFallbackTimer == null) {
422
+ this.errorFallbackTimer = setTimeout(() => {
423
+ this.errorFallbackTimer = null;
424
+ if (this.stopped || this.ws !== ws)
425
+ return; // onclose (or a teardown) already handled it
426
+ this.log("warn", "ws error was never followed by close; forcing reconnect (CTC-281)");
427
+ this.forceReconnect();
428
+ }, ERROR_CLOSE_GRACE_MS);
429
+ }
258
430
  };
259
431
  }
260
432
  /** End the in-flight connect span exactly once (idempotent — nulls the handle). */
@@ -286,6 +458,23 @@ export class LiveSyncClient {
286
458
  catch {
287
459
  // already closed
288
460
  }
461
+ // Escalate past the graceful close (CTC-281): against a half-open peer the Close frame goes into
462
+ // a black hole, and undici waits on the never-answered handshake with NO timeout — the ref'd TCP
463
+ // handle then holds a supervised process's exit hostage for up to the OS retransmission timeout
464
+ // (~minutes). Bun's WebSocket and the node 'ws' package both expose a non-standard `terminate()`
465
+ // that destroys the connection immediately; duck-type it (structurally, never `as any`) and call
466
+ // it when present. Handlers are already detached above, so a hard kill is behaviorally safe;
467
+ // native/undici sockets simply lack the member and keep today's behavior (documented gap — on
468
+ // Node, inject a 'ws'-package wsFactory if bounded process exit matters).
469
+ const t = ws;
470
+ if (typeof t.terminate === "function") {
471
+ try {
472
+ t.terminate();
473
+ }
474
+ catch {
475
+ // best-effort — already destroyed
476
+ }
477
+ }
289
478
  }
290
479
  scheduleReconnect() {
291
480
  if (this.stopped || this.resyncing || this.reconnectTimer != null)
@@ -317,7 +506,24 @@ export class LiveSyncClient {
317
506
  // capability and reset the feature-detect counter; a pong is NEVER surfaced to onFrame/onChange.
318
507
  // (lastFrameAt + the pending-deadline clear already happened synchronously in onInboundFrame.)
319
508
  this.pongObserved = true;
509
+ this.pongEverObserved = true; // CTC-281: capability proven for the client's LIFETIME
320
510
  this.probeFailures = 0;
511
+ if (this.watchdogDegraded) {
512
+ // The degraded slow re-probe just paid off (the server pongs after all — recovered mid-window
513
+ // or upgraded): re-arm full-speed detection immediately (CTC-281).
514
+ this.watchdogDegraded = false;
515
+ this.log("info", "pong observed on a degraded watchdog; full-speed liveness detection re-armed (CTC-281)");
516
+ this.armPing();
517
+ }
518
+ return;
519
+ }
520
+ if (frame.type === "head") {
521
+ // Transport-internal end-of-pass nudge (CTL-1402): a reconcile pass appended change_log rows it
522
+ // never broadcast individually, so this head seq may sit beyond our contiguous baseline with no
523
+ // change frame to detect the hole FROM. Treat that as a gap to re-request — but the head frame
524
+ // is itself NEVER delivered/applied and advances NO cursor. Not surfaced to onFrame/onChange
525
+ // (same convention as pong); onInboundFrame already stamped it as liveness bytes.
526
+ this.onHeadFrame(frame);
321
527
  return;
322
528
  }
323
529
  try {
@@ -330,12 +536,201 @@ export class LiveSyncClient {
330
536
  await this.handleResync();
331
537
  return;
332
538
  }
539
+ // A change frame: live pushes and `{type:"sync"}` replays arrive through this one path by design.
540
+ this._lastChangeFrameAt = Date.now();
541
+ // Gap check (CTL-1402): only with a real baseline (deliveredSeq > 0 — a fresh/cursorless store has
542
+ // nothing to be contiguous WITH). A frame beyond deliveredSeq+1 means the frames in between were
543
+ // never delivered — re-request them instead of applying it (the replay will redeliver it in order).
544
+ if (this.deliveredSeq > 0 && frame.seq > this.deliveredSeq + 1) {
545
+ this.onGapFrame(frame);
546
+ return;
547
+ }
548
+ // Contiguous — or a duplicate/out-of-order oldie (seq <= deliveredSeq), which is passed through
549
+ // unchanged: the consumer's stale-guard already dedups it and its cursor never moves backward.
333
550
  try {
334
551
  this.onChange(frame);
335
552
  }
336
553
  catch (err) {
337
554
  this.log("error", `onChange failed for ${frame.entity} seq=${frame.seq}`, err);
338
555
  }
556
+ if (frame.seq > this.deliveredSeq) {
557
+ this.deliveredSeq = frame.seq;
558
+ if (this.gap) {
559
+ if (this.deliveredSeq >= this.gap.seqTo) {
560
+ // The replay walked the whole detected hole: contiguity is restored, live frames resume.
561
+ this.onGapHealed();
562
+ }
563
+ else {
564
+ // Progress WITHIN the hole: refund the heal deadline. It measures "no progress for
565
+ // gapTimeoutMs", NOT "not fully healed within gapTimeoutMs" — the steady-state first heal
566
+ // replays thousands of frames (keyset-paginated server-side), and a slow consumer / heavy
567
+ // first heal must not retry (overlapping replays on the same socket) or escalate to a full
568
+ // /snapshot WHILE frames are actively landing. The retry budget still bounds a genuinely
569
+ // STALLED heal (no frames at all for a full window).
570
+ this.armGapDeadline();
571
+ }
572
+ }
573
+ }
574
+ }
575
+ // ── Gap detection + self-healing re-request (CTL-1402) ──
576
+ /**
577
+ * A change frame arrived BEYOND the contiguous next seq: every frame in `(deliveredSeq, frame.seq)`
578
+ * was silently dropped by the at-most-once live push. The frame is NOT applied and the baseline is
579
+ * NOT advanced past the hole — the mirror's change_log is durable, so the client re-requests the
580
+ * gap with the same `{type:"sync", after}` control frame it sends on every (re)connect, and the
581
+ * mirror replays the missing rows as ordinary change frames through the same apply path. While a
582
+ * re-request is in flight, further beyond-the-gap frames (in-flight live pushes the replay will
583
+ * cover) are dropped the same way WITHOUT sending another sync — one request per gap episode.
584
+ */
585
+ onGapFrame(frame) {
586
+ if (this.gap)
587
+ return; // a re-request is already in flight; the replay redelivers this frame too
588
+ this.gap = { seqFrom: this.deliveredSeq + 1, seqTo: frame.seq - 1, retries: 0 };
589
+ this.recordGap("detected", this.gap);
590
+ this.sendGapRequest();
591
+ }
592
+ /**
593
+ * An end-of-pass head nudge (CTL-1402): `{type:"head", seq:N}` reports the mirror's current feed
594
+ * head after a reconcile pass whose change_log rows were never individually broadcast. If N sits
595
+ * beyond our contiguous baseline and no gap episode is pending, start one exactly as a beyond-gap
596
+ * change frame would — the hole is `deliveredSeq+1..N` (N itself is a real, un-broadcast row, so —
597
+ * unlike a change frame's trigger seq — it IS part of the hole) — and re-request it with the same
598
+ * bounded retry/escalation machinery and telemetry. The head frame is never applied and never
599
+ * advances a cursor. A head at/below the baseline is a no-op (already caught up); a head arriving
600
+ * while a gap is already pending is ignored (the in-flight replay already covers up to the head).
601
+ */
602
+ onHeadFrame(frame) {
603
+ if (this.gap)
604
+ return; // a re-request is pending; its replay already walks up to (past) this head
605
+ if (this.deliveredSeq <= 0)
606
+ return; // no baseline yet — nothing to be contiguous with (see gap check)
607
+ if (frame.seq <= this.deliveredSeq)
608
+ return; // caught up (or a stale head): no hole to re-request
609
+ this.gap = { seqFrom: this.deliveredSeq + 1, seqTo: frame.seq, retries: 0 };
610
+ this.recordGap("detected", this.gap);
611
+ this.sendGapRequest();
612
+ }
613
+ /** Send (or re-send) the gap re-request from the current baseline and arm the heal deadline. */
614
+ sendGapRequest() {
615
+ const req = { type: "sync", after: this.deliveredSeq };
616
+ try {
617
+ this.ws?.send(JSON.stringify(req));
618
+ }
619
+ catch (err) {
620
+ // A dead socket: leave the deadline armed — it retries/escalates, and a reconnect re-baselines.
621
+ this.log("error", "gap re-request send failed", err);
622
+ }
623
+ this.armGapDeadline();
624
+ }
625
+ /** (Re)arm the gap heal deadline. Armed when a re-request is sent AND refreshed on every frame of
626
+ * heal progress (the delivery path), so the deadline measures "no PROGRESS for gapTimeoutMs" rather
627
+ * than "not fully healed within gapTimeoutMs". `gapTimeoutMs === 0` disables it (heals only via the
628
+ * replay itself or the next reconnect). Only ever called with a gap episode pending. */
629
+ armGapDeadline() {
630
+ this.clearGapTimer();
631
+ if (this.gapTimeoutMs > 0) {
632
+ this.gapTimer = setTimeout(() => this.onGapTimeout(), this.gapTimeoutMs);
633
+ }
634
+ }
635
+ /** The heal deadline elapsed with the hole still open: retry within budget, else escalate to the
636
+ * full re-seed path. Escalation — never silent acceptance — is what keeps a drop from becoming a
637
+ * permanent hole one level up. */
638
+ onGapTimeout() {
639
+ this.gapTimer = null;
640
+ const gap = this.gap;
641
+ if (!gap || this.stopped || !this.ws)
642
+ return;
643
+ gap.retries += 1;
644
+ if (gap.retries >= this.gapRetryLimit) {
645
+ this.recordGap("escalated", gap);
646
+ this.gap = null;
647
+ void this.handleResync();
648
+ return;
649
+ }
650
+ this.sendGapRequest();
651
+ }
652
+ /** The replay redelivered the whole detected hole (deliveredSeq reached seqTo contiguously). */
653
+ onGapHealed() {
654
+ const gap = this.gap;
655
+ if (!gap)
656
+ return;
657
+ this.gap = null;
658
+ this.clearGapTimer();
659
+ this.recordGap("healed", gap);
660
+ }
661
+ /** Emit the gap lifecycle signal: a structured `catalyst.replica.gap` log line (the fleet's primary,
662
+ * Loki-materialized channel — same convention as `catalyst.replica.apply`) + a `result`-style
663
+ * low-cardinality bump of the `catalyst.replica.gaps` counter. seq_from/seq_to/size ride the log
664
+ * line as VALUES, never labels. Levels: `detected` and `healed` log at INFO — a gap is the
665
+ * steady-state path (every reconcile pass punches one that heals via re-request), so ALERTING MUST
666
+ * KEY ON `escalated` ONLY (logged at ERROR); a gap that heals is routine and boring. */
667
+ recordGap(event, gap) {
668
+ this.gapCounter.add(1, {
669
+ [CATALYST_ATTR.tenant]: this.accountId,
670
+ [CATALYST_ATTR.gapEvent]: event,
671
+ });
672
+ this.log(event === "escalated" ? "error" : "info", REPLICA_LOG.gap, {
673
+ event,
674
+ seq_from: gap.seqFrom,
675
+ seq_to: gap.seqTo,
676
+ size: gap.seqTo - gap.seqFrom + 1,
677
+ retries: gap.retries,
678
+ });
679
+ }
680
+ clearGapTimer() {
681
+ if (this.gapTimer != null) {
682
+ clearTimeout(this.gapTimer);
683
+ this.gapTimer = null;
684
+ }
685
+ }
686
+ /** Drop all gap state (pending hole + timer) — on (re)open and on the resync/re-seed path, both of
687
+ * which re-request/rebuild from the durable cursor and so supersede any pending re-request. */
688
+ clearGapState() {
689
+ this.gap = null;
690
+ this.clearGapTimer();
691
+ }
692
+ clearReseedTimer() {
693
+ if (this.reseedTimer != null) {
694
+ clearTimeout(this.reseedTimer);
695
+ this.reseedTimer = null;
696
+ }
697
+ }
698
+ /**
699
+ * Run the injected reseed() bounded by {@link LiveSyncClientOptions.reseedTimeoutMs} (CTC-281).
700
+ * The injected callback is a trust boundary like the ws impl: while it runs there is NO socket and
701
+ * scheduleReconnect is suppressed, so an unbounded await here was the last zero-timer wedge — the
702
+ * deadline below is the pending timer that upholds the header invariant for the "resyncing" state.
703
+ * On timeout the attempt is ABANDONED, not cancelled (the callback owns its own I/O bounds — the
704
+ * replica's seedFromSnapshot aborts its fetch itself): a late settle is discarded via the `settled`
705
+ * latch, and a late REJECTION is swallowed so it can never surface as an unhandled rejection.
706
+ */
707
+ boundedReseed() {
708
+ const seed = this.reseed();
709
+ if (this.reseedTimeoutMs <= 0)
710
+ return seed;
711
+ void seed.catch(() => { }); // an abandoned attempt's late rejection must never go unhandled
712
+ return new Promise((resolve, reject) => {
713
+ let settled = false;
714
+ const timer = setTimeout(() => {
715
+ if (settled)
716
+ return;
717
+ settled = true;
718
+ if (this.reseedTimer === timer)
719
+ this.reseedTimer = null;
720
+ reject(new Error(`reseed did not settle within ${this.reseedTimeoutMs}ms; abandoning (CTC-281)`));
721
+ }, this.reseedTimeoutMs);
722
+ this.reseedTimer = timer;
723
+ const finish = (fn) => {
724
+ if (settled)
725
+ return; // stale settle: the deadline already abandoned this attempt
726
+ settled = true;
727
+ clearTimeout(timer);
728
+ if (this.reseedTimer === timer)
729
+ this.reseedTimer = null;
730
+ fn();
731
+ };
732
+ seed.then((cursor) => finish(() => resolve(cursor)), (err) => finish(() => reject(err instanceof Error ? err : new Error(String(err)))));
733
+ });
339
734
  }
340
735
  /**
341
736
  * Cursor underflow: the deltas we need were evicted from the service's retained change buffer. Close the socket
@@ -347,15 +742,20 @@ export class LiveSyncClient {
347
742
  if (this.resyncing)
348
743
  return;
349
744
  this.resyncing = true;
745
+ // A full re-seed supersedes any pending gap re-request (it rebuilds from /snapshot wholesale) —
746
+ // this also covers the server answering a gap re-request with {type:"resync"} (window eviction).
747
+ this.clearGapState();
350
748
  this.setStatus("resyncing");
351
749
  this.closeSocket();
750
+ let reseeded = false;
352
751
  try {
353
752
  // The reseed runs inside an ACTIVE span so the replica's seed span (the injected reseed IS
354
753
  // seedFromSnapshot) auto-parents under this resync span.
355
754
  await this.telemetry.withActiveSpan(REPLICA_SPAN.resync, { [CATALYST_ATTR.tenant]: this.accountId }, async () => {
356
- const cursor = await this.reseed();
755
+ const cursor = await this.boundedReseed();
357
756
  this.log("info", `resynced, cursor=${cursor}`);
358
757
  });
758
+ reseeded = true;
359
759
  }
360
760
  catch (err) {
361
761
  this.log("error", "resync reseed failed; will retry on reconnect", err);
@@ -363,8 +763,19 @@ export class LiveSyncClient {
363
763
  finally {
364
764
  this.resyncing = false;
365
765
  }
366
- if (!this.stopped)
766
+ if (this.stopped)
767
+ return;
768
+ if (reseeded) {
769
+ // A completed re-seed reopens immediately — the store is fresh and the endpoint just served us.
367
770
  this.openSocket();
771
+ return;
772
+ }
773
+ // A FAILED reseed re-enters the BACKOFF path (CTC-281): the old unconditional reopen made each
774
+ // gap-escalate → /snapshot-fail → reopen cycle run hot (~30-40s of upgrade + replays + /snapshot
775
+ // per client, fleet-wide, backoff reset on every open) against exactly the sick server the ticket
776
+ // covers. scheduleReconnect converges identically once the endpoint recovers — just politely.
777
+ this.setStatus("reconnecting");
778
+ this.scheduleReconnect();
368
779
  }
369
780
  // ── Liveness watchdog (CTC-135) ──
370
781
  /** Epoch ms of the last inbound frame (change, pong, or malformed) — null before the first frame.
@@ -373,6 +784,15 @@ export class LiveSyncClient {
373
784
  get lastFrameAt() {
374
785
  return this._lastFrameAt;
375
786
  }
787
+ /** Epoch ms of the last inbound CHANGE frame (live or replayed; delivered OR gap-dropped), or null
788
+ * before the first. Unlike {@link lastFrameAt} — which any bytes stamp, INCLUDING the mirror's
789
+ * watchdog auto-pongs — this only moves when the feed actually pushes data. Pairing the two lets a
790
+ * stall supervisor separate the three states CTL-1402 conflated: dead socket (both stale),
791
+ * healthy-but-unpushed socket (lastFrameAt fresh via pongs, lastChangeFrameAt stale while the
792
+ * mirror head advances), and a genuinely quiet feed (same signature, distinguished server-side). */
793
+ get lastChangeFrameAt() {
794
+ return this._lastChangeFrameAt;
795
+ }
376
796
  /** Any inbound frame: stamp liveness, cancel a pending pong deadline (it was answered), and postpone
377
797
  * the next ping so a busy stream never sends one. Runs synchronously in onmessage before parsing. */
378
798
  onInboundFrame() {
@@ -380,19 +800,24 @@ export class LiveSyncClient {
380
800
  this.clearPongDeadline();
381
801
  this.armPing();
382
802
  }
383
- /** (Re)arm the idle-ping timer. No-op when the watchdog is disabled/off or there is no live socket,
384
- * so it is safe to call on every frame. A setTimeout chain (not setInterval): each frame resets it. */
803
+ /** (Re)arm the idle-ping timer. No-op when the watchdog is off or there is no live socket, so it is
804
+ * safe to call on every frame. A setTimeout chain (not setInterval): each frame resets it. A
805
+ * DEGRADED watchdog still arms — at {@link DEGRADED_PROBE_MULTIPLIER}x the interval — so detection
806
+ * is never permanently off (CTC-281): every live socket always has a probe pending. */
385
807
  armPing() {
386
808
  this.clearPingTimer();
387
- if (this.watchdogDisabled || this.pingIntervalMs <= 0 || this.stopped || !this.ws)
809
+ if (this.pingIntervalMs <= 0 || this.stopped || !this.ws)
388
810
  return;
389
- this.pingTimer = setTimeout(() => this.sendPing(), this.pingIntervalMs);
811
+ const interval = this.watchdogDegraded
812
+ ? this.pingIntervalMs * DEGRADED_PROBE_MULTIPLIER
813
+ : this.pingIntervalMs;
814
+ this.pingTimer = setTimeout(() => this.sendPing(), interval);
390
815
  }
391
816
  /** The feed has been idle for a full interval: send one liveness ping and start the pong deadline. A
392
817
  * synchronous send throw means the socket is already dead — treat it as an unanswered probe now. */
393
818
  sendPing() {
394
819
  this.pingTimer = null; // this timer just fired
395
- if (this.stopped || this.watchdogDisabled || !this.ws)
820
+ if (this.stopped || !this.ws)
396
821
  return;
397
822
  this.pingSentAt = Date.now();
398
823
  try {
@@ -419,18 +844,30 @@ export class LiveSyncClient {
419
844
  this.onProbeUnanswered();
420
845
  }
421
846
  /** A ping went unanswered (deadline elapsed or the send threw). If this connection had already proven
422
- * pong capability it is a genuine liveness timeout; otherwise it counts toward the feature-detect
423
- * after PROBE_FAILURE_LIMIT never-ponged connections the watchdog disables itself for good (an old
424
- * server without auto-pong). Either way, force-reconnect through the existing backoff path. */
847
+ * pong capability it is a genuine liveness timeout. If pong capability was proven EARLIER in this
848
+ * client's lifetime (CTC-281), a never-ponged connection is the incident signature a half-open
849
+ * socket against a server we KNOW auto-pongs so it too is a liveness failure and must NEVER count
850
+ * toward the feature-detect (during the Jul 17-23 windows, 3 such sockets permanently disabled
851
+ * detection). Only while capability is UNPROVEN does the failure count toward the DEGRADE — after
852
+ * PROBE_FAILURE_LIMIT never-ponged connections the watchdog backs its probes off to
853
+ * DEGRADED_PROBE_MULTIPLIER x pingIntervalMs (an old server without auto-pong costs one bounded
854
+ * reconnect per degraded window; a mid-incident restart — per-process latch reset — still detects
855
+ * the next half-open within one degraded window, never restart-only; CTC-281). Every path
856
+ * force-reconnects through the existing backoff. */
425
857
  onProbeUnanswered() {
426
858
  if (this.pongObserved) {
427
859
  this.log("warn", "liveness timeout: no frame within the pong deadline; reconnecting");
428
860
  }
861
+ else if (this.pongEverObserved) {
862
+ // A distinct signal from the plain liveness timeout: a PROVEN-pong server delivered zero frames
863
+ // on a whole connection — the fleet-incident shape (server accepts upgrades, feed is dead).
864
+ 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)");
865
+ }
429
866
  else {
430
867
  this.probeFailures += 1;
431
- if (this.probeFailures >= PROBE_FAILURE_LIMIT) {
432
- this.watchdogDisabled = true;
433
- this.log("warn", `liveness watchdog disabled after ${PROBE_FAILURE_LIMIT} unanswered probes (server lacks auto-pong); relying on close/error detection`);
868
+ if (this.probeFailures >= PROBE_FAILURE_LIMIT && !this.watchdogDegraded) {
869
+ this.watchdogDegraded = true;
870
+ 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)`);
434
871
  }
435
872
  }
436
873
  this.forceReconnect();
@@ -459,6 +896,23 @@ export class LiveSyncClient {
459
896
  clearLivenessTimers() {
460
897
  this.clearPingTimer();
461
898
  this.clearPongDeadline();
899
+ // A pending gap re-request dies with its socket: the timer must not fire against the next one
900
+ // (whose onopen re-baselines and re-requests from the durable cursor anyway).
901
+ this.clearGapTimer();
902
+ // The connect deadline + onerror fallback are per-connection too (CTC-281) — they die with the
903
+ // socket on both teardown routes (closeSocket and the server-close path), same as the pair above.
904
+ this.clearConnectTimers();
905
+ }
906
+ /** Clear the per-connection connect/open deadline + onerror→onclose fallback (CTC-281). */
907
+ clearConnectTimers() {
908
+ if (this.connectTimer != null) {
909
+ clearTimeout(this.connectTimer);
910
+ this.connectTimer = null;
911
+ }
912
+ if (this.errorFallbackTimer != null) {
913
+ clearTimeout(this.errorFallbackTimer);
914
+ this.errorFallbackTimer = null;
915
+ }
462
916
  }
463
917
  }
464
918
  /** Parse a WS frame (string or ArrayBuffer) into a known server frame, or null for anything malformed. */
@@ -486,6 +940,8 @@ export function parseFrame(data) {
486
940
  return parsed;
487
941
  if (type === "pong")
488
942
  return parsed;
943
+ if (type === "head")
944
+ return parsed;
489
945
  return null;
490
946
  }
491
947
  //# sourceMappingURL=live-sync-client.js.map