Skip to content

Commit 4882960

Browse files
committed
quic: resolve various small further issues & omissions in HTTP/3 API
1 parent 9d175ef commit 4882960

12 files changed

Lines changed: 187 additions & 21 deletions

File tree

‎doc/api/quic.md‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2392,10 +2392,11 @@ added: REPLACEME
23922392

23932393
Attaches HTTP/3 to an existing `node:quic` session.
23942394

2395-
The HTTP/3 session must be attached before the QUIC session becomes active and
2396-
begins emitting events: for servers that means synchronously inside a server's
2397-
`onsession` callback, or for clients before the handshake completes. Throws
2398-
`ERR_INVALID_STATE` if the QUIC session is already attached or active.
2395+
The HTTP/3 session must be attached before any streams are created locally, and
2396+
before the QUIC session becomes active and begins emitting events. For servers
2397+
that means synchronously inside a server's `onsession` callback, or for clients
2398+
before the handshake completes. Throws `ERR_INVALID_STATE` if the QUIC session
2399+
is already attached, already active, or already has streams.
23992400

24002401
### `http3session.request(headers[, options])`
24012402

@@ -2766,6 +2767,16 @@ added: REPLACEME
27662767

27672768
The session that owns this stream. Read only.
27682769

2770+
### `http3stream.stream`
2771+
2772+
<!-- YAML
2773+
added: REPLACEME
2774+
-->
2775+
2776+
* Type: {quic.QuicStream}
2777+
2778+
The underlying QUIC stream. Read only.
2779+
27692780
### `http3stream.onheaders`
27702781

27712782
<!-- YAML
@@ -3161,7 +3172,7 @@ added:
31613172

31623173
The ALPN (Application-Layer Protocol Negotiation) identifier(s). This option is
31633174
**required**: `node:quic` is transport-only and assumes no application protocol,
3164-
so a session created without an ALPN is rejected with an `ERR_INVALID_ARG_VALUE`
3175+
so a session created without an ALPN is rejected with an `ERR_MISSING_OPTION`
31653176
error. Consumers that layer a protocol on top set it themselves (for example
31663177
[`connectHttp3()`][] negotiates `'h3'`).
31673178

‎lib/internal/quic/http3.js‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
const {
44
ArrayIsArray,
55
ArrayPrototypePush,
6-
ObjectHasOwn,
6+
ArrayPrototypeSome,
7+
ObjectKeys,
8+
StringPrototypeToLowerCase,
79
Symbol,
810
SymbolAsyncIterator,
911
} = primordials;
@@ -140,6 +142,12 @@ function parseHeaderPairs(pairs) {
140142
const kGetHttp3Handle = Symbol('kGetHttp3Handle');
141143
const kSubmitInitialHeaders = Symbol('kSubmitInitialHeaders');
142144

145+
function hasPriorityHeader(headers) {
146+
return ArrayPrototypeSome(
147+
ObjectKeys(headers),
148+
(name) => StringPrototypeToLowerCase(name) === 'priority');
149+
}
150+
143151
function priorityFieldValue(level, incremental) {
144152
const urgency = level === 'high' ? 0 : level === 'low' ? 7 : 3;
145153
if (urgency === 3 && !incremental) return undefined;
@@ -254,6 +262,9 @@ class Http3Stream {
254262
/** @type {Http3Session} */
255263
get session() { return this.#session; }
256264

265+
/** @type {QuicStream} */
266+
get stream() { return this.#stream; }
267+
257268
/** @type {bigint} */
258269
get id() { return this.#stream.id; }
259270

@@ -386,7 +397,7 @@ class Http3Stream {
386397
if (!this.#isServer) {
387398
const pri = priorityFieldValue(
388399
this.#priority.level, this.#priority.incremental);
389-
if (pri !== undefined && !ObjectHasOwn(headers, 'priority')) {
400+
if (pri !== undefined && !hasPriorityHeader(headers)) {
390401
toSend = { __proto__: null, ...headers, priority: pri };
391402
}
392403
}
@@ -683,11 +694,18 @@ class Http3Session {
683694
...quicOptions
684695
} = options;
685696

697+
if (priority !== undefined) {
698+
validateOneOf(priority, 'options.priority', ['default', 'low', 'high']);
699+
}
700+
if (incremental !== undefined) {
701+
validateBoolean(incremental, 'options.incremental');
702+
}
703+
686704
let headerString;
687705
if (headers !== undefined) {
688706
let toSend = headers;
689707
const pri = priorityFieldValue(priority ?? 'default', incremental ?? false);
690-
if (pri !== undefined && !ObjectHasOwn(headers, 'priority')) {
708+
if (pri !== undefined && !hasPriorityHeader(headers)) {
691709
toSend = { __proto__: null, ...headers, priority: pri };
692710
}
693711
headerString = buildNgHeaderString(

‎lib/internal/quic/quic.js‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const {
9898
ERR_INVALID_STATE,
9999
ERR_INVALID_THIS,
100100
ERR_MISSING_ARGS,
101+
ERR_MISSING_OPTION,
101102
ERR_OUT_OF_RANGE,
102103
ERR_QUIC_CONNECTION_FAILED,
103104
ERR_QUIC_ENDPOINT_CLOSED,
@@ -4433,9 +4434,7 @@ function processTlsOptions(tls, forServer) {
44334434

44344435
// Encode the ALPN option to wire format (length-prefixed protocol names).
44354436
if (alpn === undefined) {
4436-
throw new ERR_INVALID_ARG_VALUE(
4437-
'options.alpn', alpn,
4438-
'is required');
4437+
throw new ERR_MISSING_OPTION('options.alpn');
44394438
}
44404439
const protocols = forServer ?
44414440
(ArrayIsArray(alpn) ? alpn : [alpn]) :

‎src/quic/http3.cc‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,8 @@ MaybeLocal<Object> Http3Settings::ToObject(Environment* env) const {
251251
static constexpr std::string_view names[] = {"maxHeaderPairs",
252252
"maxHeaderLength",
253253
"maxFieldSectionSize",
254-
"qpackMaxDtableCapacity",
255-
"qpackEncoderMaxDtableCapacity",
254+
"qpackMaxDTableCapacity",
255+
"qpackEncoderMaxDTableCapacity",
256256
"qpackBlockedStreams",
257257
"enableConnectProtocol"};
258258
if (tmpl.IsEmpty()) {
@@ -1119,6 +1119,7 @@ class Http3Application final : public Session::Application {
11191119
}
11201120

11211121
void EmitHeaders(Stream* stream) {
1122+
stream->RecordReceiveActivity();
11221123
auto it = header_state_.find(stream->id());
11231124
if (it == header_state_.end()) return;
11241125
auto& hs = it->second;
@@ -1826,11 +1827,12 @@ void CreateHttp3Handle(const FunctionCallbackInfo<Value>& args) {
18261827
ASSIGN_OR_RETURN_UNWRAP(&session, args[0]);
18271828

18281829
if (!session->has_application()) {
1829-
if (session->is_active()) {
1830+
if (session->is_active() || session->has_streams()) {
18301831
THROW_ERR_INVALID_STATE(
18311832
session->env(),
18321833
"An application can only be attached to a QUIC session before it "
1833-
"becomes active (begins emitting events)");
1834+
"becomes active (begins emitting events) and before any streams "
1835+
"are created");
18341836
return;
18351837
}
18361838
session->SetApplication(CreateHttp3Application(session));

‎src/quic/session.cc‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3375,6 +3375,12 @@ BaseObjectPtr<Stream> Session::FindStream(stream_id id) const {
33753375
return it->second;
33763376
}
33773377

3378+
bool Session::has_streams() const {
3379+
if (is_destroyed()) return false;
3380+
return !impl_->streams_.empty() || !pending_bidi_stream_queue().IsEmpty() ||
3381+
!pending_uni_stream_queue().IsEmpty();
3382+
}
3383+
33783384
Session::StreamsMap Session::streams() const {
33793385
if (is_destroyed()) return {};
33803386
return impl_->streams_;

‎src/quic/session.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
327327
bool has_application() const;
328328
Application& application() const;
329329

330+
bool has_streams() const;
331+
330332
// True once the session has started delivering events to JS, which we use
331333
// as a gate for attaching an Application - it has to happen before this.
332334
bool is_active() const { return active_; }

‎src/quic/streams.cc‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1636,6 +1636,10 @@ void Stream::ReceiveStopSending(QuicError error) {
16361636
EmitStopSending(error);
16371637
}
16381638

1639+
void Stream::RecordReceiveActivity() {
1640+
STAT_RECORD_TIMESTAMP(Stats, received_at);
1641+
}
1642+
16391643
void Stream::ReceiveStreamReset(uint64_t final_size, QuicError error) {
16401644
// Importantly, reset stream only impacts the inbound data flow. It has no
16411645
// impact on the outbound data flow. It is essentially a signal that the peer
@@ -1780,7 +1784,6 @@ void Stream::EmitStopSending(const QuicError& error) {
17801784
MakeCallback(BindingData::Get(env()).stream_stop_sending_callback(), 1, &err);
17811785
}
17821786

1783-
17841787
// ============================================================================
17851788

17861789
void Stream::Schedule(Queue* queue) {

‎src/quic/streams.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,11 @@ class Stream final : public AsyncWrap,
343343
void ReceiveStopSending(QuicError error);
344344
void ReceiveStreamReset(uint64_t final_size, QuicError error);
345345

346+
// Records receive-side activity on the stream (the received_at stat, which
347+
// also feeds the idle-timeout clock). ReceiveData updates this for DATA
348+
// payloads; applications must call it for non-DATA traffic they deliver.
349+
void RecordReceiveActivity();
350+
346351
// Sends a reset stream to the peer to tell it we will not be sending any
347352
// more data for this stream. This has the effect of shutting down the
348353
// writable side of the stream for this peer. Any data that is held in the
@@ -408,7 +413,6 @@ class Stream final : public AsyncWrap,
408413
// Notifies the JavaScript side that the peer asked it to stop sending.
409414
void EmitStopSending(const QuicError& error);
410415

411-
412416
// Notifies the JavaScript side that sending data on the stream has been
413417
// blocked because of flow control restriction.
414418
void EmitBlocked();

‎test/parallel/test-quic-alpn.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ await clientSession.close();
4949
// rather than silently defaulting.
5050
await assert.rejects(listen(mustNotCall(), {
5151
sni: { '*': { keys: [key], certs: [cert] } },
52-
}), { code: 'ERR_INVALID_ARG_VALUE', message: /options\.alpn/ });
52+
}), { code: 'ERR_MISSING_OPTION', message: /options\.alpn/ });
5353
await assert.rejects(connect(serverEndpoint.address, { verifyPeer: 'manual' }), {
54-
code: 'ERR_INVALID_ARG_VALUE', message: /options\.alpn/,
54+
code: 'ERR_MISSING_OPTION', message: /options\.alpn/,
5555
});
5656

5757
await serverEndpoint.close();

‎test/parallel/test-quic-h3-no-application.mjs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,31 @@ const tooLate = { code: 'ERR_INVALID_STATE', message: /before it becomes active/
103103
await client.close();
104104
await endpoint.close();
105105
}
106+
107+
// Client: a wrap after the session has created streams is rejected, even
108+
// pre-handshake: an application installed then would strand the pending
109+
// native streams' queued data. The pending stream itself must still be
110+
// delivered over the native path once the handshake completes.
111+
{
112+
const serverGot = Promise.withResolvers();
113+
const endpoint = await quicListen(mustCall((quicSession) => {
114+
quicSession.onstream = mustCall(async (stream) => {
115+
assert.strictEqual(dec.decode(await bytes(stream)), 'x');
116+
quicSession.close();
117+
serverGot.resolve();
118+
});
119+
}), serverOpts);
120+
const client = await quicConnect(endpoint.address, clientOpts);
121+
// Create a raw (native-path) stream before the handshake completes;
122+
// it is pending until the handshake, but already owns queued data.
123+
const raw = await client.createUnidirectionalStream({ body: enc.encode('x') });
124+
assert.throws(() => new Http3Session(client), {
125+
code: 'ERR_INVALID_STATE',
126+
message: /before any streams are created/,
127+
});
128+
await client.opened;
129+
await serverGot.promise;
130+
await raw.closed;
131+
await client.close();
132+
await endpoint.close();
133+
}

0 commit comments

Comments
 (0)