Skip to content
17 changes: 16 additions & 1 deletion src/http/websocket_client/WebSocketProxyTunnel.zig
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ const SocketUnion = union(enum) {
.none => true,
};
}

pub fn close(self: SocketUnion) void {
switch (self) {
inline .tcp, .ssl => |s| s.close(.normal),
.none => {},
}
}
};

const SSLWrapperType = SSLWrapper(*WebSocketProxyTunnel);
Expand Down Expand Up @@ -334,11 +341,19 @@ pub fn write(this: *WebSocketProxyTunnel, data: []const u8) !usize {
return error.ConnectionClosed;
}

/// Gracefully shutdown the TLS connection
/// Gracefully shutdown the TLS connection and close the underlying proxy
/// socket so the upgrade client's handleClose fires and releases its socket
/// ref + proxy state. Take the socket out first so a re-entrant shutdown()
/// (via handleClose → clearData → proxy.deinit → tunnel.shutdown) finds .none
/// and returns early without re-running wrapper.shutdown.
pub fn shutdown(this: *WebSocketProxyTunnel) void {
const sock = this.#socket;
if (sock == .none) return;
this.#socket = .{ .none = {} };
if (this.#wrapper) |*wrapper| {
_ = wrapper.shutdown(true); // Fast shutdown
}
sock.close();
}

/// Check if the tunnel has backpressure
Expand Down
8 changes: 6 additions & 2 deletions src/http/websocket_client/WebSocketUpgradeClient.zig
Original file line number Diff line number Diff line change
Expand Up @@ -352,21 +352,25 @@
this.poll_ref.unref(jsc.VirtualMachine.get());

this.subprotocols.clearAndFree();
this.clearInput();
this.body.clearAndFree(bun.default_allocator);

// Clean up proxy state. Null the field BEFORE calling deinit so a
// re-entrant clearData() (via tunnel.shutdown() closing the socket
// synchronously, which fires handleClose → clearData) does not try
// to free the same WebSocketProxy a second time.
if (this.hostname.len > 0) {
bun.default_allocator.free(this.hostname);
this.hostname = "";
}

// Clean up proxy state
if (this.proxy) |*p| {
p.deinit();
var local = p.*;
this.proxy = null;
local.deinit();
}
if (this.ssl_config) |config| {
config.deinit();

Check notice on line 373 in src/http/websocket_client/WebSocketUpgradeClient.zig

View check run for this annotation

Claude / Claude Code Review

HTTPClient refcount=1 leak after successful wss-via-proxy connection

In processResponse() tunnel-mode success branch, bun.take(&this.outgoing_websocket) clears the C++ reference but the comment explicitly says 'DON'T deref the upgrade client' — omitting this.deref(). Since connect() calls out.ref() (+1 for cpp_websocket, giving refcount=2) and handleClose() issues only one deref() (2→1), the HTTPClient (~3KB, dominated by headers_buf: [128]Header) is permanently orphaned at refcount=1 after every successful wss-via-proxy connection. This is a pre-existing leak in
bun.default_allocator.destroy(config);
this.ssl_config = null;
}
Expand Down
131 changes: 131 additions & 0 deletions test/js/web/websocket/wss-proxy-tunnel-leak-fixture.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions test/js/web/websocket/wss-proxy-tunnel-leak.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Regression test for the wss://-through-HTTP-proxy tunnel leak in
* WebSocketProxyTunnel.zig: 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 connection). After the fix the socket
* closes on shutdown and the tunnel reaches refcount=0.
*/
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isDebug } from "harness";
import { join } from "node:path";

test(
"wss-via-http-proxy upgrade does not leak the WebSocketProxyTunnel",
async () => {
const ITER = isDebug ? 200 : 500;
await using proc = Bun.spawn({
cmd: [bunExe(), "--smol", join(import.meta.dirname, "wss-proxy-tunnel-leak-fixture.ts")],
env: { ...bunEnv, LEAK_ITER: String(ITER), LEAK_WARMUP: "60" },
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

// Check exitCode and stderr BEFORE parsing stdout — a fixture crash
// (SIGSEGV/SIGABRT under ASAN, empty stdout) should fail with a clear
// "expected 0 but received 139" instead of a confusing
// "JSON Parse error: Unexpected end of input" masking the real cause.
// ASAN/debug builds emit benign stderr noise, so don't assert empty —
// just verify the fixture didn't surface a leak/error.
expect(stderr).not.toContain("leak");
expect(stderr).not.toContain("Error:");
expect(exitCode).toBe(0);

const { baseline, after, growth, iter } = JSON.parse(stdout.trim());
expect(iter).toBe(ITER);

// Without the fix, the upgrade client + tunnel + SSLWrapper never free
// and growth is ~2.5MB/iter. With the fix the tunnel reaches refcount=0;
// a residual ~1MB/iter remains (per-connection SSL_CTX cost — separate
// from this leak). Threshold sits between the two so the test fails
// before the fix and passes after.
const threshold = iter * 1536 * 1024;
if (growth >= threshold) {
throw new Error(
`RSS grew ${growth} bytes (${(growth / iter).toFixed(0)} B/iter) over ${iter} wss-via-proxy upgrades ` +
`(baseline=${baseline}, after=${after}, threshold=${threshold})`,
);
}
},
isDebug ? 120_000 : 60_000,
);
Loading