Skip to content

Commit 5c2eef3

Browse files
committed
test(js client): pin stop() interrupts reconnect backoff sleep
Regression guard for the setTimeout leak: for each class point at a refused-connect URL (ws://127.0.0.1:1), start the reconnect loop, wait 50ms for the first serveOneSession to fail and the backoff sleep to begin, then call stop() and assert elapsed < 500ms. Baseline before the fix blocks ≈1000ms on the first backoff. Mutation-verified — neutering the sleep canceller fails all three.
1 parent ddf4047 commit 5c2eef3

2 files changed

Lines changed: 95 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/manifest_mcp.test.js dist-test/test/protocol.test.js dist-test/test/client.test.js dist-test/test/client_url.test.js dist-test/test/client_lifecycle.test.js dist-test/test/client_stream.test.js dist-test/test/client_handle_chat.test.js dist-test/test/expose.test.js dist-test/test/client_nonerror_throw.test.js"
29+
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/manifest.test.js dist-test/test/manifest_mcp.test.js dist-test/test/protocol.test.js dist-test/test/client.test.js dist-test/test/client_url.test.js dist-test/test/client_lifecycle.test.js dist-test/test/client_stream.test.js dist-test/test/client_handle_chat.test.js dist-test/test/expose.test.js dist-test/test/client_nonerror_throw.test.js dist-test/test/client_reconnect_stop.test.js"
3030
},
3131
"dependencies": {
3232
"ws": "^8.18.0"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* stop() must interrupt the reconnect-loop backoff sleep — parity with
3+
* Python's `asyncio.wait_for(stop_event.wait(), timeout=backoff)`.
4+
*
5+
* The pre-fix loop slept via `setTimeout(resolve, backoff * 1000)` which
6+
* is not cancellable — stop() flipped the `stopped` flag and resolved
7+
* runForever(), but the setTimeout kept a Node event-loop reference for
8+
* up to 60s, blocking a clean process exit. Also, `stop()` did not await
9+
* the internal reconnect task, so callers had no way to observe the loop
10+
* had actually wound down.
11+
*
12+
* Repro strategy: point each class at ws://127.0.0.1:1 (refused instantly);
13+
* start() → serveOneSession fails → backoff sleep begins → stop() during
14+
* the sleep must return within a small window. Baseline before the fix
15+
* blocked ≈1000 ms (the first backoff interval); this test allows 500 ms.
16+
*/
17+
import { describe, it } from 'node:test';
18+
import assert from 'node:assert/strict';
19+
import { ZhubPublication, ZhubConnection, ZhubExposure } from '../src/client.js';
20+
import type { Manifest } from '../src/manifest.js';
21+
22+
function fakeManifest(name: string): Manifest {
23+
return {
24+
schema_version: '0.1',
25+
name,
26+
description: '',
27+
operator: '',
28+
capabilities: [],
29+
auth: { type: 'bearer' },
30+
rate_limit: '60/min',
31+
public: false,
32+
contact: '',
33+
extensions: {},
34+
};
35+
}
36+
37+
const DEAD_URL = 'ws://127.0.0.1:1';
38+
39+
async function letBackoffBegin(): Promise<void> {
40+
// Give the reconnect loop a beat to fail its first serveOneSession and
41+
// enter the sleep(). The refuse happens on the next tick; 50ms is plenty.
42+
await new Promise((r) => setTimeout(r, 50));
43+
}
44+
45+
describe('ZhubPublication.stop() interrupts reconnect backoff sleep', () => {
46+
it('resolves promptly during backoff, not after full sleep', async () => {
47+
const pub = new ZhubPublication(
48+
{ name: 'p', description: '', hubUrl: DEAD_URL, chatHandler: () => '' },
49+
fakeManifest('p'),
50+
);
51+
pub.start();
52+
await letBackoffBegin();
53+
const start = Date.now();
54+
await pub.stop();
55+
const elapsed = Date.now() - start;
56+
assert(
57+
elapsed < 500,
58+
`stop() blocked for ${elapsed} ms — expected < 500 ms (interruptible sleep)`,
59+
);
60+
});
61+
});
62+
63+
describe('ZhubConnection.stop() interrupts reconnect backoff sleep', () => {
64+
it('resolves promptly during backoff, not after full sleep', async () => {
65+
const conn = new ZhubConnection({ aiName: 'a', apiKey: 'zk_test', hubUrl: DEAD_URL });
66+
conn.start();
67+
await letBackoffBegin();
68+
const start = Date.now();
69+
await conn.stop();
70+
const elapsed = Date.now() - start;
71+
assert(
72+
elapsed < 500,
73+
`stop() blocked for ${elapsed} ms — expected < 500 ms (interruptible sleep)`,
74+
);
75+
});
76+
});
77+
78+
describe('ZhubExposure.stop() interrupts reconnect backoff sleep', () => {
79+
it('resolves promptly during backoff, not after full sleep', async () => {
80+
const exp = new ZhubExposure(
81+
{ name: 'e', description: '', hubUrl: DEAD_URL, capabilities: {} },
82+
fakeManifest('e'),
83+
);
84+
exp.start();
85+
await letBackoffBegin();
86+
const start = Date.now();
87+
await exp.stop();
88+
const elapsed = Date.now() - start;
89+
assert(
90+
elapsed < 500,
91+
`stop() blocked for ${elapsed} ms — expected < 500 ms (interruptible sleep)`,
92+
);
93+
});
94+
});

0 commit comments

Comments
 (0)