Repository navigation
Description
Version
v26.8.1 (built with --experimental-quic). The same code is on main as of 2026-11-02.
Platform
Linux 6.8.0-117-generic aarch64 (Debian trixie container), OpenSSL 3.5.7, ngtcp2 1.25.0
Subsystem
quic
What steps will reproduce the bug?
Open a stream, leave it open, and close the session with an application code of 0, using session.destroy() or session.close(). The standalone script below logs the frames the client sends (from its qlog) and what the server sees. Run it with a self-signed key.pem/cert.pem in the current directory:
// node --experimental-quic repro.mjs [destroy|close]
import { listen, connect } from 'node:quic';
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
const mode = process.argv[2] ?? 'destroy';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
const t0 = performance.now();
const log = (...a) => console.log(`${(performance.now() - t0).toFixed(1)}ms`, ...a);
const endpoint = await listen((session) => {
session.closed.then(() => log('server: session closed'), (e) => log('server: session closed with error', e.message));
}, {
endpoint: { address: '127.0.0.1:0' },
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['repro'],
onstream: async (stream) => {
stream.closed.catch(() => {});
stream.onreset = (e) => log('server: stream reset by peer', e?.message ?? '');
try {
for await (const chunk of stream) log('server: read', chunk.length, 'bytes');
} catch (e) { log('server: stream read failed', e.message); }
},
});
const client = await connect(`127.0.0.1:${endpoint.address.port}`, {
alpn: 'repro', servername: 'localhost', verifyPeer: 'manual', rejectUnauthorized: false,
qlog: true,
onqlog: (data) => {
for (const m of String(data).matchAll(/"frame_type":"(reset_stream|connection_close)"[^}]*/g)) log('client sent:', m[0]);
},
});
await client.opened;
// A long-lived stream that is still open when the application closes the session.
const stream = await client.createUnidirectionalStream();
stream.writer.write(new Uint8Array([1, 2, 3]));
await new Promise((r) => setTimeout(r, 200));
log(`client: session.${mode}() with application code 0`);
if (mode === 'destroy') client.destroy(undefined, { code: 0, type: 'application' });
else await client.close({ code: 0, type: 'application' });
await new Promise((r) => setTimeout(r, 500));
process.exit(0);How often does it reproduce? Is there a required condition?
Every time, as long as a stream is still open when the session is closed.
What is the expected behavior? Why is that the expected behavior?
The client sends a CONNECTION_CLOSE carrying the application code, and nothing else. RFC 9000 §10.2 says an immediate close puts the connection into the closing state, and all of its streams end with it. A RESET_STREAM for each open stream is not required, and on destroy() its error code (1) does not match the close code the application passed (0).
What do you see instead?
Both methods send a RESET_STREAM for every open stream before the CONNECTION_CLOSE:
$ node --experimental-quic repro.mjs destroy
212.6ms client: session.destroy() with application code 0
213.2ms client sent: "frame_type":"reset_stream","stream_id":2,"error_code":1,"final_size":3
213.4ms client sent: "frame_type":"connection_close","error_space":"application","error_code":0,"raw_error_code":0
213.7ms server: stream reset by peer QUIC application error 1
301.2ms server: session closed
$ node --experimental-quic repro.mjs close
209.1ms client: session.close() with application code 0
209.5ms client sent: "frame_type":"reset_stream","stream_id":2,"error_code":0,"final_size":3
209.8ms server: stream reset by peer
210.2ms client sent: "frame_type":"connection_close","error_space":"application","error_code":0,"raw_error_code":0
295.1ms server: session closed
The server's stream handler sees the reset before the session close. Against a non-Node peer, a packet capture shows the RESET_STREAM in its own packet, ahead of the CONNECTION_CLOSE.
Additional information
This breaks protocols that keep a stream open for the whole connection and treat its reset as a protocol error. One example is the MoQT control stream (draft-ietf-moq-transport: a peer that sees the control stream closed must close the session with a protocol error). A MoQT client on node:quic cannot close a session cleanly. The peer processes the reset first and logs a protocol violation in place of the client's close code 0. The client can't work around it from JS, because the reset is sent during native teardown, after the application calls close()/destroy().
Where it happens:
lib/internal/quic/quic.js,QuicSession.destroy(): "First, forcefully and immediately destroy all open streams".src/quic/session.cc: on finishing a close, every stream is destroyed withlast_error_.src/quic/streams.cc,Stream::Destroy():EndWritable()leads tongtcp2_conn_shutdown_stream_write(), which queues the RESET_STREAM.
Possible fix: when the session is closing with a CONNECTION_CLOSE (an application or transport close, not an idle or stateless close), tear streams down locally without calling ngtcp2_conn_shutdown_stream_*, and just emit the CONNECTION_CLOSE. If per-stream resets are kept, destroy() should at least use the close's own application code rather than the internal error code.
Activity