Repository navigation
websocket: permessage-deflate delivers empty messages after a BFINAL block #66833
Description
Version
v27.0.0-pre (f88a1d25e9dc48a7f838280b236c4d74f76231a3)
The same reproducer prints lengths 15,0,0 on the installed v22.23.2 binary.
Platform
Linux SAGAN-10 6.18.40.1-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Fri Jul 31 22:12:15 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
Subsystem
websocket
What steps will reproduce the bug?
Run this standalone script. The raw server completes a WebSocket handshake with permessage-deflate and sends three compressed text frames. Each payload is one BFINAL=1 raw-deflate block plus the non-final empty-block header octet from RFC 7692 §7.2.3.4. The client is the global WebSocket, which offers permessage-deflate; client_max_window_bits on its own.
'use strict';
const net = require('node:net');
const crypto = require('node:crypto');
const zlib = require('node:zlib');
// RFC 7692 §7.2.3.4: one BFINAL=1 block, then a non-final empty-block header.
function bfinalPayload(text) {
return Buffer.concat([zlib.deflateRawSync(text), Buffer.from([0x00])]);
}
function dataFrame(payload) {
return Buffer.concat([Buffer.from([0xc1, payload.length]), payload]);
}
const messages = ['hello from msg1', 'world from msg2', 'third message here'];
const server = net.createServer((socket) => {
socket.on('error', (error) => console.log('socket error', error.message));
let buffer = Buffer.alloc(0);
socket.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
const headerEnd = buffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return;
const request = buffer.toString('latin1', 0, headerEnd);
const key = request.match(/sec-websocket-key:\s*(.+)/i)[1].trim();
const accept = crypto.createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n` +
'Sec-WebSocket-Extensions: permessage-deflate\r\n' +
'\r\n'
);
setTimeout(() => {
for (const text of messages) socket.write(dataFrame(bfinalPayload(text)));
socket.write(Buffer.from([0x88, 0x02, 0x03, 0xe8]));
setTimeout(() => socket.end(), 50);
}, 50);
});
});
server.listen(0, '127.0.0.1', () => {
const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}`);
const got = [];
ws.addEventListener('message', (event) => {
const text = String(event.data);
got.push(text);
console.log('message', JSON.stringify(text));
});
ws.addEventListener('close', (event) => {
console.log('lengths', got.map((text) => text.length).join(','));
console.log('close', event.code);
server.close();
});
ws.addEventListener('error', (event) => {
console.log('error', event.message || event.error || event);
});
});How often does it reproduce? Is there a required condition?
Every time a peer sends a permessage-deflate message that contains a DEFLATE block with BFINAL set to 1. RFC 7692 §7.2.1 allows that block, and §7.2.3.4 shows it as the flush method for implementations that cannot emit an empty non-final block.
The extension parameter is not the trigger. The same three frames with Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover also produce lengths 15,0,0.
Peers that flush with Z_SYNC_FLUSH are unaffected. Replacing bfinalPayload with a sync-flush payload (raw deflate, finishFlush: zlib.constants.Z_SYNC_FLUSH, last four 00 00 ff ff bytes removed) prints all three messages on both binaries above:
message "hello from msg1"
message "world from msg2"
message "third message here"
lengths 15,15,18
close 1000
One BFINAL message is enough to blank every later message on that connection, including a later sync-flush message. WebSocket offers the extension on every connection (deps/undici/src/lib/web/websocket/connection.js).
What is the expected behavior? Why is that the expected behavior?
Each compressed message should decode to the text the server sent:
message "hello from msg1"
message "world from msg2"
message "third message here"
lengths 15,15,18
close 1000
RFC 7692 §7.2.2 decompresses each message by appending 00 00 ff ff and inflating the result. A block with BFINAL set is a legal way to end that message's deflate data. The following message is a new deflate sequence, so the inflater has to accept it. A failed inflate should surface as a WebSocket error, not as a successful empty message.
What do you see instead?
The first message is delivered. Every later message is an empty string, with no error event:
message "hello from msg1"
message ""
message ""
lengths 15,0,0
close 1000
Additional information
PerMessageDeflate.decompress() keeps a single createInflateRaw({ windowBits: 15 }) for the connection (deps/undici/src/lib/web/websocket/permessage-deflate.js). On a final frame it appends the RFC tail and flushes. After a BFINAL=1 block, zlib is in DONE and later inflate() calls return Z_STREAM_END without consuming the new input.
In lib/zlib.js, availInAfter > 0 with the default _rejectGarbageAfterEnd === false executes push(null) and completes the chunk without error. The flush callback then concatenates an empty buffer and calls callback(null, full). The receiver delivers that as a normal message.
serverNoContextTakeover is stored in the constructor and never read. Resetting on that flag would not, by itself, decide this case: the failure also happens when the server accepts plain permessage-deflate. The inflater has to be recreated, or otherwise taken out of DONE, once a message has ended the deflate stream and before the next message is written.
Activity