Version
v22.18.0
Platform
Microsoft Windows NT 10.0.26200.0 x64 (also expected on any Windows version; not reproducible on Linux/macOS, where the same code path uses an AF_UNIX socket).
Subsystem
http2, libuv (uv_pipe_t on Windows)
What steps will reproduce the bug?
Two short scripts. The server accepts a connection on a Windows named pipe, reads a newline-terminated preamble via socket.on('data'), replays any over-read bytes with socket.unshift(), then adopts the socket into an http2.createServer() via server.emit('connection', socket). The client connects to the same pipe, writes a preamble, then uses http2.connect() with createConnection to send a single GET.
server.js
const http2 = require('http2');
const net = require('net');
const useTcp = process.argv.includes('--tcp');
const PIPE = '\\\\.\\pipe\\node-h2-repro';
const TCP_PORT = 51234;
const h2 = http2.createServer();
h2.on('stream', (s) => { s.respond({ ':status': 200 }); s.end('hi'); });
h2.on('sessionError', (e) => console.error('h2 sessionError:', e));
const srv = net.createServer((sock) => {
let buf = '';
sock.on('data', function onData(chunk) {
buf += chunk.toString('utf8');
const nl = buf.indexOf('\n');
if (nl < 0) return;
sock.removeListener('data', onData);
const rest = Buffer.from(buf.slice(nl + 1));
if (rest.length) sock.unshift(rest);
h2.emit('connection', sock);
});
});
srv.on('error', (e) => console.error('listen error:', e));
if (useTcp) {
srv.listen(TCP_PORT, '127.0.0.1', () => console.log('listening on 127.0.0.1:' + TCP_PORT));
} else {
srv.listen(PIPE, () => console.log('listening on', PIPE));
}
client.js
const http2 = require('http2');
const net = require('net');
const useTcp = process.argv.includes('--tcp');
const PIPE = '\\\\.\\pipe\\node-h2-repro';
const TCP_PORT = 51234;
const sock = useTcp ? net.connect(TCP_PORT, '127.0.0.1') : net.connect(PIPE);
sock.write('hello\n');
const client = http2.connect('http://localhost', { createConnection: () => sock });
client.on('error', (e) => console.error('client error:', e));
const req = client.request({ ':path': '/' });
req.on('response', (h) => console.log('response status:', h[':status']));
req.on('end', () => { console.log('done'); client.close(); });
req.resume();
req.end();
Run:
# crashes:
node server.js # in one terminal
node client.js # in another
# works (proves transport-specificity):
node server.js --tcp
node client.js --tcp
How often does it reproduce? Is there a required condition?
100% deterministic. The bug requires all three conditions simultaneously:
- Transport is a Windows named pipe (
uv_pipe_t on Win32). With 127.0.0.1 TCP loopback the same code works.
- A JS-level read consumes bytes from the socket before it is adopted into http2 (
socket.on('data', …)).
- Any over-read bytes are replayed via
socket.unshift(rest) before http2.emit('connection', socket).
If any one of (1), (2), or (3) is removed, the assertion does not fire and the request completes normally.
What is the expected behavior? Why is that the expected behavior?
The client request should complete with HTTP 200 and the body hi, exactly as it does over TCP loopback. The same script works on Linux/macOS without modification (where Node's pipe transport is an AF_UNIX socket).
What do you see instead?
The server process aborts via SIGABRT (Windows exit code 134) the moment http2 begins writing the SETTINGS frame:
# C:\Program Files\nodejs\node.exe[16080]: void __cdecl node::http2::Http2Session::OnStreamAfterWrite(class node::WriteWrap *,int) at c:\ws\src\node_http2.cc:1774
# Assertion failed: is_write_in_progress()
----- Native stack trace -----
1: 00007FF6BAE81427 node::SetCppgcReference+16599
2: 00007FF6BADE2691 v8::base::CPU::num_virtual_address_bits+94609
3: 00007FF6BADA9F87 v8_inspector::protocol::Binary::operator=+268327
4: 00007FF6BAC7777D RSA_meth_get_flags+19549
5: 00007FF6BAC723D4 v8::CTypeInfoBuilder<__int64>::Build+30324
6: 00007FF6BAC73491 RSA_meth_get_flags+2417
7: 00007FF6BAEE61C0 uv_thread_setname+13616
8: 00007FF6BAEF3F93 uv_run+595
9: 00007FF6BAEC3E25 node::SpinEventLoop+405
...
The crash bypasses any JavaScript-level handler (no uncaughtException, process.on('exit'), etc.).
Additional information
- The assertion is in the http2 write-completion path: an "after write" callback fires while
Http2Session's internal write tracking believes no write is in progress.
- This is reminiscent of, but distinct from, dotnet/runtime#73097 (.NET's HTTP/2-over-named-pipe deadlock). Both point to subtle differences in Windows named-pipe semantics versus TCP that HTTP/2 implementations are sensitive to.
- This was originally encountered in a real-world IPC scenario: a VS Code extension and a daemon talking over a named pipe, where the daemon sends a short
clientId\n preamble for connection authentication before HTTP/2 takes over. The workaround was to switch the transport to 127.0.0.1 ephemeral-port TCP, which avoided the assertion entirely.
- The preamble +
socket.unshift() pattern is the same one used by upgrade-style protocols (e.g., HTTP/1.1 Upgrade, multi-protocol routers), so this is a plausible failure mode beyond the original scenario.
Version
v22.18.0
Platform
Subsystem
http2, libuv (uv_pipe_t on Windows)
What steps will reproduce the bug?
Two short scripts. The server accepts a connection on a Windows named pipe, reads a newline-terminated preamble via
socket.on('data'), replays any over-read bytes withsocket.unshift(), then adopts the socket into anhttp2.createServer()viaserver.emit('connection', socket). The client connects to the same pipe, writes a preamble, then useshttp2.connect()withcreateConnectionto send a single GET.server.jsclient.jsRun:
How often does it reproduce? Is there a required condition?
100% deterministic. The bug requires all three conditions simultaneously:
uv_pipe_ton Win32). With127.0.0.1TCP loopback the same code works.socket.on('data', …)).socket.unshift(rest)beforehttp2.emit('connection', socket).If any one of (1), (2), or (3) is removed, the assertion does not fire and the request completes normally.
What is the expected behavior? Why is that the expected behavior?
The client request should complete with HTTP 200 and the body
hi, exactly as it does over TCP loopback. The same script works on Linux/macOS without modification (where Node's pipe transport is an AF_UNIX socket).What do you see instead?
The server process aborts via SIGABRT (Windows exit code
134) the moment http2 begins writing the SETTINGS frame:The crash bypasses any JavaScript-level handler (no
uncaughtException,process.on('exit'), etc.).Additional information
Http2Session's internal write tracking believes no write is in progress.clientId\npreamble for connection authentication before HTTP/2 takes over. The workaround was to switch the transport to127.0.0.1ephemeral-port TCP, which avoided the assertion entirely.socket.unshift()pattern is the same one used by upgrade-style protocols (e.g., HTTP/1.1 Upgrade, multi-protocol routers), so this is a plausible failure mode beyond the original scenario.