cursedbelt-server 1.1.0 → 2.0.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/server/sync/http.d.ts +20 -3
- package/dist/server/sync/http.js +20 -14
- package/dist/server/sync/index.d.ts +10 -2
- package/dist/server/sync/index.js +9 -1
- package/dist/server/sync/planner.d.ts +38 -8
- package/dist/server/sync/planner.js +32 -8
- package/dist/server/sync/signal.d.ts +161 -0
- package/dist/server/sync/signal.js +348 -0
- package/dist/server/sync/timer.d.ts +63 -19
- package/dist/server/sync/timer.js +104 -45
- package/dist/server/sync/types.d.ts +0 -2
- package/package.json +1 -1
- package/src/noTimerDialsAPeer.spec.ts +469 -0
- package/src/server/sync/http.ts +31 -16
- package/src/server/sync/index.ts +23 -1
- package/src/server/sync/planner.spec.ts +33 -16
- package/src/server/sync/planner.ts +48 -11
- package/src/server/sync/signal.spec.ts +306 -0
- package/src/server/sync/signal.ts +422 -0
- package/src/server/sync/timer.spec.ts +97 -16
- package/src/server/sync/timer.ts +124 -47
- package/src/server/sync/types.ts +0 -2
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The peer says it has news. Nobody asks.**
|
|
3
|
+
*
|
|
4
|
+
* This is the half of the replication story that deleting the dialer (`./timer`,
|
|
5
|
+
* `./http`'s old `GET /requested`) would otherwise have left open, and it is the
|
|
6
|
+
* reason that deletion costs nothing.
|
|
7
|
+
*
|
|
8
|
+
* ## The three ways, and why this is the one
|
|
9
|
+
*
|
|
10
|
+
* Push-from-the-Mac answers Mac → peer completely: the side that has news calls
|
|
11
|
+
* `markDirty()` and pushes. It does not answer **peer → Mac**, and something must:
|
|
12
|
+
*
|
|
13
|
+
* | | |
|
|
14
|
+
* |---|---|
|
|
15
|
+
* | the Mac polls an outbox | ❌ polling. Ruled out 2026-09-15 |
|
|
16
|
+
* | the peer dials the Mac | ❌ needs the Mac reachable — a tunnel, an open port, a laptop that cannot sleep. That is the thing we are trying to delete |
|
|
17
|
+
* | 🟢 **the Mac holds a stream open and the peer pushes down it** | ✅ the Mac is still purely a client: it dials **out**, listens on nothing, and no timer fires |
|
|
18
|
+
*
|
|
19
|
+
* So: one long-lived outbound connection, opened by the dialing half, carrying a
|
|
20
|
+
* `news` frame whenever the receiver's op log grows. The Mac's reaction is
|
|
21
|
+
* `syncNow()` — the push it would have made anyway, at the moment there is a reason
|
|
22
|
+
* for it instead of 4,320 times a day on the chance.
|
|
23
|
+
*
|
|
24
|
+
* ## 🔴 Two honest caveats, neither of which is a poll
|
|
25
|
+
*
|
|
26
|
+
* · **A sleeping Mac drops the connection.** On wake it reconnects and catches up
|
|
27
|
+
* from `since` — the same catch-up a push model needs anyway, so it costs
|
|
28
|
+
* nothing extra. `since` is the receiver's head as the client last saw it.
|
|
29
|
+
* · **The reconnect backoff is a timer.** It runs *only while disconnected*, never
|
|
30
|
+
* against a healthy peer, and it is written below as an `await sleep()` in a
|
|
31
|
+
* loop rather than a `setTimeout` that calls `fetch` — the shape matters,
|
|
32
|
+
* because the second one is indistinguishable from the poll this file exists to
|
|
33
|
+
* replace. A connected client issues **exactly one** outbound request for as
|
|
34
|
+
* long as it stays connected, and `signal.spec.ts` measures that over 24
|
|
35
|
+
* simulated hours.
|
|
36
|
+
*
|
|
37
|
+
* ## Why this is not `cursedbelt-cc`'s `serverPush.ts`
|
|
38
|
+
*
|
|
39
|
+
* `libs/cursedbelt-cc/src/server/stream/serverPush.ts` is the real tenant-scoped
|
|
40
|
+
* push tier — heartbeats, backpressure, SSE — and on shape it is exactly this.
|
|
41
|
+
* It is not imported here for a reason that survives the preference:
|
|
42
|
+
* `cursedbelt-server/sync` is a **leaf** whose only runtime dependency is `hono`
|
|
43
|
+
* (see `./index.ts`), and `cursedbelt-cc` is a separate published package with zero
|
|
44
|
+
* consumers today, parked whole. Importing it would make every app that wants an
|
|
45
|
+
* op log install the cc platform to get one — the precise trade `./index.ts` was
|
|
46
|
+
* split to refuse, and the one `leafSubpathsImportNothing.spec.ts` now gates.
|
|
47
|
+
*
|
|
48
|
+
* The hub below is therefore the same model at a tenth the surface: this channel
|
|
49
|
+
* carries one frame kind, to one peer, with no tenant dimension. If `cursedbelt-cc`
|
|
50
|
+
* ever gains consumers and the two packages can depend on each other, {@link
|
|
51
|
+
* SyncSignalHub} is a strict subset of `ServerPushHub` and swaps in without
|
|
52
|
+
* touching a producer.
|
|
53
|
+
*
|
|
54
|
+
* ## The Workers path, named and not required yet
|
|
55
|
+
*
|
|
56
|
+
* On Cloudflare the durable form of "the Mac holds a connection all day" is a
|
|
57
|
+
* **Durable Object with WebSocket Hibernation**: the socket stays open while the
|
|
58
|
+
* object is evicted from memory, so an idle connection is not billed for duration.
|
|
59
|
+
* That is what keeps this free at rest, and it is the shape task `195` (the Mac is
|
|
60
|
+
* a client, never an origin) lands on. Nothing here needs it today — SSE over the
|
|
61
|
+
* existing receiver works on Bun and on Workers alike — but the frame format below
|
|
62
|
+
* is deliberately transport-free so the move is a transport swap, not a protocol
|
|
63
|
+
* change.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/** SSE frame headers. `x-accel-buffering` keeps nginx from holding the stream in a
|
|
67
|
+
* proxy buffer, which turns a live channel into a silent one — the same trap that
|
|
68
|
+
* makes a cached `200` sit over a dead origin elsewhere in this fleet. */
|
|
69
|
+
export const SIGNAL_SSE_HEADERS: Readonly<Record<string, string>> = Object.freeze({
|
|
70
|
+
"content-type": "text/event-stream",
|
|
71
|
+
"cache-control": "no-store",
|
|
72
|
+
connection: "keep-alive",
|
|
73
|
+
"x-accel-buffering": "no",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Comment-frame interval. Without it an idle stream dies silently behind a proxy
|
|
78
|
+
* with an idle timeout and the client never learns it should reconnect.
|
|
79
|
+
*
|
|
80
|
+
* 🔴 This is a heartbeat on an ALREADY-OPEN socket, not a request. It is row (c) of
|
|
81
|
+
* the 2026-09-15 ruling — a local housekeeping timer that issues no outbound
|
|
82
|
+
* request — and it is why that row had to be carved out explicitly: a rule that
|
|
83
|
+
* said "no `setInterval`" would have deleted the one mechanism that keeps a
|
|
84
|
+
* no-polling design alive.
|
|
85
|
+
*/
|
|
86
|
+
const HEARTBEAT_MS = 15_000;
|
|
87
|
+
|
|
88
|
+
/** What the receiver tells the dialing half. One kind, deliberately. */
|
|
89
|
+
export interface SyncNews {
|
|
90
|
+
/** The receiver's op-log head at the moment it announced. The client's `since`. */
|
|
91
|
+
head: number;
|
|
92
|
+
/** The announcing instance, so a client linked to several can tell them apart. */
|
|
93
|
+
instanceId: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface SyncSignalHub {
|
|
97
|
+
/** Tell every listener the log grew. Never throws, never blocks on a socket. */
|
|
98
|
+
announce(news: SyncNews): void;
|
|
99
|
+
/** Attach a listener. The returned handle is safe to call more than once. */
|
|
100
|
+
subscribe(listener: (news: SyncNews) => void): () => void;
|
|
101
|
+
/** Live subscriber count — the reconnect tests assert against this. */
|
|
102
|
+
readonly subscriberCount: number;
|
|
103
|
+
/** Drop every subscriber (server shutdown). */
|
|
104
|
+
close(): void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createSyncSignalHub(): SyncSignalHub {
|
|
108
|
+
const listeners = new Set<(news: SyncNews) => void>();
|
|
109
|
+
let closed = false;
|
|
110
|
+
return {
|
|
111
|
+
announce(news) {
|
|
112
|
+
if (closed) return;
|
|
113
|
+
// Snapshot: a listener that unsubscribes itself mid-fanout (the normal
|
|
114
|
+
// teardown path) must not mutate the set being iterated.
|
|
115
|
+
for (const listener of [...listeners]) {
|
|
116
|
+
try {
|
|
117
|
+
listener(news);
|
|
118
|
+
} catch {
|
|
119
|
+
// A broken transport is that subscriber's problem, never the
|
|
120
|
+
// producer's: drop it and keep fanning out. An `announce` may be
|
|
121
|
+
// called from inside an op-log write, so it can never throw upward.
|
|
122
|
+
listeners.delete(listener);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
subscribe(listener) {
|
|
127
|
+
if (closed) return () => undefined;
|
|
128
|
+
listeners.add(listener);
|
|
129
|
+
return () => {
|
|
130
|
+
listeners.delete(listener);
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
get subscriberCount() {
|
|
134
|
+
return listeners.size;
|
|
135
|
+
},
|
|
136
|
+
close() {
|
|
137
|
+
if (closed) return;
|
|
138
|
+
closed = true;
|
|
139
|
+
listeners.clear();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface SignalStreamOptions {
|
|
145
|
+
/** Overridden only in tests, so the heartbeat can be driven by hand. */
|
|
146
|
+
setIntervalFn?: (fn: () => void, ms: number) => unknown;
|
|
147
|
+
clearIntervalFn?: (handle: unknown) => void;
|
|
148
|
+
heartbeatMs?: number;
|
|
149
|
+
/** Cancels the stream (the request's abort signal). */
|
|
150
|
+
signal?: AbortSignal;
|
|
151
|
+
/**
|
|
152
|
+
* Sent as the first frame so a client that reconnects after a sleep learns the
|
|
153
|
+
* current head without a round trip. This is the `since` catch-up.
|
|
154
|
+
*/
|
|
155
|
+
initial?: SyncNews;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Bridge a hub subscription onto a `ReadableStream` of SSE bytes.
|
|
160
|
+
*
|
|
161
|
+
* The stream ends when the client disconnects (`signal`) or the hub closes; both
|
|
162
|
+
* paths run the same teardown, so a dropped connection cannot leave a subscription
|
|
163
|
+
* — or an interval — behind.
|
|
164
|
+
*/
|
|
165
|
+
export function signalStream(
|
|
166
|
+
hub: SyncSignalHub,
|
|
167
|
+
options: SignalStreamOptions = {},
|
|
168
|
+
): ReadableStream<Uint8Array> {
|
|
169
|
+
const heartbeatMs = options.heartbeatMs ?? HEARTBEAT_MS;
|
|
170
|
+
const setIntervalFn = options.setIntervalFn ?? ((fn, ms) => setInterval(fn, ms));
|
|
171
|
+
const clearIntervalFn =
|
|
172
|
+
options.clearIntervalFn ??
|
|
173
|
+
((handle) => clearInterval(handle as ReturnType<typeof setInterval>));
|
|
174
|
+
const encoder = new TextEncoder();
|
|
175
|
+
|
|
176
|
+
let unsubscribe: () => void = () => undefined;
|
|
177
|
+
let heartbeat: unknown = null;
|
|
178
|
+
let done = false;
|
|
179
|
+
|
|
180
|
+
return new ReadableStream<Uint8Array>({
|
|
181
|
+
start(controller) {
|
|
182
|
+
const teardown = (): void => {
|
|
183
|
+
if (done) return;
|
|
184
|
+
done = true;
|
|
185
|
+
unsubscribe();
|
|
186
|
+
if (heartbeat !== null) clearIntervalFn(heartbeat);
|
|
187
|
+
heartbeat = null;
|
|
188
|
+
try {
|
|
189
|
+
controller.close();
|
|
190
|
+
} catch {
|
|
191
|
+
// Already closed by the runtime when the socket died — expected.
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const write = (chunk: string): void => {
|
|
196
|
+
if (done) return;
|
|
197
|
+
try {
|
|
198
|
+
controller.enqueue(encoder.encode(chunk));
|
|
199
|
+
} catch {
|
|
200
|
+
teardown();
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (options.initial) write(encodeNewsFrame(options.initial));
|
|
205
|
+
|
|
206
|
+
unsubscribe = hub.subscribe((news) => {
|
|
207
|
+
write(encodeNewsFrame(news));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
heartbeat = setIntervalFn(() => {
|
|
211
|
+
// A comment frame. It wakes no client-side handler and issues no
|
|
212
|
+
// request — it keeps proxies from calling the connection idle.
|
|
213
|
+
write(": keep-alive\n\n");
|
|
214
|
+
}, heartbeatMs);
|
|
215
|
+
|
|
216
|
+
if (options.signal) {
|
|
217
|
+
if (options.signal.aborted) teardown();
|
|
218
|
+
else options.signal.addEventListener("abort", teardown, { once: true });
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
cancel() {
|
|
222
|
+
done = true;
|
|
223
|
+
unsubscribe();
|
|
224
|
+
if (heartbeat !== null) clearIntervalFn(heartbeat);
|
|
225
|
+
heartbeat = null;
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Serialize one announcement as an SSE frame. */
|
|
231
|
+
export const encodeNewsFrame = (news: SyncNews): string =>
|
|
232
|
+
`event: news\ndata: ${JSON.stringify(news)}\n\n`;
|
|
233
|
+
|
|
234
|
+
/** A ready-to-return SSE `Response` — what the receiver's `GET /signal` answers. */
|
|
235
|
+
export const signalResponse = (
|
|
236
|
+
hub: SyncSignalHub,
|
|
237
|
+
options: SignalStreamOptions = {},
|
|
238
|
+
): Response =>
|
|
239
|
+
new Response(signalStream(hub, options), { headers: { ...SIGNAL_SSE_HEADERS } });
|
|
240
|
+
|
|
241
|
+
/* ----------------------------------------------------------------- the client */
|
|
242
|
+
|
|
243
|
+
export interface SignalClientOptions {
|
|
244
|
+
/** Where the peer mounted its receiver, e.g. `https://peer.example/api/sync`. */
|
|
245
|
+
basePath: string;
|
|
246
|
+
/** The same bearer the rest of the sync surface uses. */
|
|
247
|
+
token: string;
|
|
248
|
+
/** This instance's id, echoed as `x-sync-peer` so the far side can label us. */
|
|
249
|
+
selfId: string;
|
|
250
|
+
/**
|
|
251
|
+
* The peer has news. Wire this to {@link import("./timer").SyncTimerHandle.syncNow}
|
|
252
|
+
* — the whole point is that a reconciliation now happens for a REASON.
|
|
253
|
+
*/
|
|
254
|
+
onNews: (news: SyncNews) => void;
|
|
255
|
+
fetchImpl?: typeof fetch;
|
|
256
|
+
/** First reconnect step. Doubles to {@link backoffMaxMs}. Default 1s. */
|
|
257
|
+
backoffBaseMs?: number;
|
|
258
|
+
/** Reconnect ceiling — an offline laptop settles here. Default 5 min. */
|
|
259
|
+
backoffMaxMs?: number;
|
|
260
|
+
/** Swapped in tests. Resolves after `ms`; never issues a request. */
|
|
261
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
262
|
+
/** Jitter in [0,1). Real randomness by default — see the note on the stampede. */
|
|
263
|
+
jitterFn?: () => number;
|
|
264
|
+
log?: (msg: string) => void;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface SignalClient {
|
|
268
|
+
/** Close the stream and stop reconnecting. Idempotent. */
|
|
269
|
+
stop(): void;
|
|
270
|
+
/** Whether a stream is open right now — the tests assert against this. */
|
|
271
|
+
readonly connected: boolean;
|
|
272
|
+
/** Outbound requests issued since start. 🔴 The number the ruling is about. */
|
|
273
|
+
readonly requestCount: number;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Hold one stream open against the peer, calling {@link SignalClientOptions.onNews}
|
|
278
|
+
* on every `news` frame, and reconnect with jittered backoff **while disconnected**.
|
|
279
|
+
*
|
|
280
|
+
* 🔴 Read the loop shape as load-bearing. The reconnect is `await sleep(ms)` inside
|
|
281
|
+
* an async loop, NOT a `setTimeout` whose callback calls `fetch`. The second shape
|
|
282
|
+
* is byte-for-byte what a poll looks like, and a reviewer — or a checker — cannot
|
|
283
|
+
* tell the two apart once it is written that way. Here, a connected client is
|
|
284
|
+
* parked on `reader.read()` and issues nothing; `requestCount` stops moving the
|
|
285
|
+
* moment a connection succeeds, and that is what `signal.spec.ts` asserts over 24
|
|
286
|
+
* simulated hours.
|
|
287
|
+
*
|
|
288
|
+
* The jitter is not decoration either: a fixed backoff across N clients recovering
|
|
289
|
+
* from the same outage is a synchronized stampede at the instant the peer comes
|
|
290
|
+
* back up — the worst possible moment to send it a thundering herd.
|
|
291
|
+
*/
|
|
292
|
+
export function connectSyncSignal(options: SignalClientOptions): SignalClient {
|
|
293
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
294
|
+
const base = options.basePath.replace(/\/$/, "");
|
|
295
|
+
const backoffBase = options.backoffBaseMs ?? 1_000;
|
|
296
|
+
const backoffMax = options.backoffMaxMs ?? 5 * 60_000;
|
|
297
|
+
const sleep = options.sleepFn ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
298
|
+
const jitter = options.jitterFn ?? Math.random;
|
|
299
|
+
const log = options.log ?? (() => undefined);
|
|
300
|
+
|
|
301
|
+
let stopped = false;
|
|
302
|
+
let connected = false;
|
|
303
|
+
let requests = 0;
|
|
304
|
+
let failures = 0;
|
|
305
|
+
let abort: AbortController | null = null;
|
|
306
|
+
|
|
307
|
+
/** Open one stream and stay on it until it ends. Throws on any failure. */
|
|
308
|
+
const runOnce = async (): Promise<void> => {
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
abort = controller;
|
|
311
|
+
requests += 1;
|
|
312
|
+
const response = await doFetch(`${base}/signal?peer=${encodeURIComponent(options.selfId)}`, {
|
|
313
|
+
// 🔴 A followed redirect is how a moved hostname became eight days of
|
|
314
|
+
// silence on the `/pull` side — see `./http.ts`'s header. Same rule here.
|
|
315
|
+
redirect: "manual",
|
|
316
|
+
headers: {
|
|
317
|
+
authorization: `Bearer ${options.token}`,
|
|
318
|
+
"x-sync-peer": options.selfId,
|
|
319
|
+
accept: "text/event-stream",
|
|
320
|
+
},
|
|
321
|
+
signal: controller.signal,
|
|
322
|
+
});
|
|
323
|
+
if (response.status >= 300 && response.status < 400) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`sync/signal: ${response.status} — the peer address redirects to ` +
|
|
326
|
+
`${response.headers.get("location") ?? "an unnamed destination"}.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (!response.ok) throw new Error(`sync/signal: ${response.status}`);
|
|
330
|
+
if (!response.body) throw new Error("sync/signal: the peer sent no stream body");
|
|
331
|
+
|
|
332
|
+
connected = true;
|
|
333
|
+
failures = 0;
|
|
334
|
+
const reader = response.body.getReader();
|
|
335
|
+
const decoder = new TextDecoder();
|
|
336
|
+
let buffer = "";
|
|
337
|
+
try {
|
|
338
|
+
while (!stopped) {
|
|
339
|
+
const { done, value } = await reader.read();
|
|
340
|
+
if (done) break;
|
|
341
|
+
buffer += decoder.decode(value, { stream: true });
|
|
342
|
+
// SSE frames are separated by a blank line. A partial tail stays in
|
|
343
|
+
// the buffer until the rest of it arrives.
|
|
344
|
+
let split = buffer.indexOf("\n\n");
|
|
345
|
+
while (split !== -1) {
|
|
346
|
+
const frame = buffer.slice(0, split);
|
|
347
|
+
buffer = buffer.slice(split + 2);
|
|
348
|
+
const news = parseNewsFrame(frame);
|
|
349
|
+
if (news) options.onNews(news);
|
|
350
|
+
split = buffer.indexOf("\n\n");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
connected = false;
|
|
355
|
+
try {
|
|
356
|
+
await reader.cancel();
|
|
357
|
+
} catch {
|
|
358
|
+
// The socket is already gone — that is the normal end of a stream.
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The supervisor. Every `await` here is either the open stream itself or a sleep
|
|
365
|
+
* between failures; there is no path that waits on a healthy peer in order to
|
|
366
|
+
* ask it something.
|
|
367
|
+
*/
|
|
368
|
+
const supervise = async (): Promise<void> => {
|
|
369
|
+
while (!stopped) {
|
|
370
|
+
try {
|
|
371
|
+
await runOnce();
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (stopped) return;
|
|
374
|
+
failures += 1;
|
|
375
|
+
if (failures === 1) {
|
|
376
|
+
log(
|
|
377
|
+
`[sync] signal stream lost — reconnecting. This is normal (peer asleep / offline): ` +
|
|
378
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (stopped) return;
|
|
383
|
+
const step = Math.min(backoffMax, backoffBase * 2 ** Math.max(0, failures - 1));
|
|
384
|
+
await sleep(Math.round(step * (0.5 + jitter() * 0.5)));
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
void supervise();
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
stop() {
|
|
392
|
+
stopped = true;
|
|
393
|
+
abort?.abort();
|
|
394
|
+
abort = null;
|
|
395
|
+
},
|
|
396
|
+
get connected() {
|
|
397
|
+
return connected;
|
|
398
|
+
},
|
|
399
|
+
get requestCount() {
|
|
400
|
+
return requests;
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** `event: news\ndata: {...}` → {@link SyncNews}, or null for a heartbeat/unknown. */
|
|
406
|
+
export function parseNewsFrame(frame: string): SyncNews | null {
|
|
407
|
+
let isNews = false;
|
|
408
|
+
let data: string | null = null;
|
|
409
|
+
for (const line of frame.split("\n")) {
|
|
410
|
+
if (line.startsWith(":")) continue; // comment frame — the heartbeat
|
|
411
|
+
if (line.startsWith("event:")) isNews = line.slice(6).trim() === "news";
|
|
412
|
+
else if (line.startsWith("data:")) data = line.slice(5).trim();
|
|
413
|
+
}
|
|
414
|
+
if (!isNews || data === null) return null;
|
|
415
|
+
try {
|
|
416
|
+
const parsed = JSON.parse(data) as Partial<SyncNews>;
|
|
417
|
+
if (typeof parsed.head !== "number" || typeof parsed.instanceId !== "string") return null;
|
|
418
|
+
return { head: parsed.head, instanceId: parsed.instanceId };
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fake-clock tests: capture scheduled callbacks, fire them by hand. The timer's
|
|
3
|
-
* contract is
|
|
4
|
-
*
|
|
3
|
+
* contract is single-flight, unreachable-is-quiet, peer re-resolved every run —
|
|
4
|
+
* and, since 2026-09-15, **silent when there is nothing to say**.
|
|
5
5
|
*/
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
7
|
import { createSyncStatusReporter } from "./status";
|
|
@@ -35,7 +35,6 @@ function harness(overrides: Partial<SyncTimerDeps> = {}) {
|
|
|
35
35
|
},
|
|
36
36
|
head: () => 0,
|
|
37
37
|
status,
|
|
38
|
-
requestPollMs: 0, // the fast lane has its own test
|
|
39
38
|
now: () => now,
|
|
40
39
|
setTimer: (fn, ms) => {
|
|
41
40
|
const entry = { fn: fn as () => void, ms };
|
|
@@ -51,15 +50,18 @@ function harness(overrides: Partial<SyncTimerDeps> = {}) {
|
|
|
51
50
|
...overrides,
|
|
52
51
|
};
|
|
53
52
|
const handle = startSyncTimer(deps);
|
|
53
|
+
const settle = async () => {
|
|
54
|
+
// Let the sync promise chain settle.
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
57
|
+
};
|
|
54
58
|
/** Fire the most recently scheduled callback, advancing the clock by its delay. */
|
|
55
59
|
const fire = async () => {
|
|
56
60
|
const entry = scheduled.pop();
|
|
57
61
|
if (!entry) throw new Error("nothing scheduled");
|
|
58
62
|
now += entry.ms;
|
|
59
63
|
entry.fn();
|
|
60
|
-
|
|
61
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
62
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
64
|
+
await settle();
|
|
63
65
|
};
|
|
64
66
|
return {
|
|
65
67
|
scheduled,
|
|
@@ -67,6 +69,10 @@ function harness(overrides: Partial<SyncTimerDeps> = {}) {
|
|
|
67
69
|
status,
|
|
68
70
|
handle,
|
|
69
71
|
fire,
|
|
72
|
+
settle,
|
|
73
|
+
advance: (ms: number) => {
|
|
74
|
+
now += ms;
|
|
75
|
+
},
|
|
70
76
|
setOutcome: (fn: typeof outcome) => {
|
|
71
77
|
outcome = fn;
|
|
72
78
|
},
|
|
@@ -82,8 +88,35 @@ describe("sync timer", () => {
|
|
|
82
88
|
expect(h.runs).toHaveLength(1);
|
|
83
89
|
expect(h.status.read().lastOutcome).toBe("ok");
|
|
84
90
|
expect(h.status.read().role).toBe("dialer");
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 🔴 The check this whole change exists for, measured as BEHAVIOUR rather than
|
|
95
|
+
* shape. Whatever mechanism a future edit reaches for — a `setInterval`, a
|
|
96
|
+
* re-added `intervalMs`, an injected `checkRequest` dep, a recursive `schedule`
|
|
97
|
+
* — it has to book a callback through `setTimer` to fire, and this asserts that
|
|
98
|
+
* after the boot catch-up there is nothing booked at all.
|
|
99
|
+
*
|
|
100
|
+
* Simulated 24 hours: the loop is handed a healthy peer and a clean op log, and
|
|
101
|
+
* must dial exactly ONCE (the boot catch-up) and then go quiet forever.
|
|
102
|
+
*/
|
|
103
|
+
test("a healthy peer with no local news is dialed ONCE at boot, then never", async () => {
|
|
104
|
+
const h = harness();
|
|
105
|
+
await h.fire(); // the boot catch-up
|
|
106
|
+
expect(h.runs).toHaveLength(1);
|
|
107
|
+
|
|
108
|
+
// Nothing booked, and advancing a day cannot conjure anything: there is no
|
|
109
|
+
// callback left to fire.
|
|
110
|
+
expect(h.scheduled).toHaveLength(0);
|
|
111
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
112
|
+
h.advance(60 * 60_000);
|
|
113
|
+
await h.settle();
|
|
114
|
+
expect(
|
|
115
|
+
h.scheduled,
|
|
116
|
+
`hour ${hour + 1}: the loop booked a wake-up with a healthy peer and nothing to push — that is the poll the 2026-09-15 ruling deleted`,
|
|
117
|
+
).toHaveLength(0);
|
|
118
|
+
}
|
|
119
|
+
expect(h.runs, "24 hours of a healthy idle peer must cost exactly one dial").toHaveLength(1);
|
|
87
120
|
});
|
|
88
121
|
|
|
89
122
|
test("a failure backs off and reports unreachable quietly", async () => {
|
|
@@ -94,10 +127,34 @@ describe("sync timer", () => {
|
|
|
94
127
|
await h.fire(); // kickoff → fails
|
|
95
128
|
expect(h.status.read().lastOutcome).toBe("unreachable");
|
|
96
129
|
expect(h.status.read().consecutiveFailures).toBe(1);
|
|
97
|
-
|
|
130
|
+
// 🔴 The allowed timer: a retry books a wake-up, because the peer is DOWN.
|
|
131
|
+
expect(h.scheduled).toHaveLength(1);
|
|
132
|
+
await h.fire();
|
|
98
133
|
expect(h.status.read().consecutiveFailures).toBe(2);
|
|
99
134
|
});
|
|
100
135
|
|
|
136
|
+
test("recovering from a failure stops the retry timer rather than settling into it", async () => {
|
|
137
|
+
const h = harness();
|
|
138
|
+
h.setOutcome(async () => {
|
|
139
|
+
throw new Error("fetch failed");
|
|
140
|
+
});
|
|
141
|
+
await h.fire(); // fails, books a retry
|
|
142
|
+
expect(h.scheduled).toHaveLength(1);
|
|
143
|
+
h.setOutcome(async () => ({
|
|
144
|
+
remoteId: "peer",
|
|
145
|
+
pushed: 0,
|
|
146
|
+
pulled: 0,
|
|
147
|
+
conflicts: 0,
|
|
148
|
+
held: 0,
|
|
149
|
+
}));
|
|
150
|
+
await h.fire(); // the retry succeeds
|
|
151
|
+
expect(h.status.read().consecutiveFailures).toBe(0);
|
|
152
|
+
expect(
|
|
153
|
+
h.scheduled,
|
|
154
|
+
"a recovered peer must go quiet — a retry loop that survives success is a poll",
|
|
155
|
+
).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
101
158
|
test("a version-skew pause reports pausedOn without counting as a failure", async () => {
|
|
102
159
|
const h = harness();
|
|
103
160
|
h.setOutcome(async () => ({
|
|
@@ -115,7 +172,7 @@ describe("sync timer", () => {
|
|
|
115
172
|
expect(s.consecutiveFailures).toBe(0);
|
|
116
173
|
});
|
|
117
174
|
|
|
118
|
-
test("syncNow forces an immediate run", async () => {
|
|
175
|
+
test("syncNow forces an immediate run — this is the push, not a poll", async () => {
|
|
119
176
|
const h = harness();
|
|
120
177
|
await h.fire(); // kickoff
|
|
121
178
|
expect(h.runs).toHaveLength(1);
|
|
@@ -124,22 +181,46 @@ describe("sync timer", () => {
|
|
|
124
181
|
expect(h.runs).toHaveLength(2);
|
|
125
182
|
});
|
|
126
183
|
|
|
127
|
-
test("an unlinked peer is a quiet no-op re-
|
|
184
|
+
test("an unlinked peer is a quiet no-op — no re-check timer", async () => {
|
|
128
185
|
const h = harness({ resolvePeer: () => null });
|
|
129
186
|
await h.fire();
|
|
130
187
|
expect(h.runs).toHaveLength(0);
|
|
131
|
-
expect(
|
|
188
|
+
expect(
|
|
189
|
+
h.scheduled,
|
|
190
|
+
"polling for a peer that is not linked yet is still polling",
|
|
191
|
+
).toHaveLength(0);
|
|
132
192
|
});
|
|
133
193
|
|
|
134
|
-
|
|
194
|
+
/**
|
|
195
|
+
* 🔴 With the interval gone this is the ONLY thing that pushes a local write, so
|
|
196
|
+
* it is now required wiring rather than an optimization. `apps/vault` drops the
|
|
197
|
+
* `markDirty` handle today and the interval covered for it; on adoption of 2.0.0
|
|
198
|
+
* it must wire this or sit silent. See the task filed alongside this change.
|
|
199
|
+
*/
|
|
200
|
+
test("markDirty is what pushes a local write", async () => {
|
|
135
201
|
let head = 0;
|
|
136
202
|
const h = harness({ head: () => head });
|
|
137
203
|
await h.fire(); // kickoff
|
|
204
|
+
expect(h.scheduled).toHaveLength(0); // quiet
|
|
138
205
|
head = 3;
|
|
139
206
|
h.handle.markDirty();
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
expect(h.runs
|
|
207
|
+
expect(h.scheduled, "markDirty must book the debounced push").toHaveLength(1);
|
|
208
|
+
await h.fire(); // the debounce elapses → run
|
|
209
|
+
expect(h.runs).toHaveLength(2);
|
|
210
|
+
// …and having pushed, it goes quiet again rather than settling into a loop.
|
|
211
|
+
expect(h.scheduled).toHaveLength(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("markDirty does not push an imminent forced run out to the debounce", async () => {
|
|
215
|
+
const h = harness();
|
|
216
|
+
await h.fire(); // kickoff
|
|
217
|
+
h.handle.syncNow();
|
|
218
|
+
const forced = h.scheduled.at(-1);
|
|
219
|
+
h.handle.markDirty();
|
|
220
|
+
expect(
|
|
221
|
+
h.scheduled.at(-1),
|
|
222
|
+
"markDirty replaced the ~1ms forced wake with a 3s debounce",
|
|
223
|
+
).toBe(forced);
|
|
143
224
|
});
|
|
144
225
|
|
|
145
226
|
test("stop() cancels everything", async () => {
|