From 89d2b1cd0bd11ea7252a69bdf71d3dbb01306341 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 5 Feb 2026 20:39:19 -0800 Subject: [PATCH] fix(websocket): add missing incPendingActivityCount() in blob binaryType case (#26670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fix crash ("Pure virtual function called!") when WebSocket client receives binary data with `binaryType = "blob"` and no event listener attached - Add missing `incPendingActivityCount()` call before `postTask` in the Blob case of `didReceiveBinaryData` - Add regression test for issue #26669 ## Root Cause The Blob case in `didReceiveBinaryData` (WebSocket.cpp:1324-1331) was calling `decPendingActivityCount()` inside the `postTask` callback without a matching `incPendingActivityCount()` beforehand. This bug was introduced in #21471 when Blob support was added. The ArrayBuffer and NodeBuffer cases correctly call `incPendingActivityCount()` before `postTask`, but the Blob case was missing this call. ## Test plan - [x] New regression test verifies WebSocket with `binaryType = "blob"` doesn't crash on ping frames - [x] `bun bd test test/regression/issue/26669.test.ts` passes Fixes #26669 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Bot Co-authored-by: Claude Opus 4.5 Co-authored-by: Jarred Sumner Co-authored-by: Ciro Spaciari MacBook --- src/bun.js/bindings/webcore/WebSocket.cpp | 1 + test/regression/issue/26669.test.ts | 69 +++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 test/regression/issue/26669.test.ts diff --git a/src/bun.js/bindings/webcore/WebSocket.cpp b/src/bun.js/bindings/webcore/WebSocket.cpp index dedc2a10be..04753385bd 100644 --- a/src/bun.js/bindings/webcore/WebSocket.cpp +++ b/src/bun.js/bindings/webcore/WebSocket.cpp @@ -1323,6 +1323,7 @@ void WebSocket::didReceiveBinaryData(const AtomString& eventName, const std::spa if (auto* context = scriptExecutionContext()) { RefPtr blob = Blob::create(binaryData, context->jsGlobalObject()); + this->incPendingActivityCount(); context->postTask([this, name = eventName, blob = blob.releaseNonNull(), protectedThis = Ref { *this }](ScriptExecutionContext& context) { ASSERT(scriptExecutionContext()); protectedThis->dispatchEvent(MessageEvent::create(name, blob, protectedThis->m_url.string())); diff --git a/test/regression/issue/26669.test.ts b/test/regression/issue/26669.test.ts new file mode 100644 index 0000000000..72bcea9215 --- /dev/null +++ b/test/regression/issue/26669.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// https://github.com/oven-sh/bun/issues/26669 +// WebSocket client crashes ("Pure virtual function called!") when binaryType = "blob" +// and no event listener is attached. The missing incPendingActivityCount() allows the +// WebSocket to be GC'd before the postTask callback runs. +test("WebSocket with binaryType blob should not crash when GC'd before postTask", async () => { + await using server = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) return undefined; + return new Response("Not a websocket"); + }, + websocket: { + open(ws) { + // Send binary data immediately - this triggers didReceiveBinaryData + // with the Blob path when client has binaryType = "blob" + ws.sendBinary(new Uint8Array(64)); + ws.sendBinary(new Uint8Array(64)); + ws.sendBinary(new Uint8Array(64)); + }, + message() {}, + }, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` +const url = process.argv[1]; +// Create many short-lived WebSocket objects with blob binaryType and no listeners. +// Without the fix, the missing incPendingActivityCount() lets the WebSocket get GC'd +// before the postTask callback fires, causing "Pure virtual function called!". +async function run() { + for (let i = 0; i < 100; i++) { + const ws = new WebSocket(url); + ws.binaryType = "blob"; + // Intentionally: NO event listeners attached. + // This forces the postTask path in didReceiveBinaryData's Blob case. + } + // Force GC to collect the unreferenced WebSocket objects while postTask + // callbacks are still pending. + Bun.gc(true); + await Bun.sleep(50); + Bun.gc(true); + await Bun.sleep(50); + Bun.gc(true); + await Bun.sleep(100); +} +await run(); +Bun.gc(true); +await Bun.sleep(200); +console.log("OK"); +process.exit(0); +`, + `ws://localhost:${server.port}`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("OK"); + expect(exitCode).toBe(0); +});