Skip to content

Bun.serve websocket: deliver queued publish() messages when unsubscribing from last topic - #32852

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/9428174c/ws-pubsub-unsubscribe-drain
Jun 29, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/9428174c/ws-pubsub-unsubscribe-drain

Conversation

@robobun

@robobun robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

publish() queues messages per subscriber in the uWS TopicTree; they are flushed at the end of the event loop tick. When a ServerWebSocket calls unsubscribe() on its last topic in the same tick as a publish(), the subscriber object is freed with its undrained queue still attached, so messages that publish() had already accepted (and returned a non-zero byte count for) are silently discarded.

// inside a message handler
for (let i = 0; i < 5; i++) server.publish("room", "msg" + i);  // each returns 4
ws.unsubscribe("room");  // ws was only subscribed to "room"
// ws receives none of msg0..msg4

Keeping any other topic subscription on the same socket makes the messages arrive, which shows the loss is a subscriber-lifetime artifact rather than a delivery policy.

Cause

WebSocket::unsubscribe() calls TopicTree::freeSubscriber() when the last topic is removed. freeSubscriber() unlinks the subscriber from the drainable list without draining it and then deletes it, dropping any messages still referenced by messageIndices[].

Fix

Drain the subscriber before freeing it in WebSocket::unsubscribe(). The other two freeSubscriber call sites are unaffected: end() sends a close frame via send(), which drains first; the TCP onClose path has no live socket to write to.

Also corrects the backpressureLimit default in docs/runtime/http/websockets.mdx (16 MB, matching WebSocketServerContext.rs and the type docs; it said 1 MB).

Rebase note

This PR originally also rewrote the publish() return-value JSDoc in packages/bun-types/serve.d.ts to describe the (then) real behavior: the documented 0/-1 backpressure returns were unreachable and publish() always returned the byte count.

That is no longer the case. #32889 landed on main and fixes the underlying contract for real: publish() now aggregates the per-subscriber SendStatus out of uWS and returns 0/-1 on backpressure, and it updated the JSDoc accordingly. The serve.d.ts changes here described the pre-#32889 behavior and are superseded, so they were dropped during the rebase. The remaining diff is the unsubscribe lifetime fix, its regression test, and the backpressureLimit doc correction, none of which overlap with #32889.

Verification

Re-verified against main after the rebase onto #32889 and later.

# test file built with the fix reverted (origin/main's WebSocket.h)
(fail) Server > publish() then unsubscribe() from last topic in same tick delivers queued messages
  expect(bReceived).toEqual(["msg0","msg1","msg2","msg3","msg4","done"])
  Received: ["done"]

$ bun bd test test/js/bun/websocket/websocket-server.test.ts -t "publish\(\) then unsubscribe"
(pass) Server > publish() then unsubscribe() from last topic in same tick delivers queued messages

@robobun
robobun requested a review from alii as a code owner June 27, 2026 14:01
@mintlify

mintlify Bot commented Jun 27, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 27, 2026, 2:02 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:01 PM PT - Jun 28th, 2026

❌ @robobun, your commit 6c6b9ab has 3 failures in Build #66477 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32852

That installs a local version of the PR into your bun-32852 executable, so you can run:

bun-32852 --bun

@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9430f824-05c4-40bb-8cda-28fe0b6919d1

📥 Commits

Reviewing files that changed from the base of the PR and between 50cef6e and 6c6b9ab.

📒 Files selected for processing (3)
  • docs/runtime/http/websockets.mdx
  • packages/bun-uws/src/WebSocket.h
  • test/js/bun/websocket/websocket-server.test.ts

Walkthrough

In WebSocket::unsubscribe, a topicTree->drain(subscriber) call is added before freeSubscriber() when removing the last topic subscription, preventing queued publish() messages from being dropped. A regression test and a docs correction for backpressureLimit default (1 MB → 16 MB) accompany the fix.

WebSocket publish/unsubscribe fix

Layer / File(s) Summary
Drain queued messages before freeing subscriber
packages/bun-uws/src/WebSocket.h
Adds topicTree->drain(subscriber) before freeSubscriber() in the last-topic branch of unsubscribe, so publish() messages accepted in the same tick are not dropped.
Regression test
test/js/bun/websocket/websocket-server.test.ts
Adds a test with two clients subscribed to "room"; client B enqueues five publish() calls then immediately unsubscribes, asserting all five messages are delivered to both clients and all publish() return values are 4.
Docs: backpressureLimit default
docs/runtime/http/websockets.mdx
Corrects the inline default comment for websocket.backpressureLimit from 1024 * 1024 (1 MB) to 16 * 1024 * 1024 (16 MB).

Suggested reviewers

  • Jarred-Sumner
  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: preserving queued websocket publishes when unsubscribing from the last topic.
Description check ✅ Passed The description covers what changed and how it was verified, but it does not use the template’s exact section headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bun-types/serve.d.ts`:
- Around line 988-995: Update the JSDoc for server.publish() in the serve.d.ts
declaration so it explicitly says the return value is the queued-byte count for
each current subscriber, not an aggregate total across the topic. Keep the
existing note about 0 meaning no subscribers and the batching/backpressure
behavior, but revise the phrasing near the publish documentation to remove any
ambiguity about total bytes.
- Around line 146-149: Update the `ws.publish*()` return documentation in
`serve.d.ts` so it no longer assumes `publishToSelf: false`; in the
`publish`/`publishText`/related doc blocks, rewrite the `0` return description
in terms of “no subscribed socket will receive the message” rather than “no
other subscribers,” since `publishToSelf: true` can make a sole subscriber
receive a non-zero queued-byte result.

In `@test/js/bun/websocket/websocket-server.test.ts`:
- Around line 423-430: The websocket test helper collect() is resolving too
early for client b by using received.length === 5 instead of waiting for the
sentinel message. Update the logic in collect() so the b path only resolves when
e.data === "done" (while keeping a’s ready flow intact), using the existing
ready and done PromiseWithResolvers hooks to make the assertion deterministic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 71431670-8160-4307-aae5-31fa98309efe

📥 Commits

Reviewing files that changed from the base of the PR and between df92f8f and 50cef6e.

📒 Files selected for processing (4)
  • docs/runtime/http/websockets.mdx
  • packages/bun-types/serve.d.ts
  • packages/bun-uws/src/WebSocket.h
  • test/js/bun/websocket/websocket-server.test.ts

Comment thread packages/bun-types/serve.d.ts Outdated
Comment thread packages/bun-types/serve.d.ts Outdated
Comment thread test/js/bun/websocket/websocket-server.test.ts Outdated
Comment thread packages/bun-types/serve.d.ts Outdated
Comment thread test/js/bun/websocket/websocket-server.test.ts

@claude claude Bot left a comment

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.

Both earlier comments are addressed and I didn't find anything new, but this changes message-delivery semantics in the vendored uWS C++ (WebSocket::unsubscribe now drains before freeing) and touches packages/bun-types/ which has a CODEOWNER, so I'd like a human to sign off.

Extended reasoning...

Overview

Four files: a one-line drain() call inserted before freeSubscriber() in packages/bun-uws/src/WebSocket.h (the actual fix), JSDoc rewrites for the publish* return values in packages/bun-types/serve.d.ts, a one-character doc fix for the backpressureLimit default in docs/runtime/http/websockets.mdx, and a ~70-line regression test in test/js/bun/websocket/websocket-server.test.ts. My two prior inline comments (the inaccurate "or this socket is closed" clause in the JSDoc and the order-dependent test predicate) were both addressed in dff7581 and cd18687, and the bug-hunter pass on the current head found nothing.

Security risks

None apparent. No auth, crypto, or user-controlled-input parsing is touched. The C++ change reuses the existing TopicTree::drain(Subscriber*) path that WebSocket::send() already invokes from the same handler contexts, and drain() no-ops when needsDrainage() is false, so the steady-state cost is a single boolean check.

Level of scrutiny

Medium-high. Although the C++ delta is tiny and follows an established pattern, it is a behavior change in the vendored uWebSockets fork on a hot pub/sub path: messages that were previously silently dropped on last-topic unsubscribe are now flushed synchronously inside unsubscribe(), which subtly shifts delivery ordering relative to the end-of-tick postCb drain. Separately, packages/bun-types/ is CODEOWNER-protected, so the type-doc rewrite should get owner eyes regardless.

Other factors

The PR is well-motivated (clear repro, root cause traced to freeSubscriber unlinking without draining, the other two freeSubscriber call sites analysed), the regression test is deterministic after the per-client isDone predicate fix, and all CodeRabbit/claude inline threads are resolved. CI on cd18687 shows failures in Build #65520 — likely pre-existing flakes given the latest commit only edited a JSDoc string, but worth a glance before merge.

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: rebased onto main after #32889 merged and force-pushed (6c6b9ab486).

#32889 fixed the publish() return-value contract for real (per-subscriber SendStatus plumbed out of uWS), so the serve.d.ts doc changes originally in this PR are superseded and were dropped. What remains here is independent of #32889:

  • the WebSocket::unsubscribe drain fix (messages accepted by publish() were destroyed by a same-tick last-topic unsubscribe; still reproduces on current main)
  • its regression test
  • the backpressureLimit default doc fix (16 MB, not 1 MB)

Re-verified post-rebase: the test fails with origin/main's WebSocket.h (bReceived: ["done"]) and passes with the fix (["msg0".."msg4","done"]).

CI (#66477, final)

284 jobs passed, 2 failed, both on macOS lanes with no overlap with this diff:

  • :darwin: 26 aarch64 - test-bun: died in job setup with buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun', before the runner reached any test file. The same agent (darwin-aarch64-26-5-1-1) failed identically on this branch's earlier builds (#65520, #65611) and on unrelated concurrent builds, so this is fleet infra.
  • :darwin: 14 x64 - test-bun: test/js/bun/terminal/terminal.test.ts > Bun.spawn with terminal option > creates subprocess with terminal attached hit the 90s per-test ceiling. That test spawns a PTY subprocess and never touches Bun.serve or pub/sub; the only code change in this PR is three lines inside uWS WebSocket::unsubscribe, which is unreachable from it.

test/js/bun/websocket/websocket-server.test.ts (the file that exercises the changed code) is not in any failure annotation and passed on every lane that ran it.

The one ci: retrigger this branch is getting has been spent (it hit the same broken darwin-26 agent). Ready for maintainer review; the two darwin lanes need an agent/test retry rather than another push here.

…ubscriber on last-topic unsubscribe

When a ServerWebSocket unsubscribed from its last topic in the same event
loop tick as a publish() that had queued messages for it, those messages
were silently dropped. TopicTree::freeSubscriber unlinks the subscriber
from the drainable list without draining, so any messages that publish()
had already accepted never reached the socket.

Drain the subscriber before freeing it in WebSocket::unsubscribe. The
other two freeSubscriber callers do not need this: end() sends a close
frame via send() which drains first, and the TCP close path has no live
socket left to write to.

Also fix the documented backpressureLimit default in websockets.mdx
(16 MB, matching WebSocketServerContext.rs and serve.d.ts; it said 1 MB).
@robobun
robobun force-pushed the farm/9428174c/ws-pubsub-unsubscribe-drain branch from fef1599 to 6c6b9ab Compare June 28, 2026 18:04
@Jarred-Sumner
Jarred-Sumner merged commit e323e73 into main Jun 29, 2026
77 of 80 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/9428174c/ws-pubsub-unsubscribe-drain branch June 29, 2026 00:19

This branch was successfully deployed

1 active deployment
staging - docs — 6c6b9ab4 Deployed Jun 28, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants