Files
bun.sh/test/js/node/http/node-http-with-ws.test.ts
robobun 9ce2504554 fix(node:http): unref poll_ref on WebSocket upgrade to prevent CPU spin (#24271)
## Summary

Fixes 100% CPU usage on idle WebSocket servers between bun-v1.2.23 and
bun-v1.3.0.

Many users reported WebSocket server CPU usage jumping to 100% on idle
connections after upgrading to v1.3.0. Investigation revealed a missing
`poll_ref.unref()` call in the WebSocket upgrade path.

## Root Cause

In commit 625e537f5d (#23348), the `OnBeforeOpen` callback mechanism was
removed as part of refactoring the WebSocket upgrade process. However,
this callback contained a critical cleanup step:

```zig
defer ctx.this.poll_ref.unref(ctx.globalObject.bunVM());
```

When a `NodeHTTPResponse` is created, `poll_ref.ref()` is called (line
314) to keep the event loop alive while handling the HTTP request. After
a WebSocket upgrade, the HTTP response object is no longer relevant and
its `poll_ref` must be unref'd to indicate the request processing is
complete.

Without this unref, the event loop maintains an active reference even
after the upgrade completes, causing the CPU to spin at 100% waiting for
events on what should be an idle connection.

## Changes

- Added `poll_ref.unref()` call in `NodeHTTPResponse.upgrade()` after
setting the `upgraded` flag
- Added regression test to verify event loop properly exits after
WebSocket upgrade

## Test Plan

- [x] Code compiles successfully
- [x] Existing WebSocket tests pass
- [x] Manual testing confirms CPU usage returns to normal on idle
WebSocket connections

## Related Issues

Fixes issue reported by users between bun-v1.2.23 and bun-v1.3.0
regarding 100% CPU usage on idle WebSocket servers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-11-03 23:27:26 -08:00

106 lines
3.8 KiB
TypeScript

import { expect, test } from "bun:test";
import { bunEnv, bunExe, tls as options } from "harness";
import https from "https";
import type { AddressInfo } from "node:net";
import tls from "tls";
import { WebSocketServer } from "ws";
test.concurrent("WebSocket upgrade should unref poll_ref from response", async () => {
// Regression test for bug where poll_ref was not unref'd on WebSocket upgrade
// The bug: NodeHTTPResponse.poll_ref stayed active after upgrade
// This test verifies activeTasks is correctly decremented after upgrade
const script = /* js */ `
const http = require("http");
const { WebSocketServer } = require("ws");
const { getEventLoopStats } = require("bun:internal-for-testing");
const server = http.createServer();
const wsServer = new WebSocketServer({ server });
let initialStats;
process.exitCode = 1;
wsServer.on("connection", (ws) => {
// After WebSocket upgrade completes, check active tasks
const stats = getEventLoopStats();
ws.close();
wsServer.close();
server.close();
// With the bug: poll_ref from NodeHTTPResponse stays active (activeTasks = 1)
// With the fix: poll_ref.unref() was called on upgrade (activeTasks should be 0)
if (stats.activeTasks !== initialStats.activeTasks) {
console.error("BUG_DETECTED: activeTasks=" + stats.activeTasks + " (expected 0 after upgrade)");
process.exit(1);
}
process.exitCode = 0;
});
initialStats = getEventLoopStats();
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
const ws = new WebSocket("ws://127.0.0.1:" + port);
});
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
// Should exit cleanly without detecting the bug
expect(stderr).not.toContain("BUG_DETECTED");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test.concurrent("should not crash when closing sockets after upgrade", async () => {
const { promise, resolve } = Promise.withResolvers();
let http_sockets: tls.TLSSocket[] = [];
const server = https.createServer(options, (req, res) => {
http_sockets.push(res.socket as tls.TLSSocket);
res.writeHead(200, { "Content-Type": "text/plain", "Connection": "Keep-Alive" });
res.end("okay");
res.detachSocket(res.socket!);
});
server.listen(0, "127.0.0.1", () => {
const wsServer = new WebSocketServer({ server });
wsServer.on("connection", socket => {});
const port = (server.address() as AddressInfo).port;
const socket = tls.connect({ port, ca: options.cert }, () => {
// normal request keep the socket alive
socket.write(`GET / HTTP/1.1\r\nHost: localhost:${port}\r\nConnection: Keep-Alive\r\nContent-Length: 0\r\n\r\n`);
socket.write(`GET / HTTP/1.1\r\nHost: localhost:${port}\r\nConnection: Keep-Alive\r\nContent-Length: 0\r\n\r\n`);
socket.write(`GET / HTTP/1.1\r\nHost: localhost:${port}\r\nConnection: Keep-Alive\r\nContent-Length: 0\r\n\r\n`);
// upgrade to websocket
socket.write(
`GET / HTTP/1.1\r\nHost: localhost:${port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`,
);
});
socket.on("data", data => {
const isWebSocket = data?.toString().includes("Upgrade: websocket");
if (isWebSocket) {
socket.destroy();
setTimeout(() => {
http_sockets.forEach(http_socket => {
http_socket?.destroy();
});
server.closeAllConnections();
resolve();
}, 10);
}
});
});
await promise;
expect().pass();
});