Skip to content

Commit aa4680a

Browse files
committed
quic: do not destroy incoming streams that have a consumer
An incoming stream was destroyed unless the session had an onstream callback, even when session-level stream callbacks (onheaders et al) were registered and the negotiated application (HTTP/3) would drive the stream through them. Users had to register stub onstream handlers just to keep their streams alive. Destroy an incoming stream only when the session has no consumer for it at all: no onstream callback, and no session-level stream callbacks runnable on the negotiated application (checked via the existing headersSupported session state, computed when the application is selected from ALPN). Sessions with no consumers keep the current destroy-and-warn behavior so unconsumed streams cannot accumulate and hold flow control credit. On HTTP/3 sessions only bidirectional request streams reach this path; control and QPACK streams are consumed internally by nghttp3 and are never exposed to JavaScript. Fixes: #64192 Signed-off-by: Naman Trivedi <trivenay@amazon.com>
1 parent ad7a5b8 commit aa4680a

2 files changed

Lines changed: 159 additions & 5 deletions

File tree

lib/internal/quic/quic.js

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4129,6 +4129,19 @@ class QuicSession {
41294129
this.#inner.verifyPeer = value;
41304130
}
41314131

4132+
/**
4133+
* True if an incoming stream has a consumer registered on this session:
4134+
* either an onstream callback, or - when the negotiated application
4135+
* supports headers (e.g. HTTP/3) - session-level stream callbacks that
4136+
* the application layer will invoke (onheaders et al).
4137+
* @returns {boolean}
4138+
*/
4139+
#hasStreamConsumer() {
4140+
if (typeof this.#inner.onstream === 'function') return true;
4141+
if (this[kStreamCallbacks] == null) return false;
4142+
return getQuicSessionState(this).headersSupported === 1;
4143+
}
4144+
41324145
/**
41334146
* @param {object} handle
41344147
* @param {number} direction
@@ -4141,10 +4154,13 @@ class QuicSession {
41414154
// Set the default byte budget for received streams.
41424155
stream.budget = kDefaultBudget;
41434156

4144-
// A new stream was received. If we don't have an onstream callback, then
4145-
// there's nothing we can do about it. Destroy the stream in this case.
4146-
if (typeof inner.onstream !== 'function') {
4147-
process.emitWarning('A new stream was received but no onstream callback was provided');
4157+
// A new stream was received. If the session has no consumer for it -
4158+
// neither an onstream callback nor, on a session whose application
4159+
// supports headers (e.g. HTTP/3), any session-level stream callbacks -
4160+
// there's nothing that could ever read it. Destroy the stream in this
4161+
// case rather than letting it hold flow control credit.
4162+
if (!this.#hasStreamConsumer()) {
4163+
process.emitWarning('A new stream was received but no stream consumer callback was provided');
41484164
stream.destroy();
41494165
return;
41504166
}
@@ -4175,7 +4191,14 @@ class QuicSession {
41754191
});
41764192
}
41774193

4178-
safeCallbackInvoke(inner.onstream, this, stream);
4194+
// Deliver the stream to the onstream consumer if one is registered.
4195+
// Reaching this point without one means #hasStreamConsumer accepted
4196+
// the stream on behalf of the application layer: the session-level
4197+
// stream callbacks were applied above and the application (e.g.
4198+
// HTTP/3) drives the stream, so there is nothing to invoke here.
4199+
if (typeof inner.onstream === 'function') {
4200+
safeCallbackInvoke(inner.onstream, this, stream);
4201+
}
41794202
}
41804203

41814204
[kRemoveStream](stream) {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Flags: --experimental-quic --experimental-stream-iter --no-warnings
2+
3+
// Test: incoming stream consumer checks.
4+
// An incoming stream must not be destroyed just because `onstream` is
5+
// not set: on a session whose application supports headers (HTTP/3),
6+
// session-level stream callbacks (`onheaders` et al) are a consumer
7+
// and the stream must be kept and driven by the application layer.
8+
// Refs: https://github.com/nodejs/node/issues/64192
9+
//
10+
// A session with no stream consumers at all still destroys incoming
11+
// streams (and emits a warning), so unconsumed streams cannot
12+
// accumulate and hold flow control credit.
13+
14+
import { hasQuic, skip, mustCall } from '../common/index.mjs';
15+
import assert from 'node:assert';
16+
import * as fixtures from '../common/fixtures.mjs';
17+
18+
if (!hasQuic) {
19+
skip('QUIC is not enabled');
20+
}
21+
22+
const { listen, connect } = await import('node:quic');
23+
const { createPrivateKey } = await import('node:crypto');
24+
const { text } = await import('stream/iter');
25+
26+
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
27+
const cert = fixtures.readKey('agent1-cert.pem');
28+
29+
// The consumer warning must never fire in the first block (onheaders is
30+
// a consumer) and must fire in the second (no runnable consumer).
31+
// common.expectWarning is not usable here: importing node:quic emits
32+
// ExperimentalWarning, which it would reject as unexpected.
33+
const kWarning =
34+
'A new stream was received but no stream consumer callback was provided';
35+
function failOnConsumerWarning(warning) {
36+
assert.notStrictEqual(warning.message, kWarning);
37+
}
38+
39+
// --- An h3 request completes with only session-level stream callbacks ---
40+
{
41+
process.on('warning', failOnConsumerWarning);
42+
const serverDone = Promise.withResolvers();
43+
44+
// Note: no `onstream` callback anywhere on this session.
45+
const serverEndpoint = await listen(mustCall((serverSession) => {
46+
serverSession.onerror = () => {};
47+
}), {
48+
sni: { '*': { keys: [key], certs: [cert] } },
49+
onheaders: mustCall(function(headers) {
50+
assert.strictEqual(headers[':path'], '/test');
51+
this.sendHeaders({
52+
':status': '200',
53+
'content-type': 'text/plain',
54+
});
55+
const w = this.writer;
56+
w.writeSync('kept without onstream');
57+
w.endSync();
58+
serverDone.resolve();
59+
}),
60+
});
61+
62+
const clientSession = await connect(serverEndpoint.address, {
63+
servername: 'localhost',
64+
verifyPeer: 'manual',
65+
});
66+
await clientSession.opened;
67+
68+
const headersReceived = Promise.withResolvers();
69+
const stream = await clientSession.createBidirectionalStream({
70+
headers: {
71+
':method': 'GET',
72+
':path': '/test',
73+
':scheme': 'https',
74+
':authority': 'localhost',
75+
},
76+
onheaders: mustCall((headers) => {
77+
assert.strictEqual(headers[':status'], 200);
78+
headersReceived.resolve();
79+
}),
80+
});
81+
82+
await headersReceived.promise;
83+
const body = await text(stream);
84+
assert.strictEqual(body, 'kept without onstream');
85+
86+
await serverDone.promise;
87+
await clientSession.close();
88+
await serverEndpoint.close();
89+
process.off('warning', failOnConsumerWarning);
90+
}
91+
92+
// --- Stream callbacks that cannot run are not a consumer ---
93+
// On a session whose negotiated application does not support headers,
94+
// registered session-level stream callbacks can never fire, so an
95+
// incoming stream with no onstream callback is destroyed with the
96+
// warning. The h3 block above must not trigger that warning.
97+
{
98+
// Awaiting warned.promise is the assertion: the test times out if the
99+
// warning never fires.
100+
const warned = Promise.withResolvers();
101+
process.on('warning', function onWarning(warning) {
102+
if (warning.message === kWarning) {
103+
process.off('warning', onWarning);
104+
warned.resolve();
105+
}
106+
});
107+
108+
// The onheaders callback is registered but the ALPN is not h3,
109+
// so it can never run.
110+
const serverEndpoint = await listen(mustCall((serverSession) => {
111+
serverSession.onerror = () => {};
112+
}), {
113+
sni: { '*': { keys: [key], certs: [cert] } },
114+
alpn: ['test-proto'],
115+
onheaders: () => {},
116+
});
117+
118+
const clientSession = await connect(serverEndpoint.address, {
119+
servername: 'localhost',
120+
alpn: 'test-proto',
121+
verifyPeer: 'manual',
122+
});
123+
await clientSession.opened;
124+
125+
const stream = await clientSession.createUnidirectionalStream();
126+
stream.writer.writeSync('x');
127+
128+
await warned.promise;
129+
await clientSession.close();
130+
await serverEndpoint.close();
131+
}

0 commit comments

Comments
 (0)