@nativedesktop/rpc 0.1.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/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@nativedesktop/rpc",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
7
+ "bugs": "https://github.com/FormalSnake/NativeDesktop/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/FormalSnake/NativeDesktop.git",
11
+ "directory": "packages/rpc"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "files": ["src"],
17
+ "main": "./src/index.ts",
18
+ "types": "./src/index.ts",
19
+ "exports": {
20
+ ".": "./src/index.ts",
21
+ "./react": "./src/react.ts"
22
+ },
23
+ "peerDependencies": {
24
+ "@nativedesktop/react": "^0.1.0"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "@nativedesktop/react": {
28
+ "optional": true
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ConnectionLadder, backoffDelayMs } from "./backoff.ts";
3
+
4
+ describe("backoffDelayMs", () => {
5
+ test("doubles from the base and caps at the max", () => {
6
+ expect([1, 2, 3, 4, 5, 6, 7].map((n) => backoffDelayMs(n))).toEqual([
7
+ 500, 1000, 2000, 4000, 8000, 16000, 16000,
8
+ ]);
9
+ });
10
+
11
+ test("clamps attempt < 1 to the base", () => {
12
+ expect(backoffDelayMs(0)).toBe(500);
13
+ expect(backoffDelayMs(-3)).toBe(500);
14
+ });
15
+
16
+ test("honors custom base and max", () => {
17
+ expect(backoffDelayMs(1, 100, 400)).toBe(100);
18
+ expect(backoffDelayMs(3, 100, 400)).toBe(400);
19
+ expect(backoffDelayMs(9, 100, 400)).toBe(400);
20
+ });
21
+ });
22
+
23
+ describe("ConnectionLadder", () => {
24
+ test("nextDelayMs advances one rung per call", () => {
25
+ const ladder = new ConnectionLadder();
26
+ expect(ladder.attempt).toBe(0);
27
+ expect(ladder.nextDelayMs()).toBe(500);
28
+ expect(ladder.nextDelayMs()).toBe(1000);
29
+ expect(ladder.attempt).toBe(2);
30
+ });
31
+
32
+ test("stability window >= 30s resets the ladder on disconnect", () => {
33
+ let t = 0;
34
+ const ladder = new ConnectionLadder({ now: () => t });
35
+ ladder.nextDelayMs();
36
+ ladder.nextDelayMs();
37
+ ladder.nextDelayMs();
38
+ expect(ladder.attempt).toBe(3);
39
+ ladder.noteConnected();
40
+ t += 30_000;
41
+ ladder.noteDisconnected();
42
+ expect(ladder.attempt).toBe(0);
43
+ expect(ladder.nextDelayMs()).toBe(500); // fresh outage starts at the bottom
44
+ });
45
+
46
+ test("a drop before the stability window inherits the rung", () => {
47
+ let t = 0;
48
+ const ladder = new ConnectionLadder({ now: () => t });
49
+ ladder.nextDelayMs();
50
+ ladder.nextDelayMs();
51
+ ladder.nextDelayMs();
52
+ ladder.noteConnected();
53
+ t += 29_999;
54
+ ladder.noteDisconnected();
55
+ expect(ladder.attempt).toBe(3);
56
+ expect(ladder.nextDelayMs()).toBe(4000); // continues the old outage's backoff
57
+ });
58
+
59
+ test("a disconnect with no prior connect keeps the rung", () => {
60
+ const ladder = new ConnectionLadder();
61
+ ladder.nextDelayMs();
62
+ ladder.noteDisconnected();
63
+ expect(ladder.attempt).toBe(1);
64
+ });
65
+
66
+ test("reset() zeroes unconditionally", () => {
67
+ let t = 0;
68
+ const ladder = new ConnectionLadder({ now: () => t });
69
+ ladder.nextDelayMs();
70
+ ladder.noteConnected();
71
+ ladder.reset();
72
+ expect(ladder.attempt).toBe(0);
73
+ t += 60_000;
74
+ ladder.noteDisconnected(); // must not throw or reference the cleared timestamp
75
+ expect(ladder.attempt).toBe(0);
76
+ });
77
+
78
+ test("custom stabilityWindowMs is honored", () => {
79
+ let t = 0;
80
+ const ladder = new ConnectionLadder({ now: () => t, stabilityWindowMs: 100 });
81
+ ladder.nextDelayMs();
82
+ ladder.noteConnected();
83
+ t += 100;
84
+ ladder.noteDisconnected();
85
+ expect(ladder.attempt).toBe(0);
86
+ });
87
+ });
package/src/backoff.ts ADDED
@@ -0,0 +1,72 @@
1
+ // Reconnect backoff ladder. Pure, framework-free, no React, no ND import.
2
+ // `RpcClient` (client.ts) is the sole owner of a `ConnectionLadder`: it drives
3
+ // the real reconnect schedule and exposes the resulting attempt/delay so a
4
+ // subscriber can report it without maintaining a second, competing notion of
5
+ // "how many attempts has this client made".
6
+
7
+ export const RECONNECT_BASE_MS = 500;
8
+ export const RECONNECT_MAX_MS = 16_000;
9
+ export const STABILITY_WINDOW_MS = 30_000;
10
+
11
+ /** Pure: 500, 1000, 2000, 4000, 8000, 16000, 16000, ... Clamps attempt < 1 to the base. */
12
+ export function backoffDelayMs(attempt: number, baseMs = RECONNECT_BASE_MS, maxMs = RECONNECT_MAX_MS): number {
13
+ const n = Math.max(attempt, 1);
14
+ return Math.min(baseMs * 2 ** (n - 1), maxMs);
15
+ }
16
+
17
+ export interface LadderOptions {
18
+ now?: () => number;
19
+ baseMs?: number;
20
+ maxMs?: number;
21
+ stabilityWindowMs?: number;
22
+ }
23
+
24
+ /** The ladder itself, deterministic and injectable-clock so it is unit-testable. Tracks two
25
+ * things: how many rungs deep the current reconnect run is (`attempt`), and whether the last
26
+ * connected span was long enough (stabilityWindowMs) that a fresh drop should start back at
27
+ * the bottom instead of inheriting an old outage's backoff. */
28
+ export class ConnectionLadder {
29
+ #attempt = 0;
30
+ #connectedAtMs: number | undefined;
31
+ readonly #now: () => number;
32
+ readonly #baseMs: number;
33
+ readonly #maxMs: number;
34
+ readonly #stabilityWindowMs: number;
35
+
36
+ constructor(opts: LadderOptions = {}) {
37
+ this.#now = opts.now ?? Date.now;
38
+ this.#baseMs = opts.baseMs ?? RECONNECT_BASE_MS;
39
+ this.#maxMs = opts.maxMs ?? RECONNECT_MAX_MS;
40
+ this.#stabilityWindowMs = opts.stabilityWindowMs ?? STABILITY_WINDOW_MS;
41
+ }
42
+
43
+ get attempt(): number {
44
+ return this.#attempt;
45
+ }
46
+
47
+ /** Advances the ladder one rung and returns the delay for that attempt. */
48
+ nextDelayMs(): number {
49
+ this.#attempt += 1;
50
+ return backoffDelayMs(this.#attempt, this.#baseMs, this.#maxMs);
51
+ }
52
+
53
+ /** Call when a connection reaches `ready`. Records the timestamp. */
54
+ noteConnected(): void {
55
+ this.#connectedAtMs = this.#now();
56
+ }
57
+
58
+ /** Call when a connected socket drops. If it stayed connected for >= stabilityWindowMs the
59
+ * ladder resets to 0. */
60
+ noteDisconnected(): void {
61
+ if (this.#connectedAtMs !== undefined && this.#now() - this.#connectedAtMs >= this.#stabilityWindowMs) {
62
+ this.#attempt = 0;
63
+ }
64
+ this.#connectedAtMs = undefined;
65
+ }
66
+
67
+ /** Manual retry: reset to 0 unconditionally. */
68
+ reset(): void {
69
+ this.#attempt = 0;
70
+ this.#connectedAtMs = undefined;
71
+ }
72
+ }
@@ -0,0 +1,313 @@
1
+ // Client state-machine tests against the fake transport. Each test pins one
2
+ // of the comment-encoded ladder invariants in client.ts; timings use small
3
+ // real timers with wide margins rather than a mocked scheduler.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { RpcClient } from "./client.ts";
7
+ import type { RpcClientOptions, RpcContract, RpcState } from "./client.ts";
8
+ import { fakeTransport } from "./fake-transport.ts";
9
+ import type { FakeTransport } from "./fake-transport.ts";
10
+
11
+ function sleep(ms: number): Promise<void> {
12
+ return new Promise((r) => setTimeout(r, ms));
13
+ }
14
+
15
+ function makeClient(ft: FakeTransport, over: Partial<RpcClientOptions<RpcContract>> = {}): RpcClient<RpcContract> {
16
+ return new RpcClient<RpcContract>({
17
+ transport: ft.factory,
18
+ handshake: { method: "hello", params: { token: "t" } },
19
+ connectTimeoutMs: 40,
20
+ callTimeoutMs: 500,
21
+ backoff: { baseMs: 30, jitter: 0 },
22
+ ...over,
23
+ });
24
+ }
25
+
26
+ /** Dials, opens the latest conn, answers its handshake, awaits readiness. */
27
+ async function connectReady(
28
+ ft: FakeTransport,
29
+ client: RpcClient<RpcContract>,
30
+ opts?: { retryForever?: boolean },
31
+ ): Promise<void> {
32
+ const p = client.connect(opts);
33
+ const conn = ft.latest();
34
+ conn.open();
35
+ conn.reply(conn.callFor("hello")!.id!, { ok: true });
36
+ await p;
37
+ }
38
+
39
+ describe("RpcClient", () => {
40
+ test("connect timeout rejects the caller and still schedules the next rung", async () => {
41
+ const ft = fakeTransport();
42
+ const client = makeClient(ft);
43
+ const p = client.connect({ retryForever: true });
44
+ expect(ft.conns.length).toBe(1);
45
+ await expect(p).rejects.toThrow(/timed out after 40ms/);
46
+ expect(ft.conns[0]!.clientClosed).toBe(true);
47
+ expect(client.state).toBe("reconnecting");
48
+ expect(client.attempt).toBe(1);
49
+ await sleep(60); // backoff base is 30ms
50
+ expect(ft.conns.length).toBe(2);
51
+ client.close();
52
+ });
53
+
54
+ test("a transport that opens synchronously inside the factory still handshakes", async () => {
55
+ // Stdio-shaped: the pipe is already open, so onOpen fires during the
56
+ // factory call, before the client has stored the session/transport.
57
+ const sent: string[] = [];
58
+ let push!: (frame: string) => void;
59
+ const client = new RpcClient<RpcContract>({
60
+ transport: (handlers) => {
61
+ push = (frame) => handlers.onMessage(frame);
62
+ handlers.onOpen();
63
+ return { send: (f) => sent.push(f), close: () => {} };
64
+ },
65
+ handshake: { method: "hello", params: { token: "t" } },
66
+ connectTimeoutMs: 100,
67
+ });
68
+ const p = client.connect();
69
+ const hello = JSON.parse(sent[0]!) as { id: number; method: string };
70
+ expect(hello.method).toBe("hello");
71
+ push(JSON.stringify({ jsonrpc: "2.0", id: hello.id, result: { ok: true } }));
72
+ expect(await p).toEqual({ ok: true });
73
+ expect(client.state).toBe("ready");
74
+ client.close();
75
+ });
76
+
77
+ test("a drop mid-handshake does not emit offline while retryForever", async () => {
78
+ const ft = fakeTransport();
79
+ const client = makeClient(ft);
80
+ const states: RpcState[] = [];
81
+ client.onStateChange((s) => states.push(s));
82
+ const p = client.connect({ retryForever: true });
83
+ ft.latest().open();
84
+ expect(ft.latest().callFor("hello")).toBeDefined();
85
+ ft.latest().drop();
86
+ await expect(p).rejects.toThrow("connection closed before authentication");
87
+ expect(states).not.toContain("offline");
88
+ expect(client.state).toBe("reconnecting");
89
+ client.close();
90
+ });
91
+
92
+ test("a queued call survives one reconnect and lands after the handshake", async () => {
93
+ const ft = fakeTransport();
94
+ const client = makeClient(ft);
95
+ await connectReady(ft, client);
96
+ ft.latest().drop();
97
+ expect(client.state).toBe("reconnecting");
98
+ const callP = client.call("doThing", { x: 1 });
99
+ await sleep(60); // next rung dials at 30ms
100
+ expect(ft.conns.length).toBe(2);
101
+ const conn = ft.latest();
102
+ conn.open();
103
+ // Nothing but the handshake goes out until it answers.
104
+ expect(conn.callFor("doThing")).toBeUndefined();
105
+ conn.reply(conn.callFor("hello")!.id!, {});
106
+ const flushed = conn.callFor("doThing");
107
+ expect(flushed).toBeDefined();
108
+ expect(flushed!.params).toEqual({ x: 1 });
109
+ conn.reply(flushed!.id!, "done");
110
+ expect(await callP).toBe("done");
111
+ client.close();
112
+ });
113
+
114
+ test("an in-flight call rejects on drop", async () => {
115
+ const ft = fakeTransport();
116
+ const client = makeClient(ft);
117
+ await connectReady(ft, client);
118
+ const conn = ft.latest();
119
+ const p = client.call("doThing", {});
120
+ expect(conn.callFor("doThing")).toBeDefined();
121
+ conn.drop();
122
+ await expect(p).rejects.toThrow("connection closed");
123
+ client.close();
124
+ });
125
+
126
+ test("the call deadline fires while queued on a link that stays down", async () => {
127
+ const ft = fakeTransport();
128
+ // Backoff far past the deadline: the link genuinely stays down.
129
+ const client = makeClient(ft, { callTimeoutMs: 60, backoff: { baseMs: 10_000, jitter: 0 } });
130
+ await connectReady(ft, client);
131
+ ft.latest().drop();
132
+ const p = client.call("doThing", {});
133
+ await expect(p).rejects.toThrow(/doThing timed out after 60ms/);
134
+ client.close();
135
+ });
136
+
137
+ test("a fatal handshake error suppresses reconnect and leaves the descriptive state", async () => {
138
+ const ft = fakeTransport();
139
+ const client = makeClient(ft, {
140
+ handshake: { method: "hello", params: {}, fatal: (err) => err.rpcCode === 401 },
141
+ });
142
+ const seen: Array<{ state: RpcState; detail?: string }> = [];
143
+ client.onStateChange((state, detail) => seen.push({ state, detail }));
144
+ const p = client.connect({ retryForever: true });
145
+ const conn = ft.latest();
146
+ conn.open();
147
+ conn.replyError(conn.callFor("hello")!.id!, 401, "bad token");
148
+ await expect(p).rejects.toThrow("bad token");
149
+ expect(client.state).toBe("offline");
150
+ expect(seen.at(-1)).toEqual({ state: "offline", detail: "bad token" });
151
+ await sleep(80); // well past the 30ms rung that must NOT have been armed
152
+ expect(ft.conns.length).toBe(1);
153
+ client.close();
154
+ });
155
+
156
+ test("a non-fatal handshake error keeps riding the ladder", async () => {
157
+ const ft = fakeTransport();
158
+ const client = makeClient(ft, {
159
+ handshake: { method: "hello", params: {}, fatal: (err) => err.rpcCode === 401 },
160
+ });
161
+ const p = client.connect({ retryForever: true });
162
+ const conn = ft.latest();
163
+ conn.open();
164
+ conn.replyError(conn.callFor("hello")!.id!, 500, "busy");
165
+ await expect(p).rejects.toThrow("busy");
166
+ expect(client.state).toBe("reconnecting");
167
+ await sleep(60);
168
+ expect(ft.conns.length).toBe(2);
169
+ client.close();
170
+ });
171
+
172
+ test("the watchdog probes at idleProbe and closes at idleClose", async () => {
173
+ const ft = fakeTransport();
174
+ const client = makeClient(ft, {
175
+ watchdogIntervalMs: 15,
176
+ idleProbeMs: 60,
177
+ idleCloseMs: 150,
178
+ probe: { method: "ping", params: {} },
179
+ });
180
+ await connectReady(ft, client);
181
+ const conn = ft.latest();
182
+ await sleep(100); // past idleProbe with a few ticks of margin
183
+ expect(conn.callFor("ping")).toBeDefined();
184
+ expect(conn.clientClosed).toBe(false);
185
+ await sleep(120); // past idleClose (the probe was never answered)
186
+ expect(conn.clientClosed).toBe(true);
187
+ expect(client.state).toBe("reconnecting");
188
+ client.close();
189
+ });
190
+
191
+ test("a wall-clock jump forces an immediate probe", async () => {
192
+ const ft = fakeTransport();
193
+ let offset = 0;
194
+ const client = makeClient(ft, {
195
+ watchdogIntervalMs: 20,
196
+ idleProbeMs: 10_000,
197
+ idleCloseMs: 60_000,
198
+ probe: { method: "ping", params: {} },
199
+ now: () => Date.now() + offset,
200
+ });
201
+ await connectReady(ft, client);
202
+ const conn = ft.latest();
203
+ await sleep(35); // at least one normal tick so lastTick is fresh
204
+ expect(conn.callFor("ping")).toBeUndefined(); // silence budget far from due
205
+ offset = 1000; // suspend/resume: > watchdogIntervalMs * 3 between ticks
206
+ await sleep(35);
207
+ expect(conn.callFor("ping")).toBeDefined();
208
+ client.close();
209
+ });
210
+
211
+ test("connect() during a pending backoff leaks neither timer nor socket", async () => {
212
+ const ft = fakeTransport();
213
+ const client = makeClient(ft, { connectTimeoutMs: 30, backoff: { baseMs: 100, jitter: 0 } });
214
+ const p1 = client.connect({ retryForever: true });
215
+ await expect(p1).rejects.toThrow(/timed out/);
216
+ expect(ft.conns.length).toBe(1); // rung armed for +100ms, not dialed yet
217
+ const p2 = client.connect({ retryForever: true });
218
+ expect(ft.conns.length).toBe(2); // fresh dial, immediately
219
+ const conn = ft.latest();
220
+ conn.open();
221
+ conn.reply(conn.callFor("hello")!.id!, { ok: true });
222
+ await p2;
223
+ expect(client.state).toBe("ready");
224
+ expect(ft.conns[0]!.clientClosed).toBe(true);
225
+ await sleep(150); // past the superseded backoff timer
226
+ expect(ft.conns.length).toBe(2); // it was cleared, no ghost dial
227
+ client.close();
228
+ });
229
+
230
+ test("nextRetryInMs read from inside the state callback equals the armed delay", async () => {
231
+ const ft = fakeTransport();
232
+ const client = makeClient(ft, { connectTimeoutMs: 40, backoff: { baseMs: 50, jitter: 0 } });
233
+ const rungs: Array<{ attempt: number; next: number | undefined }> = [];
234
+ client.onStateChange((s) => {
235
+ if (s === "reconnecting" && client.nextRetryInMs !== undefined) {
236
+ rungs.push({ attempt: client.attempt, next: client.nextRetryInMs });
237
+ }
238
+ });
239
+ await connectReady(ft, client);
240
+ ft.latest().drop();
241
+ // Rung 1 armed synchronously inside the drop's close cascade.
242
+ expect(rungs).toEqual([{ attempt: 1, next: 50 }]);
243
+ await sleep(70); // rung 1 dials at 50ms; that dial times out at +40ms
244
+ await sleep(60);
245
+ expect(rungs).toEqual([
246
+ { attempt: 1, next: 50 },
247
+ { attempt: 2, next: 100 },
248
+ ]);
249
+ client.close();
250
+ });
251
+
252
+ test("events dispatch to on() subscribers and refresh the rx clock", async () => {
253
+ const ft = fakeTransport();
254
+ const client = makeClient(ft);
255
+ await connectReady(ft, client);
256
+ const got: unknown[] = [];
257
+ const off = client.on("event.tick", (p) => got.push(p));
258
+ ft.latest().notify("event.tick", { n: 1 });
259
+ expect(got).toEqual([{ n: 1 }]);
260
+ off();
261
+ ft.latest().notify("event.tick", { n: 2 });
262
+ expect(got).toEqual([{ n: 1 }]);
263
+ client.close();
264
+ });
265
+
266
+ test("call() fails fast only when there is no ladder to wait for", async () => {
267
+ const ft = fakeTransport();
268
+ const client = makeClient(ft);
269
+ await expect(client.call("doThing", {})).rejects.toThrow("not connected");
270
+ await connectReady(ft, client);
271
+ client.close();
272
+ await expect(client.call("doThing", {})).rejects.toThrow("not connected");
273
+ });
274
+
275
+ test("close() rejects pending calls and stops the machine", async () => {
276
+ const ft = fakeTransport();
277
+ const client = makeClient(ft);
278
+ await connectReady(ft, client);
279
+ const p = client.call("doThing", {});
280
+ client.close();
281
+ await expect(p).rejects.toThrow("client closed");
282
+ expect(client.state).toBe("closed");
283
+ await sleep(60);
284
+ expect(ft.conns.length).toBe(1); // no ladder after an explicit close
285
+ });
286
+
287
+ test("handshakeResult exposes the latest handshake and resume runs on the first dial", async () => {
288
+ const ft = fakeTransport();
289
+ let resumes = 0;
290
+ let reconnects = 0;
291
+ const client = makeClient(ft, {
292
+ resume: async () => {
293
+ resumes += 1;
294
+ },
295
+ });
296
+ client.onReconnected(() => (reconnects += 1));
297
+ await connectReady(ft, client);
298
+ await sleep(1); // resume is async void off the ready transition
299
+ expect(client.handshakeResult).toEqual({ ok: true });
300
+ expect(resumes).toBe(1);
301
+ expect(reconnects).toBe(0); // first dial is not a reconnect
302
+ ft.latest().drop();
303
+ await sleep(60);
304
+ const conn = ft.latest();
305
+ conn.open();
306
+ conn.reply(conn.callFor("hello")!.id!, { ok: 2 });
307
+ await sleep(5);
308
+ expect(client.handshakeResult).toEqual({ ok: 2 });
309
+ expect(resumes).toBe(2);
310
+ expect(reconnects).toBe(1);
311
+ client.close();
312
+ });
313
+ });