@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.
- package/README.md +92 -0
- package/dist/browser.d.ts +9 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +24 -0
- package/dist/browser.js.map +1 -0
- package/dist/live-sync-client.d.ts +203 -13
- package/dist/live-sync-client.d.ts.map +1 -1
- package/dist/live-sync-client.js +605 -50
- package/dist/live-sync-client.js.map +1 -1
- package/dist/replica/browser/apply.d.ts +29 -0
- package/dist/replica/browser/apply.d.ts.map +1 -0
- package/dist/replica/browser/apply.js +59 -0
- package/dist/replica/browser/apply.js.map +1 -0
- package/dist/replica/browser/browser-lock.d.ts +25 -0
- package/dist/replica/browser/browser-lock.d.ts.map +1 -0
- package/dist/replica/browser/browser-lock.js +91 -0
- package/dist/replica/browser/browser-lock.js.map +1 -0
- package/dist/replica/browser/browser-replica.d.ts +237 -0
- package/dist/replica/browser/browser-replica.d.ts.map +1 -0
- package/dist/replica/browser/browser-replica.js +952 -0
- package/dist/replica/browser/browser-replica.js.map +1 -0
- package/dist/replica/browser/db.worker.d.ts +2 -0
- package/dist/replica/browser/db.worker.d.ts.map +1 -0
- package/dist/replica/browser/db.worker.js +40 -0
- package/dist/replica/browser/db.worker.js.map +1 -0
- package/dist/replica/browser/delta-queue.d.ts +187 -0
- package/dist/replica/browser/delta-queue.d.ts.map +1 -0
- package/dist/replica/browser/delta-queue.js +328 -0
- package/dist/replica/browser/delta-queue.js.map +1 -0
- package/dist/replica/browser/ports.d.ts +39 -0
- package/dist/replica/browser/ports.d.ts.map +1 -0
- package/dist/replica/browser/ports.js +144 -0
- package/dist/replica/browser/ports.js.map +1 -0
- package/dist/replica/browser/protocol.d.ts +129 -0
- package/dist/replica/browser/protocol.d.ts.map +1 -0
- package/dist/replica/browser/protocol.js +12 -0
- package/dist/replica/browser/protocol.js.map +1 -0
- package/dist/replica/browser/seed-read-gate.d.ts +13 -0
- package/dist/replica/browser/seed-read-gate.d.ts.map +1 -0
- package/dist/replica/browser/seed-read-gate.js +36 -0
- package/dist/replica/browser/seed-read-gate.js.map +1 -0
- package/dist/replica/browser/seed-session.d.ts +23 -0
- package/dist/replica/browser/seed-session.d.ts.map +1 -0
- package/dist/replica/browser/seed-session.js +57 -0
- package/dist/replica/browser/seed-session.js.map +1 -0
- package/dist/replica/browser/snapshot-stream.d.ts +24 -0
- package/dist/replica/browser/snapshot-stream.d.ts.map +1 -0
- package/dist/replica/browser/snapshot-stream.js +113 -0
- package/dist/replica/browser/snapshot-stream.js.map +1 -0
- package/dist/replica/browser/sqlite-db.d.ts +9 -0
- package/dist/replica/browser/sqlite-db.d.ts.map +1 -0
- package/dist/replica/browser/sqlite-db.js +28 -0
- package/dist/replica/browser/sqlite-db.js.map +1 -0
- package/dist/replica/browser/support.d.ts +2 -0
- package/dist/replica/browser/support.d.ts.map +1 -0
- package/dist/replica/browser/support.js +30 -0
- package/dist/replica/browser/support.js.map +1 -0
- package/dist/replica/browser/validate.d.ts +17 -0
- package/dist/replica/browser/validate.d.ts.map +1 -0
- package/dist/replica/browser/validate.js +47 -0
- package/dist/replica/browser/validate.js.map +1 -0
- package/dist/replica/browser/worker-core.d.ts +10 -0
- package/dist/replica/browser/worker-core.d.ts.map +1 -0
- package/dist/replica/browser/worker-core.js +181 -0
- package/dist/replica/browser/worker-core.js.map +1 -0
- package/dist/replica/catalyst-replica.d.ts +34 -10
- package/dist/replica/catalyst-replica.d.ts.map +1 -1
- package/dist/replica/catalyst-replica.js +195 -83
- package/dist/replica/catalyst-replica.js.map +1 -1
- package/dist/replica/migration-shape.d.ts +11 -0
- package/dist/replica/migration-shape.d.ts.map +1 -0
- package/dist/replica/migration-shape.js +25 -0
- package/dist/replica/migration-shape.js.map +1 -0
- package/package.json +16 -3
package/dist/live-sync-client.js
CHANGED
|
@@ -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
|
|
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).
|
|
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.
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
152
|
-
* (an old server without auto-pong)
|
|
153
|
-
|
|
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
|
-
|
|
316
|
+
start() {
|
|
186
317
|
this.stopped = false;
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
561
|
-
const cursor = await this.
|
|
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 (
|
|
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
|
|
598
|
-
*
|
|
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.
|
|
1127
|
+
if (this.pingIntervalMs <= 0 || this.stopped || !this.ws)
|
|
602
1128
|
return;
|
|
603
|
-
|
|
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 ||
|
|
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
|
|
637
|
-
*
|
|
638
|
-
* server
|
|
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.
|
|
647
|
-
this.log("warn", `liveness watchdog
|
|
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. */
|