Skip to content

Commit f036f05

Browse files
committed
quic: fix subtle bugs in resume & failed H3 start teardown
And a little related cleanup en route Signed-off-by: Tim Perry <pimterry@gmail.com>
1 parent b1591aa commit f036f05

6 files changed

Lines changed: 93 additions & 28 deletions

File tree

‎src/quic/endpoint.cc‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1997,7 +1997,12 @@ void Endpoint::SocketAddressInfoTraits::Touch(const SocketAddress& address,
19971997
// JavaScript call outs
19981998

19991999
void Endpoint::EmitNewSession(const BaseObjectPtr<Session>& session) {
2000-
if (!env()->can_call_into_js()) return;
2000+
if (!env()->can_call_into_js()) {
2001+
// Even if we can't call into JS, we need to attach the app to handle
2002+
// other callbacks before we do proper teardown:
2003+
session->EnsureApplication();
2004+
return;
2005+
}
20012006
CallbackScope<Endpoint> scope(this);
20022007
session->set_wrapped();
20032008
Local<Value> arg = session->object();

‎src/quic/session.cc‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2629,7 +2629,7 @@ bool Session::EnsureApplication() {
26292629
if (is_destroyed()) [[unlikely]]
26302630
return false;
26312631
if (impl_->application_) [[likely]]
2632-
return true;
2632+
return !flags_.application_start_failed;
26332633

26342634
if (application_type() == Application::Type::HTTP3) {
26352635
SetApplication(CreateHttp3Application(this));
@@ -2639,9 +2639,16 @@ bool Session::EnsureApplication() {
26392639
}
26402640

26412641
// If the keys are already ready, that means we should start immediately.
2642-
// If application start fails then we can't continue.
2642+
// If application start fails then we can't continue. Inside an ngtcp2
2643+
// callback the session can't be closed directly, but the failure sticks,
2644+
// and HandshakeCompleted() then fails the callback, which closes it.
26432645
if (keys_ready_ && !application().Start()) {
26442646
Debug(this, "Application start failed");
2647+
flags_.application_start_failed = 1;
2648+
if (!flags_.in_ngtcp2_callback_scope) {
2649+
SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
2650+
Close();
2651+
}
26452652
return false;
26462653
}
26472654
return true;
@@ -2865,7 +2872,7 @@ bool Session::AfterNgtcp2Read(int err) {
28652872
if (is_server() && tls_session().early_selection() ==
28662873
TLSSession::EarlySelection::kSelected) {
28672874
endpoint().EmitNewSession(BaseObjectPtr<Session>(this));
2868-
if (!is_destroyed()) ResumeHandshake();
2875+
if (has_application()) ResumeHandshake();
28692876
}
28702877
}
28712878
return true;
@@ -3408,15 +3415,16 @@ void Session::StreamDataBlocked(stream_id id) {
34083415

34093416
void Session::CollectSessionTicketAppData(
34103417
SessionTicket::AppData* app_data) const {
3411-
DCHECK(!is_destroyed());
3412-
CHECK(has_application());
3418+
if (!has_application()) [[unlikely]]
3419+
return;
34133420
application().CollectSessionTicketAppData(app_data);
34143421
}
34153422

34163423
SessionTicket::AppData::Status Session::ExtractSessionTicketAppData(
34173424
const SessionTicket::AppData& app_data, Flag flag) {
3418-
DCHECK(!is_destroyed());
3419-
CHECK(has_application());
3425+
if (!has_application()) [[unlikely]] {
3426+
return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW;
3427+
}
34203428
return application().ExtractSessionTicketAppData(app_data, flag);
34213429
}
34223430

‎src/quic/session.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
#include <node_sockaddr.h>
1212
#include <timer_wrap.h>
1313
#include <util.h>
14-
#include <memory>
1514
#include <optional>
1615
#include <span>
1716
#include "bindingdata.h"
@@ -741,6 +740,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
741740
// Set during FlushPendingData to avoid the one-tick latency of
742741
// async-only sends from the uv_check callback.
743742
uint8_t prefer_try_send : 1 = 0;
743+
// Set if the application couldn't be started, which is fatal to it.
744+
uint8_t application_start_failed : 1 = 0;
744745
};
745746
Flags flags_;
746747

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,6 @@ assert.throws(() => stream.sendHeaders({ ':status': '200' }), {
4949
message: /does not support headers/,
5050
});
5151

52-
clientSession.destroy();
52+
stream.destroy();
53+
await clientSession.close();
5354
await serverEndpoint.close();

‎test/parallel/test-quic-h3-attach.mjs‎

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,6 @@ assert.throws(() => new Http3Session(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' });
6161
code: 'ERR_INVALID_STATE',
6262
message: /cannot be set on a session/,
6363
});
64-
// And the HTTP/3-only callbacks exist only there:
65-
for (const name of ['ongoaway', 'onorigin', 'onapplication']) {
66-
assert.strictEqual(name in quicSession, false);
67-
}
6864
// The onerror callback stays transport-level, so both sides keep their own:
6965
quicSession.onerror = () => {};
7066
session.onerror = () => {};
@@ -96,20 +92,6 @@ const tooLate = {
9692
message: /already has an application/,
9793
};
9894

99-
// HTTP/3-only callbacks can't be passed as QuicSession options, so they
100-
// can't be registered before the application exists:
101-
for (const name of ['ongoaway', 'onorigin', 'onapplication']) {
102-
const expected = {
103-
code: 'ERR_INVALID_ARG_VALUE',
104-
message: new RegExp(`options\\.${name}.*Http3Session`),
105-
};
106-
const callback = { [name]: () => {} };
107-
await assert.rejects(listen(() => {}, { ...serverOpts, ...callback }),
108-
expected);
109-
await assert.rejects(connect('127.0.0.1:1', { ...clientOpts, ...callback }),
110-
expected);
111-
}
112-
11395
// Setting onstream claims the session for raw QUIC, so HTTP/3 can't be
11496
// attached afterwards, whether it is set directly or passed as an option.
11597
{
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
// Test: HTTP/3 can't start when the peer allows fewer than the three
4+
// unidirectional streams it needs for its control and QPACK streams. That
5+
// must close the session, rather than leave it running without HTTP/3.
6+
7+
import { hasQuic, skip, mustCall } from '../common/index.mjs';
8+
import assert from 'node:assert';
9+
import * as fixtures from '../common/fixtures.mjs';
10+
11+
if (!hasQuic) {
12+
skip('QUIC is not enabled');
13+
}
14+
15+
const { listen, connect } = await import('node:quic');
16+
const { createPrivateKey } = await import('node:crypto');
17+
18+
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
19+
const cert = fixtures.readKey('agent1-cert.pem');
20+
const headers = {
21+
':method': 'GET',
22+
':path': '/',
23+
':scheme': 'https',
24+
':authority': 'localhost',
25+
};
26+
const internalError = {
27+
code: 'ERR_QUIC_TRANSPORT_ERROR',
28+
message: /INTERNAL_ERROR/,
29+
};
30+
31+
async function attachToLowUniServer() {
32+
const endpoint = await listen(mustCall((quicSession) => {
33+
quicSession.onerror = () => {};
34+
}), {
35+
alpn: ['h3'],
36+
sni: { '*': { keys: [key], certs: [cert] } },
37+
transportParams: { initialMaxStreamsUni: 2 },
38+
});
39+
const client = await connect(endpoint.address, {
40+
alpn: 'h3',
41+
servername: 'localhost',
42+
verifyPeer: 'manual',
43+
});
44+
return { endpoint, client };
45+
}
46+
47+
// Nothing opened: the session closes when the handshake completes.
48+
{
49+
const { endpoint, client } = await attachToLowUniServer();
50+
await assert.rejects(client.closed, internalError);
51+
await endpoint.close();
52+
}
53+
54+
// A request opened as soon as the session opens fails, and the session
55+
// closes rather than accepting further requests.
56+
{
57+
const { endpoint, client } = await attachToLowUniServer();
58+
client.onerror = mustCall((err) => {
59+
assert.strictEqual(err.code, internalError.code);
60+
});
61+
await client.opened;
62+
await assert.rejects(client.createBidirectionalStream({ headers }),
63+
{ code: 'ERR_QUIC_OPEN_STREAM_FAILED' });
64+
await assert.rejects(client.closed, internalError);
65+
await assert.rejects(client.createBidirectionalStream({ headers }),
66+
{ code: 'ERR_INVALID_STATE' });
67+
await endpoint.close();
68+
}

0 commit comments

Comments
 (0)