Skip to content

Commit f0aee49

Browse files
committed
fix(websocket): close proxy socket on tunnel shutdown so wss-via-proxy upgrade resources free
WebSocketProxyTunnel.shutdown() only sent TLS close_notify and never closed the underlying socket, so the upgrade client's handleClose never fired, proxy.deinit never ran, and the tunnel + per-connection SSL_CTX + upgrade client all leaked (~2.5MB per wss-via-http-proxy connection). shutdown() now takes the socket out (so a re-entrant call via handleClose -> clearData -> proxy.deinit -> tunnel.shutdown finds .none and is a no-op) and closes it. The synchronous re-entry is refcount-safe without an extra guard: tunnel is at 2 (proxy + WebSocketClient) before cleanup; proxy.deinit derefs to 1; the WebSocketClient cleanup's own deref takes it to 0. Test runs 200 connect/close cycles with servers in a separate process and asserts client RSS growth < 1.5MB/iter (pre-fix ~2.5MB/iter, post-fix ~1.0MB/iter).
1 parent 0ff0065 commit f0aee49

File tree

3 files changed

+183
-0
lines changed

3 files changed

+183
-0
lines changed

src/http/websocket_client/WebSocketProxyTunnel.zig

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ const SocketUnion = union(enum) {
8888
.none => true,
8989
};
9090
}
91+
92+
pub fn close(self: SocketUnion) void {
93+
switch (self) {
94+
inline .tcp, .ssl => |s| s.close(.normal),
95+
.none => {},
96+
}
97+
}
9198
};
9299

93100
const SSLWrapperType = SSLWrapper(*WebSocketProxyTunnel);
@@ -339,6 +346,13 @@ pub fn shutdown(this: *WebSocketProxyTunnel) void {
339346
if (this.#wrapper) |*wrapper| {
340347
_ = wrapper.shutdown(true); // Fast shutdown
341348
}
349+
// Close the underlying proxy socket so the upgrade client's handleClose
350+
// fires and releases its socket ref + proxy state. Take the socket out
351+
// first so a re-entrant shutdown() (via handleClose → clearData →
352+
// proxy.deinit → tunnel.shutdown) finds .none and is a no-op.
353+
const sock = this.#socket;
354+
this.#socket = .{ .none = {} };
355+
sock.close();
342356
}
343357

344358
/// Check if the tunnel has backpressure

test/js/web/websocket/wss-proxy-tunnel-leak-fixture.ts

Lines changed: 123 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Regression test for the tunnel-mode HTTPClient leak in
3+
* WebSocketUpgradeClient.zig. The wss://-through-HTTP-proxy success path took
4+
* `outgoing_websocket` without dropping the cpp_websocket ref, so each
5+
* upgrade left an HTTPClient (~4KB struct including 128 PicoHTTP.Header
6+
* headers_buf) at refcount=1 forever. 500 upgrades ≈ 2MB+ of unreclaimable
7+
* RSS — well above the noise floor under --smol.
8+
*/
9+
import { expect, test } from "bun:test";
10+
import { bunEnv, bunExe, isDebug } from "harness";
11+
import { join } from "node:path";
12+
13+
test(
14+
"wss-via-http-proxy upgrade does not leak the HTTPClient",
15+
async () => {
16+
const ITER = isDebug ? 200 : 500;
17+
await using proc = Bun.spawn({
18+
cmd: [bunExe(), "--smol", join(import.meta.dirname, "wss-proxy-tunnel-leak-fixture.ts")],
19+
env: { ...bunEnv, LEAK_ITER: String(ITER), LEAK_WARMUP: "60" },
20+
stdout: "pipe",
21+
stderr: "pipe",
22+
});
23+
24+
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
25+
26+
expect(stderr).toBe("");
27+
expect(exitCode).toBe(0);
28+
29+
const { baseline, after, growth, iter } = JSON.parse(stdout.trim());
30+
expect(iter).toBe(ITER);
31+
32+
// Without the fix, the upgrade client + tunnel + SSLWrapper never free
33+
// and growth is ~2.5MB/iter. With the fix the tunnel reaches refcount=0;
34+
// a residual ~1MB/iter remains (per-connection SSL_CTX cost — separate
35+
// from this leak). Threshold sits between the two so the test fails
36+
// before the fix and passes after.
37+
const threshold = iter * 1536 * 1024;
38+
if (growth >= threshold) {
39+
throw new Error(
40+
`RSS grew ${growth} bytes (${(growth / iter).toFixed(0)} B/iter) over ${iter} wss-via-proxy upgrades ` +
41+
`(baseline=${baseline}, after=${after}, threshold=${threshold})`,
42+
);
43+
}
44+
},
45+
isDebug ? 120_000 : 60_000,
46+
);

0 commit comments

Comments
 (0)