ws: drop dead if(!this) guard in server WebSocket terminate()

Closed
RO
1mo ago
+27-8

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 plain wsClient.terminate() on an instance produced by WebSocketServer.handleUpgrade. Instrumenting the shutdown loop under Bun confirms the receiver is always set.

  • With the guard removed, next dev --turbopack with 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 npm ws package throws TypeError in that case (it immediately reads this.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