fix(server): serialize async websocket messages

Closed
OU
1mo ago
+344-8

Fix #32483

Summary

  • pause the underlying uWS websocket while an async websocket.message handler is pending, so two messages on the same socket cannot run their handlers concurrently
  • resume reads when the handler promise settles — on rejection as well as resolution
  • route rejected async message handlers through the existing websocket.error callback
  • skip the pause() when the handler closed or terminated the socket synchronously before suspending (the socket is already gone; AnyWebSocket::pause() would deref it)
  • bump promiseFunctionsSize to cover the two new PromiseFunctions entries, with a static_assert so the array can never again be sized below the highest enum index

Testing

Serialization regression:

USE_SYSTEM_BUN=1 bun test test/js/bun/http/bun-server.test.ts -t "async websocket message handlers run serially per socket"

Fails on released Bun with maxInFlightHandlers: 2 and secondStartedBeforeFirstSettled: true.

Reject-path regression:

USE_SYSTEM_BUN=1 bun test test/js/bun/http/bun-server.test.ts -t "rejected async websocket message handler"

Fails on released Bun — the rejection escapes to the uncaught path on stderr instead of reaching websocket.error, and the socket never resumes, so the follow-up message is never processed:

- "echoed": "done:after-reject",        + "echoed": null,
- "errorMessage": "async websocket ...  + "errorMessage": null,
+ stderr: "error: async websocket handler rejected"
 0 pass  1 fail

Both pass with the fix:

bun bd test test/js/bun/http/bun-server.test.ts -t "async websocket"
# 2 pass  0 fail  4 expect() calls

Full file plus bun test test/internal/source-lints/ are green, with one pre-existing exception: Server > normlizes incoming request URLs (an HTTPS fetch test unrelated to this diff) takes 4.42s standalone against the 5s default timeout and tips over under suite load in a debug build. It passes in isolation on the same binary.

Notes

The is_closed() guard on pause() has no dedicated test: the unguarded path does not fault deterministically in a non-ASAN build, and a "does not crash" test would never fail in CI. close() and terminate() both set flags.closed before touching uWS, so is_closed() is accurate at that point; the guard matches what on_drain and on_message_promise_resolve already do for their uWS calls.