Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions test/common/quic/helpers.mjs
Original file line number Diff line number Diff line change
@@ -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<void>
* }>}
*/
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 };
}
142 changes: 142 additions & 0 deletions test/parallel/test-quic-endpoint-close.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
107 changes: 107 additions & 0 deletions test/parallel/test-quic-session-close.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
Loading