Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 29 additions & 18 deletions src/jsc/bindings/webcore/MessagePort.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Ref<MessagePort> MessagePort::create(ScriptExecutionContext& context, Ref<Messag
{
auto messagePort = adoptRef(*new MessagePort(context, WTF::move(pipe), side));
messagePort->suspendIfNeeded();
// So the peer's close() reaches this port (peerClosed()) even if it never gets a listener.
messagePort->m_pipe->registerCloseContext(side, context.identifier(), ThreadSafeWeakPtr<MessagePort> { messagePort.get() });
return messagePort;
}

Expand Down Expand Up @@ -271,24 +273,33 @@ void MessagePort::dispatchCloseEvent()

void MessagePort::peerClosed()
{
if (m_isDetached)
if (m_isDetached || m_isClosing)
return;
auto* context = scriptExecutionContext();
if (!context || !context->globalObject())
return;
Ref protectedThis { *this };
// Deliver whatever the peer sent before it closed, then fire 'close'. Node orders
// them that way, and registerCloseContext()'s retroactive notify can land before any
// drain is scheduled -- e.g. on('close') registered before on('message').
// Node delivers what the peer sent before closing first; this task can run before the drain.
if (m_started && hasMessageEventListener())
flushQueuedMessagesBeforeClose();
// Fire 'close' (guarded against a double dispatch) and release this side's loop refs
// so the loop can idle, matching node.
dispatchCloseEvent();
// jsUnref() clears both the listener loop-ref (m_isRefd) and the onmessage/ref()
// keepalive (m_hasRef), so a listening transferred port stops pinning the loop.
auto* globalObject = defaultGlobalObject(context->globalObject());
jsUnref(globalObject);
// A handler in that flush closed or transferred this port.
if (m_isDetached || m_isClosing)
return;
if (!m_pipe->isOtherSideClosedByRequest(m_side)) {
// Peer collected, not closed: node never closes a channel over that (see jsRef()), so
// only release the loop refs of a port that is started or listening for 'close'. An
// idle port keeps its one 'close' event for its own close(cb).
Comment thread
robobun marked this conversation as resolved.
Outdated
if (m_started || m_hasCloseEventListener.load(std::memory_order_relaxed)) {
dispatchCloseEvent();
jsUnref(defaultGlobalObject(context->globalObject()));
}
return;
}
// Not receiving, messages still queued: node's close notification waits behind them.
// attach() notifies again once the port starts; tryTakeMessage() closes on the empty read.
if (MessagePortPipe::queuedCount(m_pipe->state(m_side)) > 0)
return;
Comment on lines +307 to +311

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The !m_receiving && queuedCount > 0 deferral models node's receiving_messages_, but node's same removeListener hook that clears it (stopMessagePort) also calls port.unref() — so a not-receiving port never holds a loop ref there. Bun's removeEventListener (:562-568) clears m_receiving but leaves m_hasRef untouched, so port1.ref(); port1.on('message',h); port1.off('message',h); port2.postMessage('m'); port2.close(); (or .once() + two peer messages) defers here forever with refKeepAlive(+1) never balanced — process hangs. Pre-PR peerClosed() unconditionally jsUnref()'d and it exited; node exits because the pause unref'd the port. Fix: mirror node — release the loop ref when the last 'message' listener is removed (or jsUnref() here even when deferring, so the port stays open/transferable but stops pinning the loop).

Extended reasoning...

What the bug is

peerClosed() at MessagePort.cpp:310-311 defers closing when !m_receiving && queuedCount > 0, keeping the port open until a later 'message' listener (via attach()) or receiveMessageOnPort() (via tryTakeMessage()) drains the queue. m_receiving is modeled on node's receiving_messages_, which node's stopMessagePort clears when the last 'message' listener is removed. But node's setupPortReferencing removeListener hook does two things at that point: it calls stopMessagePort and port.unref(). Bun's removeEventListener at :562-568 replicates only the first — it clears m_receiving but leaves m_hasRef (the explicit .ref() keepalive) alone. So a .ref()'d port paused by removing its last listener still holds Bun__eventLoop__refKeepAlive(+1), and if the peer closes with a message left queued, peerClosed() defers here forever without ever reaching close() — the process hangs.

Step-by-step proof

const { port1, port2 } = new (require('worker_threads').MessageChannel)();
port1.ref();
const h = () => {};
port1.on('message', h);
port1.off('message', h);
port2.postMessage('queued');
port2.close();
setTimeout(() => { console.log('hung, hasRef=' + port1.hasRef()); process.exit(1); }, 3000).unref();
  1. port1.ref()jsRef(): isEntangled() true, isOtherSideClosedByRequest() false → m_hasRef=true, self-ref(), Bun__eventLoop__refKeepAlive(vm, +1).
  2. port1.on('message', h)addEventListener calls start() (sets m_started=true, m_receiving=true, attach()); onDidChangeListenerImpl bumps m_messageEventCount to 1 → m_listenerLoopRefActive=true.
  3. port1.off('message', h)removeEventListener at :559-568: no more 'message' listeners → m_hasMessageEventListener=false, m_receiving=false. onDidChangeListenerImpl drops count to 0 → m_listenerLoopRefActive=false. m_hasRef stays true — nothing in this path touches it.
  4. port2.postMessage('queued')send() appends to side 0's inbox (queued=1), sets DrainScheduled, posts a drain task.
  5. port2.close()pipe->close(1, Explicit) sets Closed | ClosedByRequest on side 1, notifyPeerClosed(0) posts a task.
  6. Drain task runsdrainAndDispatch hits !port->hasMessageEventListener() (MessagePortPipe.cpp:127-131), clears DrainScheduled, returns without consuming. queued stays 1.
  7. peerClosed() task runsm_started && hasMessageEventListener() is (true && false) → skip flush. isOtherSideClosedByRequest() is true → skip collected-peer branch. !m_receiving && queuedCount > 0 is (true && 1>0) → return at :311. close() at :312 (which would have released m_hasRef) is never reached.

Result: m_hasRef's Bun__eventLoop__refKeepAlive(+1) is never balanced → process hangs.

The .once() variant is arguably more realistic and hits the identical path: port1.ref(); port1.once('message', ()=>{}); then the peer sends two messages and closes — the once() consumes the first (its self-removal clears m_receiving), the second is left queued, peerClosed() defers forever.

Why nothing recovers it

  • attach() re-notifies on a Closed peer, but is only reached from start() or a new 'message' listener — the user never adds one and never calls start().
  • tryTakeMessage() requires a user receiveMessageOnPort() call; there isn't one.
  • registerCloseContext() (from a late 'close' listener) would just re-post peerClosed(), which hits the same deferral.
  • virtualHasPendingActivity() returns false (no listeners, DrainScheduled clear, peer closed, but queued>0 → actually true on the last line, but irrelevant): even if the wrapper were collected, m_hasRef's self-ref() keeps the C++ object alive and ~MessagePort() doesn't release the keepalive.

Why this is a regression

  • Pre-PR: peerClosed() unconditionally called jsUnref(globalObject), which cleared m_hasRef and Bun__eventLoop__refKeepAlive(-1) → process exited.
  • Node: setupPortReferencing's removeListener handler calls port.unref() alongside stopMessagePort. libuv ref is a single bit, so this undoes the earlier explicit .ref() — the port stays open (deferred close, matching this PR's intent) but its handle is unref'd → process exits.
  • The onmessage = fn; onmessage = null variant doesn't hit this because setJSMessagePort_onmessageSetter calls jsUnref() on the null assignment; only .ref() + .on/.off (or a fired .once) reaches it.

This is the remaining gap in the m_receiving fix from the previous review rounds: the earlier hangs (start()-only, and pause+start()) were fixed by making start() set m_receiving on every call, but a paused port the user never resumes still holds m_hasRef forever. The three "paused by …" tests don't .ref(), and the two "start() on a paused port" tests .ref() but explicitly resume via start(), so no test in the PR covers .ref() + paused + queued message + peer close + never resumed.

How to fix it

Two options that both match node's observable state (port stays open/transferable, but does not pin the loop):

(a) Mirror node — call jsUnref() (or at least release m_hasRef) in removeEventListener when the last 'message' listener goes, alongside clearing m_receiving. This is exactly what makes node's deferral safe: a not-receiving port never holds a loop ref there. addEventListener's start() path would need to reacquire it (or the user calls .ref() again), which is also what node does.

(b) Release the loop refs when deferring — in peerClosed(), jsUnref() even when taking the !m_receiving && queuedCount > 0 early return. The port stays open/transferable (the close is still deferred), but stops pinning the loop.

Either way, add a spawned-process test alongside the existing "a ref()'d port with no listeners…" one: port1.ref(); port1.once('message', ()=>{}); port2.postMessage('a'); port2.postMessage('b'); port2.close(); and assert the process exits.

close();
}
Comment thread
robobun marked this conversation as resolved.

TransferredMessagePort MessagePort::disentangle()
Expand Down Expand Up @@ -381,8 +392,14 @@ JSValue MessagePort::tryTakeMessage(JSGlobalObject* lexicalGlobalObject, bool& h
return jsUndefined();

auto message = m_pipe->takeOne(m_side);
if (!message)
if (!message) {
// This read reached the peer's close: node closes the port here (see peerClosed()).
// Peer state before our queue, as in virtualHasPendingActivity(): a message the peer
// sent between takeOne() and its close() is then visible and must not be dropped.
Comment thread
robobun marked this conversation as resolved.
if (m_pipe->isOtherSideClosedByRequest(m_side) && !MessagePortPipe::queuedCount(m_pipe->state(m_side)))
close();
return jsUndefined();
Comment thread
claude[bot] marked this conversation as resolved.
}

hadMessage = true;
auto ports = MessagePort::entanglePorts(*context, WTF::move(message->transferredPorts));
Expand Down Expand Up @@ -525,12 +542,6 @@ bool MessagePort::addEventListener(const AtomString& eventType, Ref<EventListene
}
} else if (eventType == eventNames().closeEvent) {
m_hasCloseEventListener.store(true, std::memory_order_release);
if (isEntangled()) {
// Record our context with the pipe so the peer's close() can deliver a
// 'close' event even if we never started (no 'message' listener).
if (auto* context = scriptExecutionContext())
m_pipe->registerCloseContext(m_side, context->identifier(), ThreadSafeWeakPtr<MessagePort> { *this });
}
}
return EventTarget::addEventListener(eventType, WTF::move(listener), options);
}
Expand Down
3 changes: 1 addition & 2 deletions src/jsc/bindings/webcore/MessagePort.h
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ class MessagePort final : public ActiveDOMObject, public EventTarget, public Thr
// The worker's entry module finished evaluating: a start() requested before that takes effect now.
void entrySettled();
void close();
// Called on the entangled peer when this side closes: dispatches a
// 'close' event and releases the event-loop ref so the loop can idle.
// Posted by the pipe to the peer of a side that closed: closes this port too once its queue is drained.
void peerClosed();
void dispatchCloseEvent();

Expand Down
5 changes: 2 additions & 3 deletions src/jsc/bindings/webcore/MessagePortPipe.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,8 @@ void MessagePortPipe::attach(uint8_t side, ScriptExecutionContextIdentifier ctxI
}
if (wakeCtx)
scheduleDrain(side, wakeCtx);
// Peer already closed while this side was in transit (detach() cleared
// ContextKnown so notifyPeerClosed() early-returned): re-deliver to the new
// owner, or the receiving context's listener loop-ref is never released.
// Peer already closed: notify again now that this side is receiving. The task runs after the
// drain scheduled above, so a close that peerClosed() deferred behind queued messages completes.
Comment thread
robobun marked this conversation as resolved.
if (m_sides[1 - side].state.load(std::memory_order_acquire) & Closed)
notifyPeerClosed(side);
}
Expand Down
5 changes: 2 additions & 3 deletions src/jsc/bindings/webcore/MessagePortPipe.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,8 @@ class MessagePortPipe final : public ThreadSafeRefCounted<MessagePortPipe> {
// already queued (e.g. after transfer). Passing a null port is allowed and
// means "just buffer, don't dispatch" (used before start()).
void attach(uint8_t side, ScriptExecutionContextIdentifier, ThreadSafeWeakPtr<MessagePort>);
// Like attach() but only records ctxId/port so the peer's close() can deliver
// a 'close' event to a port that never started (no 'message' listener). Does
// NOT enable drains. No-op if already attached/registered/closed.
// Called when a MessagePort is created for this side: records ctxId/port so the peer's close()
// reaches a port that never started, without enabling drains. No-op once attached/registered/closed.
Comment thread
robobun marked this conversation as resolved.
Outdated
void registerCloseContext(uint8_t side, ScriptExecutionContextIdentifier, ThreadSafeWeakPtr<MessagePort>);
void detach(uint8_t side);
// Explicit == a real, permanent close: close(), context teardown, or an orphaned
Expand Down
261 changes: 261 additions & 0 deletions test/js/node/worker_threads/worker_threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1785,6 +1785,267 @@ test("MessagePort: peer closing while a port is in transit still delivers 'close
});
});

// When one port of a channel is close()d, node closes the other one as well (once the
// close has propagated): it fires 'close', drops its loop refs, and from then on it is
// rejected as a transferable like any closed port. Bun fired 'close' on the peer but
// left it open, so a dead endpoint could be transferred; a peer with no listeners was
// not told at all.
describe("a port whose peer closed is closed too", () => {
const detached = expect.objectContaining({
name: "DataCloneError",
message: "MessagePort in transfer list is already detached",
});
const tick = () => new Promise(r => setImmediate(r));

// Probe without transferring anything: the trailing plain object is itself invalid, so the
// post always throws, and the message says whether `port` (checked first, in list order)
// was rejected as detached.
function isRejectedAsDetached(port: MessagePort): boolean {
const carrier = new MessageChannel();
let message: string | undefined;
try {
carrier.port1.postMessage(null, [port, {} as any]);
} catch (e: any) {
message = e.message;
} finally {
carrier.port1.close();
carrier.port2.close();
}
if (message === undefined) throw new Error("the probe post must throw");
return message === "MessagePort in transfer list is already detached";
}

test("with a 'close' listener: rejected by postMessage and structuredClone, transfer stays atomic", async () => {
const { port1, port2 } = new MessageChannel();
const carrier = new MessageChannel();
const closed = once(port1, "close");
port2.close();
await closed;

const ab = new ArrayBuffer(8);
expect(() => carrier.port1.postMessage(null, [ab, port1])).toThrow(detached);
// Same as node: a bad port later in the list means nothing earlier in it is transferred.
expect(ab.byteLength).toBe(8);
expect(() => structuredClone(port1, { transfer: [port1] })).toThrow(detached);
expect(port1.hasRef()).toBe(false);
carrier.port1.close();
carrier.port2.close();
});

test("with a 'message' listener: what the peer sent first is delivered, then the port closes", async () => {
const { port1, port2 } = new MessageChannel();
const events: string[] = [];
port1.on("message", m => events.push(m));
const closed = once(port1, "close");
port2.postMessage("m1");
port2.close();
await closed;
events.push("close");

expect(events).toEqual(["m1", "close"]);
expect(isRejectedAsDetached(port1)).toBe(true);
expect(port1.hasRef()).toBe(false);
});

test("the port is already closed when its own 'close' listener runs", async () => {
const { port1, port2 } = new MessageChannel();
const { promise, resolve } = Promise.withResolvers<boolean>();
port1.on("close", () => resolve(isRejectedAsDetached(port1)));
port2.close();
expect(await promise).toBe(true);
});

test("with no listeners at all", async () => {
const { port1, port2 } = new MessageChannel();
port2.close();
// The close reaches port1 as a task; poll for it rather than assume how many turns it takes.
let rejected = false;
for (let i = 0; i < 100 && !rejected; i++) {
await tick();
rejected = isRejectedAsDetached(port1);
}
expect(rejected).toBe(true);
});

test("Worker.postMessage() and the Worker constructor's transferList reject it as well", async () => {
const { port1, port2 } = new MessageChannel();
const closed = once(port1, "close");
port2.close();
await closed;

const worker = new Worker("", { eval: true });
try {
expect(() => worker.postMessage(port1, [port1])).toThrow(detached);
expect(() => new Worker("", { eval: true, workerData: port1, transferList: [port1] })).toThrow(detached);
} finally {
await worker.terminate();
}
});

// node's close notification sits behind whatever the peer posted before closing: a port
// that is not receiving keeps those messages and stays open until they are consumed.
test("a port that is not receiving stays open until a 'message' listener drains the queue", async () => {
const { port1, port2 } = new MessageChannel();
const events: string[] = [];
const closed = once(port1, "close");
port2.postMessage("m1");
port2.postMessage("m2");
port2.close();
for (let i = 0; i < 4; i++) await tick();
expect({ events, rejected: isRejectedAsDetached(port1) }).toEqual({ events: [], rejected: false });

port1.on("message", m => events.push(m));
await closed;
events.push("close");
expect(events).toEqual(["m1", "m2", "close"]);
expect(isRejectedAsDetached(port1)).toBe(true);
});

test("a port that is not receiving stays open until receiveMessageOnPort() drains the queue", async () => {
const { port1, port2 } = new MessageChannel();
let closes = 0;
const closed = once(port1, "close");
port1.on("close", () => closes++);
port2.postMessage("m1");
port2.close();
for (let i = 0; i < 4; i++) await tick();
expect({ closes, rejected: isRejectedAsDetached(port1) }).toEqual({ closes: 0, rejected: false });

expect(receiveMessageOnPort(port1)).toEqual({ message: "m1" });
// Taking the last message does not close it yet (node closes on reaching the notification).
expect({ closes, rejected: isRejectedAsDetached(port1) }).toEqual({ closes: 0, rejected: false });

// This call reaches the peer's close: no message, and the port is closed on the spot.
expect(receiveMessageOnPort(port1)).toBeUndefined();
expect({ closes, rejected: isRejectedAsDetached(port1) }).toEqual({ closes: 0, rejected: true });
await closed;
expect(closes).toBe(1);
});

test("a port left open that way is still a valid transferable; the receiver gets the queue and the close", async () => {
const { port1, port2 } = new MessageChannel();
const carrier = new MessageChannel();
let senderCloses = 0;
port1.on("close", () => senderCloses++);
port2.postMessage("m1");
port2.close();
for (let i = 0; i < 4; i++) await tick();
expect(senderCloses).toBe(0);

carrier.port1.postMessage(port1, [port1]);
const [received] = (await once(carrier.port2, "message")) as [MessagePort];
const events: string[] = [];
const closed = once(received, "close");
received.on("message", m => events.push(m));
await closed;
events.push("close");
expect(events).toEqual(["m1", "close"]);
carrier.port1.close();
carrier.port2.close();
});

// Makes a channel, drops one port and collects it (its WeakRef clearing is the proof; the
// collection runs ~MessagePort, which posts the peer notification), then gives the surviving
// port a turn to process that notification and returns it.
async function survivorOfCollectedPeer(prepare: (survivor: MessagePort, doomed: MessagePort) => void) {
const { survivor, doomed } = (() => {
const { port1, port2 } = new MessageChannel();
prepare(port1, port2);
return { survivor: port1, doomed: new WeakRef(port2) };
})();
let collected = false;
for (let i = 0; i < 20 && !collected; i++) {
// new WeakRef() / deref() keep the target alive for the rest of the current turn.
await tick();
Bun.gc(true);
collected = doomed.deref() === undefined;
}
expect(collected).toBe(true);
await tick();
return survivor;
}

// Only an explicit close counts. Bun collects an entangled port whose wrapper is unreachable
// (node never does) and notifies the peer so it releases its loop refs; closing the peer on
// top of that would make its transfer fail or not depending on when GC ran.
test("a peer that was garbage collected rather than closed leaves the port transferable", async () => {
const carrier = new MessageChannel();
const port1 = await survivorOfCollectedPeer(survivor => survivor.on("close", () => {}));
expect(isRejectedAsDetached(port1)).toBe(false);
carrier.port1.postMessage(port1, [port1]);
carrier.port1.close();
carrier.port2.close();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// The wait-for-the-queue-to-drain rule is for an explicit close only: nothing would ever
// re-notify a 'close'-listening port that never starts, and until 'close' has been dispatched
// such a port is pinned in the heap. This is what main did before the change, kept as is.
test("a 'close'-listening port with an unread message is still told when its peer is garbage collected", async () => {
let closes = 0;
const port1 = await survivorOfCollectedPeer((survivor, doomed) => {
survivor.on("close", () => closes++);
doomed.postMessage("unread");
});
for (let i = 0; i < 20 && closes === 0; i++) await tick();
expect({ closes, unread: receiveMessageOnPort(port1), rejected: isRejectedAsDetached(port1) }).toEqual({
closes: 1,
unread: { message: "unread" },
rejected: false,
});
});

// ...and an idle port is not even told: its one 'close' event has to stay available for
// the close() that eventually happens (node's test-worker-message-port.js relies on this).
test("a port whose peer was garbage collected still delivers close(cb) when it is closed later", async () => {
const idle = await survivorOfCollectedPeer(() => {});
const unread = await survivorOfCollectedPeer((_survivor, doomed) => doomed.postMessage("queued"));

// 'close' is dispatched from a task queued by close(); give it a few turns, then give up.
function closeCallbackFires(port: MessagePort): Promise<string> {
return Promise.race([
new Promise<string>(resolve => port.close(() => resolve("called"))),
(async () => {
for (let i = 0; i < 20; i++) await tick();
return "not called";
})(),
]);
}

expect(await closeCallbackFires(idle)).toBe("called");
// close() from inside the 'message' handler, as node's test does.
const unreadResult = await new Promise<string>(resolve => {
unread.on("message", m => closeCallbackFires(unread).then(cb => resolve(`${m}, close(cb) ${cb}`)));
});
expect(unreadResult).toBe("queued, close(cb) called");
});

// The same missing notification left a ref()'d port with no listeners pinning the loop
// forever after its peer closed; node's port closes and the process exits.
test("a ref()'d port with no listeners lets the process exit once its peer closes", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const { MessageChannel } = require("worker_threads");
const { port1, port2 } = new MessageChannel();
port1.ref();
port2.close();
// unref'd, so it only ever fires if something else (the port) is still holding the loop open.
setTimeout(() => { console.log("still running, hasRef=" + port1.hasRef()); process.exit(1); }, 3000).unref();
process.on("exit", code => console.log("exit " + code + ", hasRef=" + port1.hasRef()));`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({
stdout: "exit 0, hasRef=false",
stderr: "",
exitCode: 0,
});
});
});

test("workerData is not unwrapped for a non-node globalThis.Worker", async () => {
await using proc = Bun.spawn({
cmd: [
Expand Down
Loading