From 46d7cdd0fdeef84efe397713364b95f5d7478ab3 Mon Sep 17 00:00:00 2001 From: mannie-exe Date: Sat, 28 Feb 2026 15:03:04 -0800 Subject: [PATCH] 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: https://github.com/nodejs/node/issues/60122 Refs: https://github.com/nodejs/node/issues/60309 Refs: https://github.com/nodejs/node/pull/57119 --- test/common/quic/helpers.mjs | 68 ++++++++++ test/parallel/test-quic-endpoint-close.mjs | 142 ++++++++++++++++++++ test/parallel/test-quic-session-close.mjs | 107 +++++++++++++++ test/parallel/test-quic-session-destroy.mjs | 101 ++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 test/common/quic/helpers.mjs create mode 100644 test/parallel/test-quic-endpoint-close.mjs create mode 100644 test/parallel/test-quic-session-close.mjs create mode 100644 test/parallel/test-quic-session-destroy.mjs diff --git a/test/common/quic/helpers.mjs b/test/common/quic/helpers.mjs new file mode 100644 index 00000000000000..5e381cbfe62ecd --- /dev/null +++ b/test/common/quic/helpers.mjs @@ -0,0 +1,68 @@ +// Shared QUIC test helpers for session-level setup and teardown. +// Complements test-client.mjs and test-server.mjs (ngtcp2 binary wrappers). + +import { hasQuic, skip, mustCall } from '../index.mjs'; +import * as fixtures from '../fixtures.mjs'; + +/** + * Guard check. Skips the test if QUIC is not available. + * Call at the top of every QUIC test after imports. + */ +export function checkQuic() { + if (!hasQuic) { + skip('QUIC is not enabled'); + } +} + +/** + * Returns TLS credentials from test fixtures. + * @returns {{ keys: KeyObject, certs: Buffer }} + */ +export async function defaultCerts() { + const { createPrivateKey } = await import('node:crypto'); + const keys = createPrivateKey(fixtures.readKey('agent1-key.pem')); + const certs = fixtures.readKey('agent1-cert.pem'); + return { keys, certs }; +} + +/** + * Creates a connected client-server QUIC pair. + * Returns the endpoint, both sessions, and a cleanup function. + * @param {object} [options] + * @param {object} [options.serverOptions] - Additional options for listen(). + * @param {object} [options.clientOptions] - Additional options for connect(). + * @returns {Promise<{ + * endpoint: QuicEndpoint, + * serverSession: QuicSession, + * clientSession: QuicSession, + * cleanup: () => Promise + * }>} + */ +export async function createQuicPair(options = {}) { + const { listen, connect } = await import('node:quic'); + const { keys, certs } = await defaultCerts(); + + const serverReady = Promise.withResolvers(); + + const endpoint = await listen(mustCall((session) => { + serverReady.resolve(session); + }), { keys, certs, ...options.serverOptions }); + + const clientSession = await connect(endpoint.address, options.clientOptions); + + // Wait for both sides to complete the handshake. + const [serverSession] = await Promise.all([ + serverReady.promise, + clientSession.opened, + ]); + await serverSession.opened; + + async function cleanup() { + clientSession.close(); + serverSession.close(); + await Promise.allSettled([clientSession.closed, serverSession.closed]); + await endpoint.close(); + } + + return { endpoint, serverSession, clientSession, cleanup }; +} diff --git a/test/parallel/test-quic-endpoint-close.mjs b/test/parallel/test-quic-endpoint-close.mjs new file mode 100644 index 00000000000000..b90d93b676f496 --- /dev/null +++ b/test/parallel/test-quic-endpoint-close.mjs @@ -0,0 +1,142 @@ +// Flags: --experimental-quic --no-warnings + +import { mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import { checkQuic, createQuicPair, defaultCerts } from '../common/quic/helpers.mjs'; + +checkQuic(); + +const { listen, connect } = await import('node:quic'); + +// Test 1: endpoint.close() with no active sessions resolves cleanly. +{ + const { keys, certs } = await defaultCerts(); + + const endpoint = await listen(mustCall(0), { keys, certs }); + + assert.strictEqual(endpoint.destroyed, false); + assert.strictEqual(endpoint.closing, false); + + const closePromise = endpoint.close(); + assert.ok(closePromise instanceof Promise, + 'close() should return a promise'); + + assert.strictEqual(endpoint.closing, true); + + await closePromise; + assert.strictEqual(endpoint.destroyed, true); +} + +// Test 2: endpoint.close() is idempotent. +{ + const { keys, certs } = await defaultCerts(); + + const endpoint = await listen(mustCall(0), { keys, certs }); + + endpoint.close(); + endpoint.close(); // Second call should not throw. + + await endpoint.closed; + assert.strictEqual(endpoint.destroyed, true); +} + +// Test 3: endpoint.destroy() forcefully tears down sessions. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + assert.strictEqual(endpoint.destroyed, false); + assert.strictEqual(clientSession.destroyed, false); + assert.strictEqual(serverSession.destroyed, false); + + // destroy() should trigger session destruction. + endpoint.destroy(); + + await endpoint.closed; + assert.strictEqual(endpoint.destroyed, true); + + // Sessions should also be destroyed after endpoint.destroy(). + assert.strictEqual(serverSession.destroyed, true); + + // Client session may not be immediately destroyed since it's on its own + // endpoint, but it should eventually close due to peer disconnect. + // Wait with a reasonable timeout. + await clientSession.closed.catch(() => {}); +} + +// Test 4: endpoint.destroy(error) rejects the closed promise with that error. +{ + const { keys, certs } = await defaultCerts(); + const endpoint = await listen(mustCall(0), { keys, certs }); + + const testError = new Error('endpoint destroy error'); + + await assert.rejects(async () => { + endpoint.destroy(testError); + await endpoint.closed; + }, (err) => { + assert.strictEqual(err, testError); + return true; + }); +} + +// Test 5: endpoint.close() with active sessions waits for sessions to end. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + // Initiate graceful close on endpoint. This should wait for sessions. + const endpointClosed = endpoint.close(); + + // The endpoint should be closing but not yet destroyed since sessions + // are still active. + assert.strictEqual(endpoint.closing, true); + + // Now close the sessions. + clientSession.close(); + serverSession.close(); + + await Promise.allSettled([clientSession.closed, serverSession.closed]); + + // The endpoint close should now complete. + await endpointClosed; + assert.strictEqual(endpoint.destroyed, true); +} + +// Test 6: endpoint.closed reflects the same promise as close() return value. +{ + const { keys, certs } = await defaultCerts(); + const endpoint = await listen(mustCall(0), { keys, certs }); + + const closeReturn = endpoint.close(); + const closedProp = endpoint.closed; + + assert.strictEqual(closeReturn, closedProp); + + await closeReturn; +} + +// Test 7: listen() callback is not invoked for connections arriving +// after endpoint.close() has been called. +{ + const { keys, certs } = await defaultCerts(); + let unexpectedSession = false; + + const endpoint = await listen(() => { + unexpectedSession = true; + }, { keys, certs }); + + const { address } = endpoint; + + // Close the endpoint before any client connects. + await endpoint.close(); + + assert.strictEqual(endpoint.destroyed, true); + + // Attempt a connection to the now-closed endpoint. The server + // callback must not fire. + const client = await connect(address); + await client.opened.catch(() => {}); + client.destroy(); + await client.closed.catch(() => {}); + + assert.strictEqual(unexpectedSession, false); +} diff --git a/test/parallel/test-quic-session-close.mjs b/test/parallel/test-quic-session-close.mjs new file mode 100644 index 00000000000000..0c113548d9329c --- /dev/null +++ b/test/parallel/test-quic-session-close.mjs @@ -0,0 +1,107 @@ +// Flags: --experimental-quic --no-warnings + +import { mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import { checkQuic, createQuicPair, defaultCerts } from '../common/quic/helpers.mjs'; + +checkQuic(); + +const { listen, connect } = await import('node:quic'); + +// Test 1: session.close() returns a promise that resolves. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + const closePromise = clientSession.close(); + assert.ok(closePromise instanceof Promise, + 'close() should return a promise'); + + await closePromise; + + // Closed should also resolve after close() completes. + await clientSession.closed; + + serverSession.close(); + await serverSession.closed; + await endpoint.close(); +} + +// Test 2: close() is idempotent -- calling it multiple times does not throw. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + clientSession.close(); + clientSession.close(); // Second call should not throw. + await clientSession.closed; + + serverSession.close(); + await serverSession.closed; + await endpoint.close(); +} + +// Test 3: Endpoint survives session close and can accept new connections. +{ + const { keys, certs } = await defaultCerts(); + let sessionCount = 0; + + const endpoint = await listen(mustCall((session) => { + sessionCount++; + session.opened.then(mustCall(() => { + session.close(); + })); + }, 2), { keys, certs }); + + // First connection. + const client1 = await connect(endpoint.address); + await client1.opened; + client1.close(); + await client1.closed; + + // Second connection on the same endpoint. + const client2 = await connect(endpoint.address); + await client2.opened; + client2.close(); + await client2.closed; + + assert.strictEqual(sessionCount, 2); + + await endpoint.close(); +} + +// Test 4: session.closed resolves after close() on both client and server side. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + const clientClosed = Promise.withResolvers(); + const serverClosed = Promise.withResolvers(); + + clientSession.closed.then(mustCall(() => { + clientClosed.resolve(); + })); + + serverSession.closed.then(mustCall(() => { + serverClosed.resolve(); + })); + + clientSession.close(); + serverSession.close(); + + await Promise.all([clientClosed.promise, serverClosed.promise]); + await endpoint.close(); +} + +// Test 5: session.destroyed is true after close completes. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + assert.strictEqual(clientSession.destroyed, false); + + clientSession.close(); + await clientSession.closed; + + assert.strictEqual(clientSession.destroyed, true); + + serverSession.close(); + await serverSession.closed; + await endpoint.close(); +} diff --git a/test/parallel/test-quic-session-destroy.mjs b/test/parallel/test-quic-session-destroy.mjs new file mode 100644 index 00000000000000..5e7675c387e131 --- /dev/null +++ b/test/parallel/test-quic-session-destroy.mjs @@ -0,0 +1,101 @@ +// Flags: --experimental-quic --no-warnings + +import { mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import { checkQuic, createQuicPair } from '../common/quic/helpers.mjs'; + +checkQuic(); + +// Test 1: destroy() immediately marks session as destroyed. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + assert.strictEqual(clientSession.destroyed, false); + + clientSession.destroy(); + + assert.strictEqual(clientSession.destroyed, true); + + // Closed should still resolve when destroy is called without error. + await clientSession.closed; + + serverSession.destroy(); + await serverSession.closed; + await endpoint.close(); +} + +// Test 2: destroy() with no error resolves the closed promise. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + const closedResolved = Promise.withResolvers(); + + clientSession.closed.then(mustCall(() => { + closedResolved.resolve('resolved'); + })).catch(() => { + closedResolved.resolve('rejected'); + }); + + clientSession.destroy(); + + const result = await closedResolved.promise; + assert.strictEqual(result, 'resolved'); + + serverSession.destroy(); + await serverSession.closed; + await endpoint.close(); +} + +// Test 3: destroy(error) rejects the closed promise with that error. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + const testError = new Error('test destroy error'); + + await assert.rejects(async () => { + clientSession.destroy(testError); + await clientSession.closed; + }, (err) => { + assert.strictEqual(err, testError); + return true; + }); + + serverSession.destroy(); + await serverSession.closed; + await endpoint.close(); +} + +// Test 4: destroy() is idempotent -- calling it after already destroyed is a no-op. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + clientSession.destroy(); + assert.strictEqual(clientSession.destroyed, true); + + // Second destroy should not throw. + clientSession.destroy(); + assert.strictEqual(clientSession.destroyed, true); + + await clientSession.closed; + serverSession.destroy(); + await serverSession.closed; + await endpoint.close(); +} + +// Test 5: destroy() on server session. +{ + const { clientSession, serverSession, endpoint } = await createQuicPair(); + + serverSession.destroy(); + + assert.strictEqual(serverSession.destroyed, true); + + await serverSession.closed; + + // Client session may also close due to peer disconnect. Clean up. + if (!clientSession.destroyed) { + clientSession.destroy(); + } + await clientSession.closed; + await endpoint.close(); +}