Bun.serve: honor perMessageDeflate compress and decompress set to false or "disable"

Closed
RO
1mo ago
+312-141

Problem

  • Bun.serve websocket perMessageDeflate: { compress, decompress } ignores false and "disable" for one direction. { compress: "disable", decompress: "shared" } still sends ws.send(x, true) compressed (RSV1 set, 320 bytes go out as 8). { compress: "shared", decompress: "disable" } still negotiates the extension and inflates inbound frames. All four mixed configs produce the same handshake as true.
  • The cause is src/runtime/server/WebSocketServerContext.rs:257-301. It ORs both directions into one uWS CompressOptions bitfield. Off contributes 0, and uWS negotiates both directions whenever the field is nonzero. A second bug in the same parser: DECOMPRESS_TABLE maps "3KB" to "256KB" to compressor constants, so decompress: "8KB" sets compressor bits and leaves the inflater shared.

Fix

  • Each direction is parsed on its own (None absent, Some(0) off). compress off leaves the compressor bits zero. WebSocket::send in packages/bun-uws/src/WebSocket.h now drops the compress hint when the context has no compressor bits, so ws.send, ws.publish and server.publish never set RSV1. Inbound frames are still inflated.
  • decompress off turns the extension off. RFC 7692 has no way to accept the extension and refuse inbound compression. compress on with decompress off now throws at Bun.serve() with a message that names the fix, instead of compressing both ways as before.
  • src/uws/lib.rs gains the DEDICATED_DECOMPRESSOR_* constants from PerMessageDeflate.h, and DECOMPRESS_TABLE maps each size to an inflate window. Sizes above 32KB use the 32KB window (the deflate maximum).
  • Verified: test/js/bun/websocket/websocket-server-rsv-frames.test.ts (11 new tests, 7 fail on bun 1.4.3). Also websocket-server.test.ts (perMessageDeflate block), bun-serve-args.test.ts, 20053.test.ts, 29684.test.ts and the bun-types test.

Background

  • CompressOptions packs the compressor in the low byte (windowBits << 4 | memLevel) and the decompressor in bits 8 to 11 (windowBits). SHARED_* is 1 in that byte. DISABLED is 0 for the whole field.
  • RSV1 is the frame bit that marks a message as compressed. send(message, compress) is a hint. uWS already drops it when the client did not offer the extension.
  • { compress: "shared" } alone and {} behave as before: shared both ways, and off. An absent direction defaults to shared when the other is on.
Notes

Self-reviewed: 2 concerns raised, 2 addressed. The first draft turned the extension off for compress on with decompress off. The review found one public project with { compress: "dedicated", decompress: "disable" } that would lose compression silently, and found the DECOMPRESS_TABLE hole that let decompress: "8KB" defeat the compressor-bits check. This version throws for the first and fixes the table for the second.

Shapes considered for compress on with decompress off: negotiate and fail inbound RSV1 frames (breaks browsers, which compress whenever the extension is negotiated), negotiate and inflate anyway (keeps the option a no-op), turn the extension off (silent loss of compression), or throw. The throw is loud and the message says what to do.

Repro on bun 1.4.2 and main: a server with perMessageDeflate: JSON.parse(process.argv[2]) and message(ws) { ws.send("x".repeat(320), true) }, a raw client that offers permessage-deflate; client_max_window_bits and sends one deflated RSV1 text frame. For every mixed config the reply frame head is 0xc1 and the frame is 8 bytes, and the 101 carries permessage-deflate; client_no_context_takeover; server_no_context_takeover.

websocket-server.test.ts as a whole has tests that time out in this container on main too (the (benchmark) test starves its neighbors on a debug build). They do not use compression.

#41629 changes HttpResponse.h and WebSocketExtensions.h (dedicated compressor selection and RFC 7692 negotiation). This PR does not touch those files. The client_max_window_bits=13 assertion for decompress: "8KB" relies on the current client_max_window_bits handling, which that PR keeps.