Skip to content

docs: a paused client WebSocket does not answer pings or see the close until resume() - #42976

Open
robobun wants to merge 1 commit into
mainfrom
robobun/1eda22f1/docs-websocket-pause-keepalive
Open

robobun wants to merge 1 commit into
mainfrom
robobun/1eda22f1/docs-websocket-pause-keepalive

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A client WebSocket that stays pause()d longer than the server's idle timeout is dropped, and gets no close event until resume(). With idleTimeout: 8 the server closes at 8.0 s (1006, "WebSocket timed out from inactivity"). The client reads readyState 1 until resume() at 24.0 s, then gets close 1006 "Connection ended".
  • This is what pause() means. The client stops reading, so it sees no Ping, Close frame or FIN. uSockets holds a paused socket's EOF until resume() on purpose (packages/bun-usockets/src/loop.c:849), to keep unread data. The ws package on Node v26.3.0 does the same.
  • The pause() JSDoc and the "Backpressure" docs section do not say so. The docs example pauses with no bound.

Fix

  • docs/runtime/http/websockets.mdx: say what a paused client does not do, name the Bun.serve idle timeout, and show a pong() heartbeat for a long pause.
  • packages/bun-types/bun.d.ts: the same facts in the pause() JSDoc.
  • No runtime change. To surface the peer's close while paused is a behavior decision. This PR does not make it.
  • Verified: I ran every statement and the snippet on 1.4.3 (Linux) and 1.4.3-canary.1+a8e4e9042 (Windows). The runs are in Notes.

Background

  • us_socket_pause removes the readable interest from the socket's poll. Writes still work.
  • Bun.serve resets a WebSocket's idle timer on every frame it receives (packages/bun-uws/src/WebSocketContext.h:307). With sendPings: true it sends a Ping before the timeout, and the Pong resets the timer.
  • RFC 6455 section 5.5.3 allows a Pong that answers no Ping, as a one-way heartbeat.
Notes

Repro (from the report), 1.4.3 on Linux x64. Server: Bun.serve with websocket.idleTimeout: 8, sends "hello" on open. Client: pause() in onmessage, probes readyState every 4 s, resume() at 24 s.

0.0s client pause() -> true
8.0s SERVER close handler: 1006 "WebSocket timed out from inactivity"
4.0s to 24.0s, six probes: client readyState 1 isPaused true bufferedAmount 0
24.0s client resume() -> true
24.0s CLIENT close event: 1006 "Connection ended" isPaused false

Node parity. ws 8.18.3 on Node v26.3.0, client ws.pause() on the first message, server pings at 1 s and 2 s and calls terminate() at 3 s:

0.0s client pause(), isPaused true
3.0s SERVER terminate (no pong), pongs = 0
3.0s SERVER close: 1006
4.5s client readyState 1 isPaused true
6.0s client resume()
6.0s CLIENT close event: 1006 "" isPaused false

Statements in the new text, each run.

Statement Run
A paused client does not see a Close frame Server ws.close(1000, "bye") while the client is paused: client readyState 1 at 2.0 s, close 1000 "bye" right after resume(). Same over wss://, and on Windows.
A paused client does not see the end of the connection Server ws.terminate(): client readyState 1 at 2.0 s, close 1006 right after resume().
send(), ping(), pong() work while paused The server's message, ping and pong handlers fire at 0.0 s for a paused client. Run over ws://, wss://, wss:// through an http:// and an https:// CONNECT proxy, ws:// through an https:// proxy, and on Windows.
Bun.serve counts every frame as activity idleTimeout: 8, paused client calls pong() every 3 s: no close on either side through 21 s, and the connection still works after resume(). Without the pong() the server closes at 8.0 s.
pong() on a closed socket does not throw pong(), ping() and send() return normally in CLOSING and CLOSED, so the timer in the snippet is safe if the socket closes first.

The snippet, as written. A Writable that drains after 60 s stands in for file. idleTimeout: 40, heartbeat 30_000:

60.0s client resumed, readyState 1
60.0s client got ack after the pause: the connection survived

The first docs example (no heartbeat), same setup:

40.0s SERVER close handler: 1006 "WebSocket timed out from inactivity"
60.0s client resumed, readyState 1
60.0s CLIENT close event: 1006 "Connection ended"

Why close "can wait" and not "waits". A connection reset is reported at once, also while paused. A send() into a connection that the server already closed gets an RST back, and the client then dispatches close 1006 without a resume() (seen at 12.0 s in a run where the server closed at 8.0 s).

Types test. test/integration/bun-types/bun-types.test.ts fails 10 of 21 tests, with the same diagnostics, with and without this change. The bun-types CI job on this PR fails the same way, and so do its last 25 runs on six unrelated branches. The diagnostics are missing @types/node exports (for example TextEncoderEncodeIntoResult, TLSSocket, KeyObject). #42230 tracks that break. An earlier version of this note said my environment could not install the fixture dependencies. That cause was wrong. This change cannot affect the job: bun.d.ts at main and at this commit print identically with comments removed (TypeScript 6.0.2, 0 parse diagnostics).

The RFC link resolves (HTTP 200) and the page has the section-5.5.3 anchor.

…e until resume()

State in the Backpressure section and in the pause() JSDoc that a paused
client reads nothing: it does not answer Ping frames and does not see a
Close frame or the end of the connection. Name the Bun.serve idle
timeout, and show a pong() heartbeat that keeps a long pause alive.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 4 days. After that, they cost $0.25 per reviewed file.

Or wait 9 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 048e5c4a-e25a-4d76-9d0b-bcfda2d1ab9a

📥 Commits

Reviewing files that changed from the base of the PR and between b8eacea and 3d835cc.

📒 Files selected for processing (2)
  • docs/runtime/http/websockets.mdx
  • packages/bun-types/bun.d.ts

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

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: docs change, ready for review.

How I reproduced the report, on 1.4.3 (Linux x64) and 1.4.3-canary.1+a8e4e9042 (Windows x64):

  • Server: Bun.serve with websocket.idleTimeout: 8. It sends "hello" on open.
  • Client: new WebSocket(...), calls pause() in onmessage, reads readyState every few seconds, calls resume() at the end.
  • Result: the server's close handler fires at 8.0 s with 1006 "WebSocket timed out from inactivity". The client reads readyState 1 until resume(), then gets close 1006 "Connection ended".
  • Control: the ws package on Node v26.3.0 gives the same result for a paused client (0 Pongs while paused, close 1006 only at resume()).
  • With a pong() from the paused client every 3 s, neither side closes, and the connection works after resume().

This PR changes docs/runtime/http/websockets.mdx and the pause() JSDoc in packages/bun-types/bun.d.ts. It does not change runtime behavior.

CI on 3d835cc: no failure comes from this diff, which changes no compiled input.

  • Buildkite #116724: test/js/bun/spawn/spawn.test.ts failed 3 of 3 attempts on debian 13 x64-asan ("an idle reader stopped at the highwater mark does not keep the process alive"). Reported for main-break triage. bun-lock.test.ts, process.test.js and test/js/bun/css/color.test.ts (darwin x64) each passed on retry. The build is final: that ASAN job is its only failed job.
  • The bun-types GitHub Actions job fails the same way on unrelated branches. bun-types test: check against the newest @types/node release, not the latest dist-tag #42230 tracks it.

@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.

Code review found no issues

No high-confidence issues detected in this change.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:13 PM PT - Sep 16th, 2026

❌ @robobun, your commit 3d835cc has 1 failures in Build #116724 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42976

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

bun-42976 --bun

robobun added a commit that referenced this pull request Sep 16, 2026
pause() returned false for a client that is flushing a Close frame, but
isPaused read true although the socket reads. The docs changes are
dropped from this branch: #42976 covers the docs for a paused client and
#42978 covers isPaused on a socket with no connection.

This branch has not been deployed

No deployments
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