Skip to content

Commit 0c9f9b3

Browse files
committed
quic: apply multiple review fixups
1 parent babb19d commit 0c9f9b3

8 files changed

Lines changed: 1109 additions & 70 deletions

lib/internal/quic/quic.js

Lines changed: 414 additions & 70 deletions
Large diffs are not rendered by default.

lib/internal/quic/symbols.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const kEarlyDataRejected = Symbol('kEarlyDataRejected');
3333
const kFinishClose = Symbol('kFinishClose');
3434
const kGoaway = Symbol('kGoaway');
3535
const kHandshake = Symbol('kHandshake');
36+
const kHandshakeCompleted = Symbol('kHandshakeCompleted');
3637
const kHeaders = Symbol('kHeaders');
3738
const kKeylog = Symbol('kKeylog');
3839
const kListen = Symbol('kListen');
@@ -66,6 +67,7 @@ module.exports = {
6667
kFinishClose,
6768
kGoaway,
6869
kHandshake,
70+
kHandshakeCompleted,
6971
kHeaders,
7072
kInspect,
7173
kKeylog,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
// Test: When `endpoint.destroy(err)` is called *after* `endpoint.close()`
4+
// has already initiated graceful shutdown, the error must still surface
5+
// on `endpoint.closed` instead of being swallowed. First-error-wins
6+
// semantics: a fatal error reported via `destroy(err)` after a
7+
// graceful close was already in flight is still propagated through
8+
// `endpoint.closed`.
9+
10+
import { hasQuic, skip, mustCall } from '../common/index.mjs';
11+
import assert from 'node:assert';
12+
13+
const { rejects } = assert;
14+
15+
if (!hasQuic) {
16+
skip('QUIC is not enabled');
17+
}
18+
19+
const { listen, connect } = await import('../common/quic.mjs');
20+
21+
// Hold the connection open for a while so that close() has actual work
22+
// to wait on.
23+
const transportParams = { maxIdleTimeout: 5000 };
24+
25+
const serverDone = Promise.withResolvers();
26+
27+
const serverEndpoint = await listen(mustCall(async (serverSession) => {
28+
// Don't observe `serverSession.closed` directly here - the cascade
29+
// from `endpoint.destroy(err)` will reject it, and the outer
30+
// `markPromiseAsHandled` in the cascade keeps that from surfacing as
31+
// an unhandled rejection. We just need this callback to keep running
32+
// until the test ends.
33+
try {
34+
await serverSession.closed;
35+
} catch {
36+
// Expected: the cascade destroys the session with an error.
37+
}
38+
serverDone.resolve();
39+
}), { transportParams });
40+
41+
const clientSession = await connect(serverEndpoint.address, {
42+
transportParams,
43+
});
44+
await clientSession.opened;
45+
46+
// Step 1: kick off a graceful close. After this, the endpoint is in
47+
// the "closing" state - so `#isClosedOrClosing` is true and the
48+
// pre-fix `destroy()` would skip recording `#pendingError`.
49+
const closingPromise = serverEndpoint.close();
50+
51+
// Step 2: while the graceful close is in flight, call destroy(err).
52+
// With the fix, the error is still recorded and surfaces on
53+
// `endpoint.closed`. Without the fix, it'd be silently dropped.
54+
const destroyError = new Error('destroy after close');
55+
const closedAssertion = rejects(serverEndpoint.closed, destroyError);
56+
57+
serverEndpoint.destroy(destroyError);
58+
59+
await closedAssertion;
60+
61+
// `endpoint.close()` returns the same promise as `endpoint.closed`, so
62+
// it should reject with the same error as well.
63+
await rejects(closingPromise, destroyError);
64+
65+
await serverDone.promise;
66+
clientSession.destroy();
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
// Test: when `endpoint.destroy(err)` is called on a side that has open,
4+
// fully-handshaked sessions, the cascade through `session.destroy(err)`
5+
// passes close options derived from the error so each session emits a
6+
// CONNECTION_CLOSE on the wire. The peer learns about the teardown via
7+
// that frame, not via its own idle timer.
8+
//
9+
// How the test distinguishes the two cases:
10+
//
11+
// * If CONNECTION_CLOSE is sent, the client's `session.closed`
12+
// rejects after the network round-trip with an
13+
// `ERR_QUIC_TRANSPORT_ERROR` carrying the cascaded code.
14+
// * If CONNECTION_CLOSE is NOT sent, the client only learns of the
15+
// teardown via its own idle timer, which hits `[kFinishClose]`
16+
// case 3 (`/* Idle close */`) and resolves `session.closed`
17+
// *cleanly*. The `mustCall` rejection-handler would then never
18+
// fire and the test fails.
19+
//
20+
// A short `maxIdleTimeout` keeps the failure mode fast.
21+
22+
import { hasQuic, skip, mustCall } from '../common/index.mjs';
23+
import assert from 'node:assert';
24+
25+
const { rejects, strictEqual } = assert;
26+
27+
if (!hasQuic) {
28+
skip('QUIC is not enabled');
29+
}
30+
31+
const { listen, connect } = await import('../common/quic.mjs');
32+
33+
// `maxIdleTimeout` is measured in seconds. One second is far longer
34+
// than CONNECTION_CLOSE on loopback needs to win, while still short
35+
// enough that a regression in which `CONNECTION_CLOSE` is *not* sent
36+
// fails the test promptly: the idle-close path takes the
37+
// `[kFinishClose]` case-3 branch and resolves `session.closed` cleanly
38+
// instead of rejecting, so the rejection-handler `mustCall` below
39+
// would fail with "expected exactly 1, actual 0".
40+
const transportParams = { maxIdleTimeout: 1 };
41+
42+
const serverError = new Error('cascade close frame test');
43+
44+
// Capture the server-side session and wait for *its* `onhandshake` to
45+
// fire before triggering the cascade. The client's `session.opened`
46+
// resolves as soon as the client receives the server's TLS Finished,
47+
// which can land slightly *before* the server has processed the
48+
// client's reciprocal Finished. Without this synchronization the
49+
// server-side `kHandshakeCompleted` flag may still be `false` at
50+
// destroy time and the cascade would skip emitting `CONNECTION_CLOSE`
51+
// (which is the regression this test is designed to catch).
52+
const serverHandshake = Promise.withResolvers();
53+
const onsession = mustCall((serverSession) => {
54+
serverSession.onhandshake = mustCall(() => {
55+
serverHandshake.resolve();
56+
});
57+
});
58+
const serverEndpoint = await listen(onsession, { transportParams });
59+
60+
const clientSession = await connect(serverEndpoint.address, {
61+
transportParams,
62+
});
63+
await clientSession.opened;
64+
await serverHandshake.promise;
65+
66+
// Attach the rejection handlers BEFORE triggering destroy so neither
67+
// `serverEndpoint.closed` (rejects with `serverError` via the
68+
// `#pendingError` semantics from B7) nor `clientSession.closed`
69+
// (rejects with the transport error decoded from the CONNECTION_CLOSE
70+
// frame) ends up as an unhandled rejection in the brief window before
71+
// this test gets back to awaiting them.
72+
const serverClosedAssertion = rejects(serverEndpoint.closed, serverError);
73+
const clientClosedAssertion = rejects(clientSession.closed, mustCall((err) => {
74+
strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR');
75+
return true;
76+
}));
77+
78+
serverEndpoint.destroy(serverError);
79+
80+
await clientClosedAssertion;
81+
await serverClosedAssertion;
82+
83+
// Explicit cleanup: the client-side session has been rejected via
84+
// CONNECTION_CLOSE but the underlying client endpoint is still alive.
85+
// Tear it down so the event loop drains promptly.
86+
clientSession.destroy();
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
// Test: When the user's `onsession` callback throws synchronously or
4+
// returns a rejected promise, the endpoint is destroyed with that
5+
// error rather than surfacing as an unhandled exception/rejection out
6+
// of the C++ -> JS callback boundary. The error is observable through
7+
// `endpoint.closed` rejecting with the thrown value.
8+
9+
import { hasQuic, skip, mustCall } from '../common/index.mjs';
10+
import assert from 'node:assert';
11+
12+
const { rejects } = assert;
13+
14+
if (!hasQuic) {
15+
skip('QUIC is not enabled');
16+
}
17+
18+
const { listen, connect } = await import('../common/quic.mjs');
19+
20+
const transportParams = { maxIdleTimeout: 1 };
21+
22+
// -------------------------------------------------------------------
23+
// 1. Synchronous throw in onsession -> endpoint.closed rejects with
24+
// that error.
25+
// -------------------------------------------------------------------
26+
{
27+
const sessionError = new Error('sync onsession failure');
28+
29+
const serverEndpoint = await listen(mustCall(() => {
30+
throw sessionError;
31+
}), { transportParams });
32+
33+
// Attach the rejection handler BEFORE initiating the connection so
34+
// there is no window where serverEndpoint.closed is rejected without
35+
// a handler attached. The throw inside `onsession` is delivered
36+
// synchronously from the C++ -> JS callback, so the rejection can
37+
// arrive on the very next microtask after `connect()` returns.
38+
const closedAssertion = rejects(serverEndpoint.closed, sessionError);
39+
40+
const clientSession = await connect(serverEndpoint.address, {
41+
transportParams,
42+
});
43+
44+
await closedAssertion;
45+
46+
// Explicitly tear down the client side. Even though the endpoint
47+
// cascade now sends CONNECTION_CLOSE so the client learns about the
48+
// teardown promptly, dropping our reference here keeps the test
49+
// robust to network-dropped close packets and stops the event loop
50+
// from waiting on the client's idle timer to expire.
51+
clientSession.destroy();
52+
}
53+
54+
// -------------------------------------------------------------------
55+
// 2. onsession returns a rejected promise -> endpoint.closed rejects
56+
// with that error.
57+
// -------------------------------------------------------------------
58+
{
59+
const sessionError = new Error('async onsession failure');
60+
61+
const serverEndpoint = await listen(mustCall(async () => {
62+
throw sessionError;
63+
}), { transportParams, onerror() {} });
64+
65+
const closedAssertion = rejects(serverEndpoint.closed, sessionError);
66+
67+
const clientSession = await connect(serverEndpoint.address, {
68+
transportParams,
69+
});
70+
71+
await closedAssertion;
72+
73+
clientSession.destroy();
74+
}

0 commit comments

Comments
 (0)