@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.
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/live-sync-client.d.ts +162 -9
- package/dist/live-sync-client.d.ts.map +1 -1
- package/dist/live-sync-client.js +497 -41
- package/dist/live-sync-client.js.map +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js.map +1 -1
- package/dist/otel.d.ts +14 -0
- package/dist/otel.d.ts.map +1 -1
- package/dist/otel.js +9 -0
- package/dist/otel.js.map +1 -1
- package/dist/replica/catalyst-replica.d.ts +39 -1
- package/dist/replica/catalyst-replica.d.ts.map +1 -1
- package/dist/replica/catalyst-replica.js +202 -69
- package/dist/replica/catalyst-replica.js.map +1 -1
- package/dist/types.d.ts +17 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/live-sync-client.js
CHANGED
|
@@ -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
|
|
34
|
-
// watchdog against an old server that never pongs —
|
|
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
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
115
|
-
* (an old server without auto-pong)
|
|
116
|
-
|
|
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
|
-
|
|
257
|
+
start() {
|
|
147
258
|
this.stopped = false;
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
384
|
-
*
|
|
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.
|
|
809
|
+
if (this.pingIntervalMs <= 0 || this.stopped || !this.ws)
|
|
388
810
|
return;
|
|
389
|
-
|
|
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 ||
|
|
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
|
|
423
|
-
*
|
|
424
|
-
* server
|
|
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.
|
|
433
|
-
this.log("warn", `liveness watchdog
|
|
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
|