ws: drop dead if(!this) guard in server WebSocket terminate()
Summary
Removes the if (!this) return; guard from BunWebSocketMocked.prototype.terminate() in the ws shim. It was added in #17893 as a defensive workaround for an unhandledRejection: TypeError: undefined is not an object (evaluating 'this.#state') seen during next dev --turbopack shutdown, on the reading that this was undefined. The actual fault was elsewhere and was fixed by #20246; the guard never covered it.
Root cause of the original error
At the time of #17893 the server-side terminate() read a module-level constant off a lazily-initialized variable:
let WebSocket; // only assigned inside the client BunWebSocket constructor
class BunWebSocketMocked extends EventEmitter {
terminate() {
...
if (ws) {
this.#state = WebSocket.CLOSING; // WebSocket === undefined on server-only use
ws.terminate();
}
}
}
next dev only uses WebSocketServer, never the ws client, so WebSocket stayed undefined and WebSocket.CLOSING threw. JSC's expression-info for the failing assignment quoted the LHS this.#state in the message, which made it look like a detached call. #20246 fixed the real bug by replacing WebSocket.CLOSING with the module-level ReadyState_CLOSING constant; its title is literally "fix undefined is not an object with ctrl+c during next dev". The guard only checks !this, so with a valid receiver (which Next.js always supplies) it was a no-op both before and after.
Why removing the guard is correct
-
Auditing Next.js 15.2.0 through 16.x shows every
terminate()call site in the SIGINT/SIGTERM path is a plainwsClient.terminate()on an instance produced byWebSocketServer.handleUpgrade. Instrumenting the shutdown loop under Bun confirms the receiver is always set. -
With the guard removed,
next dev --turbopackwith active HMR sockets shuts down cleanly on SIGINT (verified with the debug build against Next 15.2.0 and 16.2.10). -
The only behaviour the guard changes is a detached
terminate()becoming a silent no-op. The npmwspackage throwsTypeErrorin that case (it immediately readsthis.readyState), so keeping the guard diverged Bun from Node:$ node -e 'const {WebSocketServer,WebSocket}=require("ws");const s=new WebSocketServer({port:0});s.on("connection",ws=>{const t=ws.terminate;t()});s.on("listening",()=>new WebSocket("ws://localhost:"+s.address().port))' ...TypeError: Cannot read properties of undefined (reading 'readyState')
Test
Added test/js/first_party/ws/ws-terminate.test.ts: a detached terminate() and terminate.call(undefined) must throw TypeError (matching npm ws), and the normal bound call must still succeed. Kept in its own file because ws.test.ts's subprocess-spawning helper has a hardcoded 1s timeout that fails 30 unrelated tests under the ASAN debug build on constrained runners; a standalone file lets this diff be verified cleanly.
=== before (USE_SYSTEM_BUN=1) ===
error: expect(received).toThrow(expected)
Received function did not throw
(fail) server WebSocket terminate() throws TypeError when called without a receiver
=== after (bun bd) ===
(pass) server WebSocket terminate() throws TypeError when called without a receiver
[stamp-90s] gate passed · iteration 2 · 2 files touched
fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/first_party/ws/ws-terminate.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (583de0194)
test/js/first_party/ws/ws-terminate.test.ts:
8 | const { resolve, reject, promise } = Promise.withResolvers<void>();
9 |
10 | wss.on("connection", ws => {
11 | try {
12 | const terminate = ws.terminate;
13 | expect(() => terminate()).toThrow(TypeError);
^
error: expect(received).toThrow(expected)
Expected constructor: TypeError
Received function did not throw
Received value: undefined
at <anonymous> (/workspace/bun/test/js/first_party/ws/ws-terminate.test.ts:13:33)
at emit (node:events:155:22)
at completeUpgrade (ws:1086:9)
at handleUpgrade (ws:1149:25)
at doUpgrade (ws:972:27)
at emit (node:events:158:22)
a
... (truncated)
release without fix: all passed
bun test v1.4.0-canary.1 (87600edf1)
test/js/first_party/ws/ws-terminate.test.ts:
(pass) server WebSocket terminate() throws TypeError when called without a receiver [8.17ms]
1 pass
0 fail
3 expect() calls
Ran 1 test across 1 file. [179.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/first_party/ws/ws-terminate.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (583de0194)
test/js/first_party/ws/ws-terminate.test.ts:
(pass) server WebSocket terminate() throws TypeError when called without a receiver [344.33ms]
1 pass
0 fail
3 expect() calls
Ran 1 test across 1 file. [3.33s]
__F:0:S:0
release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 762ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/28] gen cpp.rs (cppbind)
[2/28] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[3/28] gen JS modules (bundle-modules)
Preprocess modules (7513ms)
Bundle modules (29ms)
Postprocesss modules (184ms)
Bundle Functions (751ms)
Generate Code (98ms)
[8.59s] Bundled "src/js" for production
2040 kb
165 internal modules
13 native modules
90 internal functions across 19 files
[3/18] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rus
... (truncated)
diff hotspot
src/js/thirdparty/ws.js | 8 --------
test/js/first_party/ws/ws-terminate.test.ts | 27 +++++++++++++++++++++++++++
2 files changed, 27 insertions(+), 8 deletions(-)
gate history · 2 passed · 1 rejected · iteration 2
evidence per changed file
file reads edits tests
src/js/thirdparty/ws.js 3 1 0
test/js/first_party/ws/ws-terminate.test.ts 0 1 0