@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 +31 -0
- package/src/backoff.test.ts +87 -0
- package/src/backoff.ts +72 -0
- package/src/client.test.ts +313 -0
- package/src/client.ts +700 -0
- package/src/fake-transport.ts +101 -0
- package/src/index.ts +6 -0
- package/src/react.ts +17 -0
- package/src/status-store.test.ts +46 -0
- package/src/status-store.ts +56 -0
- package/src/transport.test.ts +48 -0
- package/src/transport.ts +142 -0
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
|
+
});
|