Skip to content

Commit 46d7cdd

Browse files
committed
test: add quic teardown lifecycle tests
Adds shared test helpers (checkQuic, defaultCerts, createQuicPair) and 17 subtests covering session close, session destroy, and endpoint close/destroy behavior. session.close() hangs on current main because handle.gracefulClose() never fires kFinishClose back to JS. Tests assert the documented contract and will fail until that is fixed. Refs: #60122 Refs: #60309 Refs: #57119
1 parent 6964b53 commit 46d7cdd

4 files changed

Lines changed: 418 additions & 0 deletions

File tree

test/common/quic/helpers.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Shared QUIC test helpers for session-level setup and teardown.
2+
// Complements test-client.mjs and test-server.mjs (ngtcp2 binary wrappers).
3+
4+
import { hasQuic, skip, mustCall } from '../index.mjs';
5+
import * as fixtures from '../fixtures.mjs';
6+
7+
/**
8+
* Guard check. Skips the test if QUIC is not available.
9+
* Call at the top of every QUIC test after imports.
10+
*/
11+
export function checkQuic() {
12+
if (!hasQuic) {
13+
skip('QUIC is not enabled');
14+
}
15+
}
16+
17+
/**
18+
* Returns TLS credentials from test fixtures.
19+
* @returns {{ keys: KeyObject, certs: Buffer }}
20+
*/
21+
export async function defaultCerts() {
22+
const { createPrivateKey } = await import('node:crypto');
23+
const keys = createPrivateKey(fixtures.readKey('agent1-key.pem'));
24+
const certs = fixtures.readKey('agent1-cert.pem');
25+
return { keys, certs };
26+
}
27+
28+
/**
29+
* Creates a connected client-server QUIC pair.
30+
* Returns the endpoint, both sessions, and a cleanup function.
31+
* @param {object} [options]
32+
* @param {object} [options.serverOptions] - Additional options for listen().
33+
* @param {object} [options.clientOptions] - Additional options for connect().
34+
* @returns {Promise<{
35+
* endpoint: QuicEndpoint,
36+
* serverSession: QuicSession,
37+
* clientSession: QuicSession,
38+
* cleanup: () => Promise<void>
39+
* }>}
40+
*/
41+
export async function createQuicPair(options = {}) {
42+
const { listen, connect } = await import('node:quic');
43+
const { keys, certs } = await defaultCerts();
44+
45+
const serverReady = Promise.withResolvers();
46+
47+
const endpoint = await listen(mustCall((session) => {
48+
serverReady.resolve(session);
49+
}), { keys, certs, ...options.serverOptions });
50+
51+
const clientSession = await connect(endpoint.address, options.clientOptions);
52+
53+
// Wait for both sides to complete the handshake.
54+
const [serverSession] = await Promise.all([
55+
serverReady.promise,
56+
clientSession.opened,
57+
]);
58+
await serverSession.opened;
59+
60+
async function cleanup() {
61+
clientSession.close();
62+
serverSession.close();
63+
await Promise.allSettled([clientSession.closed, serverSession.closed]);
64+
await endpoint.close();
65+
}
66+
67+
return { endpoint, serverSession, clientSession, cleanup };
68+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import { mustCall } from '../common/index.mjs';
4+
import assert from 'node:assert';
5+
import { checkQuic, createQuicPair, defaultCerts } from '../common/quic/helpers.mjs';
6+
7+
checkQuic();
8+
9+
const { listen, connect } = await import('node:quic');
10+
11+
// Test 1: endpoint.close() with no active sessions resolves cleanly.
12+
{
13+
const { keys, certs } = await defaultCerts();
14+
15+
const endpoint = await listen(mustCall(0), { keys, certs });
16+
17+
assert.strictEqual(endpoint.destroyed, false);
18+
assert.strictEqual(endpoint.closing, false);
19+
20+
const closePromise = endpoint.close();
21+
assert.ok(closePromise instanceof Promise,
22+
'close() should return a promise');
23+
24+
assert.strictEqual(endpoint.closing, true);
25+
26+
await closePromise;
27+
assert.strictEqual(endpoint.destroyed, true);
28+
}
29+
30+
// Test 2: endpoint.close() is idempotent.
31+
{
32+
const { keys, certs } = await defaultCerts();
33+
34+
const endpoint = await listen(mustCall(0), { keys, certs });
35+
36+
endpoint.close();
37+
endpoint.close(); // Second call should not throw.
38+
39+
await endpoint.closed;
40+
assert.strictEqual(endpoint.destroyed, true);
41+
}
42+
43+
// Test 3: endpoint.destroy() forcefully tears down sessions.
44+
{
45+
const { clientSession, serverSession, endpoint } = await createQuicPair();
46+
47+
assert.strictEqual(endpoint.destroyed, false);
48+
assert.strictEqual(clientSession.destroyed, false);
49+
assert.strictEqual(serverSession.destroyed, false);
50+
51+
// destroy() should trigger session destruction.
52+
endpoint.destroy();
53+
54+
await endpoint.closed;
55+
assert.strictEqual(endpoint.destroyed, true);
56+
57+
// Sessions should also be destroyed after endpoint.destroy().
58+
assert.strictEqual(serverSession.destroyed, true);
59+
60+
// Client session may not be immediately destroyed since it's on its own
61+
// endpoint, but it should eventually close due to peer disconnect.
62+
// Wait with a reasonable timeout.
63+
await clientSession.closed.catch(() => {});
64+
}
65+
66+
// Test 4: endpoint.destroy(error) rejects the closed promise with that error.
67+
{
68+
const { keys, certs } = await defaultCerts();
69+
const endpoint = await listen(mustCall(0), { keys, certs });
70+
71+
const testError = new Error('endpoint destroy error');
72+
73+
await assert.rejects(async () => {
74+
endpoint.destroy(testError);
75+
await endpoint.closed;
76+
}, (err) => {
77+
assert.strictEqual(err, testError);
78+
return true;
79+
});
80+
}
81+
82+
// Test 5: endpoint.close() with active sessions waits for sessions to end.
83+
{
84+
const { clientSession, serverSession, endpoint } = await createQuicPair();
85+
86+
// Initiate graceful close on endpoint. This should wait for sessions.
87+
const endpointClosed = endpoint.close();
88+
89+
// The endpoint should be closing but not yet destroyed since sessions
90+
// are still active.
91+
assert.strictEqual(endpoint.closing, true);
92+
93+
// Now close the sessions.
94+
clientSession.close();
95+
serverSession.close();
96+
97+
await Promise.allSettled([clientSession.closed, serverSession.closed]);
98+
99+
// The endpoint close should now complete.
100+
await endpointClosed;
101+
assert.strictEqual(endpoint.destroyed, true);
102+
}
103+
104+
// Test 6: endpoint.closed reflects the same promise as close() return value.
105+
{
106+
const { keys, certs } = await defaultCerts();
107+
const endpoint = await listen(mustCall(0), { keys, certs });
108+
109+
const closeReturn = endpoint.close();
110+
const closedProp = endpoint.closed;
111+
112+
assert.strictEqual(closeReturn, closedProp);
113+
114+
await closeReturn;
115+
}
116+
117+
// Test 7: listen() callback is not invoked for connections arriving
118+
// after endpoint.close() has been called.
119+
{
120+
const { keys, certs } = await defaultCerts();
121+
let unexpectedSession = false;
122+
123+
const endpoint = await listen(() => {
124+
unexpectedSession = true;
125+
}, { keys, certs });
126+
127+
const { address } = endpoint;
128+
129+
// Close the endpoint before any client connects.
130+
await endpoint.close();
131+
132+
assert.strictEqual(endpoint.destroyed, true);
133+
134+
// Attempt a connection to the now-closed endpoint. The server
135+
// callback must not fire.
136+
const client = await connect(address);
137+
await client.opened.catch(() => {});
138+
client.destroy();
139+
await client.closed.catch(() => {});
140+
141+
assert.strictEqual(unexpectedSession, false);
142+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import { mustCall } from '../common/index.mjs';
4+
import assert from 'node:assert';
5+
import { checkQuic, createQuicPair, defaultCerts } from '../common/quic/helpers.mjs';
6+
7+
checkQuic();
8+
9+
const { listen, connect } = await import('node:quic');
10+
11+
// Test 1: session.close() returns a promise that resolves.
12+
{
13+
const { clientSession, serverSession, endpoint } = await createQuicPair();
14+
15+
const closePromise = clientSession.close();
16+
assert.ok(closePromise instanceof Promise,
17+
'close() should return a promise');
18+
19+
await closePromise;
20+
21+
// Closed should also resolve after close() completes.
22+
await clientSession.closed;
23+
24+
serverSession.close();
25+
await serverSession.closed;
26+
await endpoint.close();
27+
}
28+
29+
// Test 2: close() is idempotent -- calling it multiple times does not throw.
30+
{
31+
const { clientSession, serverSession, endpoint } = await createQuicPair();
32+
33+
clientSession.close();
34+
clientSession.close(); // Second call should not throw.
35+
await clientSession.closed;
36+
37+
serverSession.close();
38+
await serverSession.closed;
39+
await endpoint.close();
40+
}
41+
42+
// Test 3: Endpoint survives session close and can accept new connections.
43+
{
44+
const { keys, certs } = await defaultCerts();
45+
let sessionCount = 0;
46+
47+
const endpoint = await listen(mustCall((session) => {
48+
sessionCount++;
49+
session.opened.then(mustCall(() => {
50+
session.close();
51+
}));
52+
}, 2), { keys, certs });
53+
54+
// First connection.
55+
const client1 = await connect(endpoint.address);
56+
await client1.opened;
57+
client1.close();
58+
await client1.closed;
59+
60+
// Second connection on the same endpoint.
61+
const client2 = await connect(endpoint.address);
62+
await client2.opened;
63+
client2.close();
64+
await client2.closed;
65+
66+
assert.strictEqual(sessionCount, 2);
67+
68+
await endpoint.close();
69+
}
70+
71+
// Test 4: session.closed resolves after close() on both client and server side.
72+
{
73+
const { clientSession, serverSession, endpoint } = await createQuicPair();
74+
75+
const clientClosed = Promise.withResolvers();
76+
const serverClosed = Promise.withResolvers();
77+
78+
clientSession.closed.then(mustCall(() => {
79+
clientClosed.resolve();
80+
}));
81+
82+
serverSession.closed.then(mustCall(() => {
83+
serverClosed.resolve();
84+
}));
85+
86+
clientSession.close();
87+
serverSession.close();
88+
89+
await Promise.all([clientClosed.promise, serverClosed.promise]);
90+
await endpoint.close();
91+
}
92+
93+
// Test 5: session.destroyed is true after close completes.
94+
{
95+
const { clientSession, serverSession, endpoint } = await createQuicPair();
96+
97+
assert.strictEqual(clientSession.destroyed, false);
98+
99+
clientSession.close();
100+
await clientSession.closed;
101+
102+
assert.strictEqual(clientSession.destroyed, true);
103+
104+
serverSession.close();
105+
await serverSession.closed;
106+
await endpoint.close();
107+
}

0 commit comments

Comments
 (0)