Skip to content

quic: session.close()/destroy() send RESET_STREAM for every open stream before CONNECTION_CLOSE #67278

Open

Description

@zshenker

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 with last_error_.
  • src/quic/streams.cc, Stream::Destroy(): EndWritable() leads to ngtcp2_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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions