Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
706f962b67 fix(ws): dispatch WebSocket close event asynchronously
The close event was being dispatched synchronously during the `.close()`
call when event listeners were present, violating the HTML spec which
requires the close event to be "queued as a task". This caused code
after `.close()` to run after `onclose`, breaking patterns where a
promise resolver is set up after calling `.close()`.

Fixes #15665

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 05:14:45 +00:00
2 changed files with 53 additions and 12 deletions

View File

@@ -1474,18 +1474,7 @@ void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code,
// so we just call decPendingActivityCount() after dispatching the event
ASSERT(m_pendingActivityCount > 0);
if (this->hasEventListeners("close"_s)) {
this->dispatchEvent(CloseEvent::create(wasClean, code, reason));
// we deinit if possible in the next tick
if (auto* context = scriptExecutionContext()) {
context->postTask([this, protectedThis = Ref { *this }](ScriptExecutionContext& context) {
ASSERT(scriptExecutionContext());
protectedThis->disablePendingActivity();
});
return;
}
} else if (auto* context = scriptExecutionContext()) {
if (auto* context = scriptExecutionContext()) {
context->postTask([this, code, wasClean, reason, protectedThis = Ref { *this }](ScriptExecutionContext& context) {
ASSERT(scriptExecutionContext());
protectedThis->dispatchEvent(CloseEvent::create(wasClean, code, reason));

View File

@@ -0,0 +1,52 @@
import { expect, test } from "bun:test";
// https://github.com/oven-sh/bun/issues/15665
// WebSocket.onclose should fire asynchronously after .close() returns,
// not synchronously during the .close() call.
test("WebSocket.onclose fires asynchronously after .close()", async () => {
using server = Bun.serve({
port: 0,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("not a websocket", { status: 400 });
},
websocket: {
open(ws) {},
message(ws, data) {
ws.send(data);
},
close(ws, code, reason) {},
},
});
const ws = new WebSocket(`ws://localhost:${server.port}`);
// Wait for open
const { promise: openPromise, resolve: openResolve } = Promise.withResolvers<void>();
ws.onopen = () => openResolve();
await openPromise;
// Track the order of execution
const order: string[] = [];
let closeResolve: (() => void) | undefined;
ws.onclose = () => {
order.push("onclose");
closeResolve?.();
};
// Call close - onclose should NOT fire synchronously here
ws.close(3000);
order.push("after-close");
// Set up the close promise AFTER calling close.
// If onclose fires asynchronously (correct), closeResolve will be set
// before onclose runs, and the promise will resolve.
// If onclose fires synchronously (bug), closeResolve is still undefined
// when onclose runs, and the promise would never resolve.
const closePromise = new Promise<void>(r => (closeResolve = r));
await closePromise;
// Verify that "after-close" was recorded before "onclose"
expect(order).toEqual(["after-close", "onclose"]);
});