File size: 6,663 Bytes
6ca24e1
 
 
2df2e7e
6ca24e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2df2e7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ca24e1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer } from "ws";
import { PI_RPC_REPLAY_GAP_MESSAGE, PiRpcClient, PiRpcReplayGapError } from "./PiRpcClient";

const servers: Server[] = [];

afterEach(async () => {
	await Promise.all(
		servers.splice(0).map(
			(server) =>
				new Promise<void>((resolve) => {
					server.close(() => resolve());
				})
		)
	);
});

describe("PiRpcClient", () => {
	it("retries a transient HTTP 400 upgrade within one connect deadline", async () => {
		const server = createServer();
		servers.push(server);
		const webSockets = new WebSocketServer({ noServer: true });
		let upgrades = 0;
		server.on("upgrade", (request, socket, head) => {
			upgrades += 1;
			if (upgrades === 1) {
				socket.end(
					"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 11\r\n\r\nBad Request"
				);
				return;
			}
			webSockets.handleUpgrade(request, socket, head, (webSocket) => {
				webSockets.emit("connection", webSocket, request);
			});
		});
		webSockets.on("connection", (socket) => {
			socket.send(JSON.stringify({ type: "bridge_ready", fromSeq: 1, toSeq: 0 }));
		});
		await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
		const address = server.address();
		if (!address || typeof address === "string") throw new Error("Test server has no port");

		const statuses: string[] = [];
		const client = new PiRpcClient(`ws://127.0.0.1:${address.port}/ws`, {
			headers: {},
			connectTimeoutMs: 2_000,
			minReconnectDelayMs: 10,
			maxReconnectDelayMs: 10,
			onStatus: (status) => statuses.push(status),
		});
		try {
			await Promise.all([client.connect(), client.connect()]);
			expect(client.connected).toBe(true);
			expect(upgrades).toBe(2);
			expect(statuses.slice(0, 3)).toEqual(["connecting", "reconnecting", "connected"]);
		} finally {
			client.close();
			webSockets.close();
		}
	});

	it("keeps reconnecting when an upgraded socket never becomes bridge-ready", async () => {
		const server = createServer();
		servers.push(server);
		const webSockets = new WebSocketServer({ noServer: true });
		let upgrades = 0;
		let firstSocket: import("ws").WebSocket | undefined;
		server.on("upgrade", (request, socket, head) => {
			upgrades += 1;
			webSockets.handleUpgrade(request, socket, head, (webSocket) => {
				webSockets.emit("connection", webSocket, request);
			});
		});
		webSockets.on("connection", (socket) => {
			if (upgrades === 1) firstSocket = socket;
			if (upgrades !== 2) {
				socket.send(JSON.stringify({ type: "bridge_ready", fromSeq: 1, toSeq: 0 }));
			}
		});
		await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
		const address = server.address();
		if (!address || typeof address === "string") throw new Error("Test server has no port");

		let connectedCount = 0;
		let resolveReconnected: (() => void) | undefined;
		const reconnected = new Promise<void>((resolve) => {
			resolveReconnected = resolve;
		});
		const client = new PiRpcClient(`ws://127.0.0.1:${address.port}/ws`, {
			headers: {},
			connectTimeoutMs: 100,
			minReconnectDelayMs: 5,
			maxReconnectDelayMs: 5,
			onStatus: (status) => {
				if (status === "connected" && ++connectedCount === 2) resolveReconnected?.();
			},
		});
		try {
			await client.connect();
			firstSocket?.terminate();
			await reconnected;
			expect(client.connected).toBe(true);
			expect(upgrades).toBe(3);
		} finally {
			client.close();
			webSockets.close();
		}
	});

	it("fails an attach gap before dispatching a retry prompt", async () => {
		const server = createServer();
		servers.push(server);
		const webSockets = new WebSocketServer({ server });
		const receivedTypes: string[] = [];
		webSockets.on("connection", (socket) => {
			socket.on("message", (raw) => {
				const message = JSON.parse(raw.toString()) as { type?: string };
				if (message.type) receivedTypes.push(message.type);
			});
			socket.send(JSON.stringify({ type: "bridge_ready", fromSeq: 5, toSeq: 7 }));
		});
		await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
		const address = server.address();
		if (!address || typeof address === "string") throw new Error("Test server has no port");

		const client = new PiRpcClient(`ws://127.0.0.1:${address.port}/ws`, {
			headers: {},
			lastSequence: 1,
			connectTimeoutMs: 1_000,
		});
		try {
			const failure = await client
				.command({ type: "prompt", message: "do not dispatch twice" })
				.catch((error: unknown) => error);
			expect(failure).toBeInstanceOf(PiRpcReplayGapError);
			expect(failure).toMatchObject({
				message: PI_RPC_REPLAY_GAP_MESSAGE,
				gap: { expected: 2, received: 5, earliestAvailable: 5, latestAvailable: 7 },
			});
			expect(client.lastSequence).toBe(1);
			await new Promise((resolve) => setImmediate(resolve));
			expect(receivedTypes).not.toContain("prompt");
		} finally {
			client.close();
			webSockets.close();
		}
	});

	it("does not advance its cursor across a hole inside the initial replay", async () => {
		const server = createServer();
		servers.push(server);
		const webSockets = new WebSocketServer({ server });
		const receivedTypes: string[] = [];
		webSockets.on("connection", (socket) => {
			socket.on("message", (raw) => {
				const message = JSON.parse(raw.toString()) as { type?: string };
				if (message.type) receivedTypes.push(message.type);
			});
			socket.send(JSON.stringify({ type: "bridge_ready", fromSeq: 1, toSeq: 3 }));
			socket.send(
				JSON.stringify({ type: "bridge_event", seq: 1, payload: { type: "agent_start" } })
			);
			socket.send(
				JSON.stringify({ type: "bridge_event", seq: 3, payload: { type: "agent_settled" } })
			);
		});
		await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
		const address = server.address();
		if (!address || typeof address === "string") throw new Error("Test server has no port");

		const client = new PiRpcClient(`ws://127.0.0.1:${address.port}/ws`, {
			headers: {},
			connectTimeoutMs: 1_000,
		});
		const sequences: number[] = [];
		client.subscribe((_event, sequence) => sequences.push(sequence));
		try {
			await expect(
				client.command({ type: "prompt", message: "wait for replay first" })
			).rejects.toMatchObject({
				name: "PiRpcReplayGapError",
				gap: { expected: 2, received: 3, latestAvailable: 3 },
			});
			expect(sequences).toEqual([1]);
			expect(client.lastSequence).toBe(1);
			await new Promise((resolve) => setImmediate(resolve));
			expect(receivedTypes).not.toContain("prompt");
		} finally {
			client.close();
			webSockets.close();
		}
	});
});