Skip to content

Commit 223517f

Browse files
committed
test(js/client): cover disconnect-drain for Publication and Connection
Three node:test cases against a real local ws.WebSocketServer (no mocks): ZhubPublication.invoke() rejects fast on remote terminate; ZhubConnection.chat() rejects fast on remote terminate; and a happy- path chat() echo so the rejection tests aren't the only coverage of the chat path. Both rejection cases assert ZhubConnectionError and elapsed < 2s with a 60s timeoutMs configured, so a regression here manifests as a multi-second hang. Add the new client.test.js bundle to the npm test target. Mutation-verified: reverting js/src/client.ts leaves the elapsed assertion failing (call hangs the full 60s before the per-call timeout fires).
1 parent 72a559a commit 223517f

2 files changed

Lines changed: 166 additions & 1 deletion

File tree

js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
],
2727
"scripts": {
2828
"build": "tsc -p tsconfig.json",
29-
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/manifest.test.js dist-test/test/protocol.test.js"
29+
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/manifest.test.js dist-test/test/protocol.test.js dist-test/test/client.test.js"
3030
},
3131
"dependencies": {
3232
"ws": "^8.18.0"

js/test/client.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* ZhubPublication / ZhubConnection disconnect-drain tests.
3+
*
4+
* Regression for: pending invoke()/chat() futures waited the full 60-second
5+
* timeout when the WS dropped instead of rejecting immediately on close.
6+
*
7+
* Uses a real local ws.WebSocketServer so the transport is faithful.
8+
*/
9+
import { describe, it, before, after } from 'node:test';
10+
import assert from 'node:assert/strict';
11+
import { WebSocketServer } from 'ws';
12+
import type { AddressInfo } from 'node:net';
13+
import { ZhubPublication, ZhubConnection, publish, connect } from '../src/client.js';
14+
import { ZhubConnectionError } from '../src/errors.js';
15+
import { chatResponse } from '../src/protocol.js';
16+
17+
function makeHubUrl(port: number): string {
18+
return `http://127.0.0.1:${port}`;
19+
}
20+
21+
async function startServer(): Promise<{ wss: WebSocketServer; port: number; close: () => Promise<void> }> {
22+
const wss = new WebSocketServer({ port: 0 });
23+
await new Promise<void>((r) => wss.on('listening', r));
24+
const port = (wss.address() as AddressInfo).port;
25+
return {
26+
wss,
27+
port,
28+
close: () => new Promise<void>((r) => wss.close(() => r())),
29+
};
30+
}
31+
32+
describe('ZhubPublication disconnect drain', () => {
33+
let wss: WebSocketServer;
34+
let port: number;
35+
let closeServer: () => Promise<void>;
36+
37+
before(async () => {
38+
const s = await startServer();
39+
wss = s.wss;
40+
port = s.port;
41+
closeServer = s.close;
42+
43+
// Accept connections and send 'registered', then stay open so callers can invoke
44+
wss.on('connection', (ws) => {
45+
ws.send(JSON.stringify({ type: 'registered', request_id: 'r0', payload: { name: 'test-pub', base_url: '', api_key: 'zk_test' } }));
46+
});
47+
});
48+
49+
after(async () => {
50+
await closeServer();
51+
});
52+
53+
it('invoke() rejects with ZhubConnectionError immediately when WS closes', async () => {
54+
const pub = publish({
55+
name: 'test-pub',
56+
description: 'test',
57+
hubUrl: makeHubUrl(port),
58+
apiKey: 'zk_test',
59+
chatHandler: async () => 'hi',
60+
});
61+
62+
// Wait until the WS is open (registered message received)
63+
await new Promise<void>((r) => setTimeout(r, 100));
64+
65+
// Kick off an invoke() — no server-side handler, so it would wait for the timeout
66+
const invokePromise = pub.invoke('cx_1', 'ping', {}, 60_000);
67+
68+
// Kill all server-side connections to trigger onclose on the client
69+
for (const client of wss.clients) client.terminate();
70+
71+
const start = Date.now();
72+
await assert.rejects(invokePromise, ZhubConnectionError);
73+
const elapsed = Date.now() - start;
74+
assert(elapsed < 2_000, `expected fast rejection, got ${elapsed}ms`);
75+
76+
await pub.stop();
77+
});
78+
});
79+
80+
describe('ZhubConnection disconnect drain', () => {
81+
let wss: WebSocketServer;
82+
let port: number;
83+
let closeServer: () => Promise<void>;
84+
85+
before(async () => {
86+
const s = await startServer();
87+
wss = s.wss;
88+
port = s.port;
89+
closeServer = s.close;
90+
91+
wss.on('connection', (ws) => {
92+
ws.send(JSON.stringify({ type: 'registered', request_id: 'r0', payload: {} }));
93+
});
94+
});
95+
96+
after(async () => {
97+
await closeServer();
98+
});
99+
100+
it('chat() rejects with ZhubConnectionError immediately when WS closes', async () => {
101+
const conn = connect({
102+
aiName: 'test-ai',
103+
apiKey: 'zk_test',
104+
hubUrl: makeHubUrl(port),
105+
});
106+
107+
// Wait until registered
108+
await new Promise<void>((r) => setTimeout(r, 100));
109+
110+
const chatPromise = conn.chat([{ role: 'user', content: 'hello' }], { timeoutMs: 60_000 });
111+
112+
// Close all server-side sockets
113+
for (const client of wss.clients) client.terminate();
114+
115+
const start = Date.now();
116+
await assert.rejects(chatPromise, ZhubConnectionError);
117+
const elapsed = Date.now() - start;
118+
assert(elapsed < 2_000, `expected fast rejection, got ${elapsed}ms`);
119+
120+
await conn.stop();
121+
});
122+
});
123+
124+
describe('ZhubConnection chat() happy path', () => {
125+
let wss: WebSocketServer;
126+
let port: number;
127+
let closeServer: () => Promise<void>;
128+
129+
before(async () => {
130+
const s = await startServer();
131+
wss = s.wss;
132+
port = s.port;
133+
closeServer = s.close;
134+
135+
// Echo server: on chat-request, immediately send chat-response
136+
wss.on('connection', (ws) => {
137+
ws.send(JSON.stringify({ type: 'registered', request_id: 'r0', payload: {} }));
138+
ws.on('message', (raw) => {
139+
const env = JSON.parse(raw.toString());
140+
if (env.type === 'chat-request') {
141+
ws.send(JSON.stringify(chatResponse('hello back', env.request_id, 'stop')));
142+
}
143+
});
144+
});
145+
});
146+
147+
after(async () => {
148+
await closeServer();
149+
});
150+
151+
it('resolves with text from hub response', async () => {
152+
const conn = connect({
153+
aiName: 'test-ai',
154+
apiKey: 'zk_test',
155+
hubUrl: makeHubUrl(port),
156+
});
157+
158+
await new Promise<void>((r) => setTimeout(r, 100));
159+
160+
const result = await conn.chat([{ role: 'user', content: 'hello' }], { timeoutMs: 5_000 });
161+
assert.equal(result.text, 'hello back');
162+
163+
await conn.stop();
164+
});
165+
});

0 commit comments

Comments
 (0)