mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 10:15:07 +08:00
`session.close()` and `session.destroy()` only checked that `options.code` was a bigint or a number. Numbers were then converted to `uint64_t` unchecked, so fractions were truncated and negative, non-finite or too large values were undefined behavior. Bigints were only checked to fit in 64 bits, but QUIC error codes are variable-length integers limited to 62 bits, so larger codes cannot be encoded in the `CONNECTION_CLOSE` frame. Reject codes that are not integers between `0` and `2n ** 62n - 1n`, the range `QuicError` already enforces, before any close or destroy side effects. Signed-off-by: Christian Aurich Zanettini Martins <christian.aurichzm@gmail.com> PR-URL: https://github.com/nodejs/node/pull/66302 Reviewed-By: Tim Perry <pimterry@gmail.com>
149 lines
6.0 KiB
JavaScript
149 lines
6.0 KiB
JavaScript
// Flags: --experimental-quic --no-warnings
|
|
|
|
// Test: QuicSession.destroy(error, options) validates `options` up front,
|
|
// before any side effects. A malformed `options` argument must throw
|
|
// without:
|
|
// * publishing on the `quic.session.error` diagnostics channel,
|
|
// * invoking the user's `onerror` callback,
|
|
// * cascading destroy to open streams,
|
|
// * clearing the session's private state,
|
|
// * destroying the underlying C++ handle.
|
|
//
|
|
// After such a throw the session is still alive and a subsequent
|
|
// destroy() with valid options must succeed: the client emits a
|
|
// CONNECTION_CLOSE frame with the supplied transport error code, and
|
|
// the local `closed` promise rejects with the original error.
|
|
|
|
import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs';
|
|
import assert from 'node:assert';
|
|
import diagnostics_channel from 'node:diagnostics_channel';
|
|
|
|
if (!hasQuic) {
|
|
skip('QUIC is not enabled');
|
|
}
|
|
|
|
const { listen, connect } = await import('../common/quic.mjs');
|
|
|
|
// `maxIdleTimeout` is measured in seconds. Set short so any regression
|
|
// that prevents CONNECTION_CLOSE from being sent fails promptly via
|
|
// idle close instead of hanging the test.
|
|
const transportParams = { maxIdleTimeout: 1 };
|
|
|
|
// Capture the server-side session in the listen callback. Do not
|
|
// `await serverSession.closed` from inside the callback: the final
|
|
// destroy in this test sends a transport-error CONNECTION_CLOSE which
|
|
// makes that promise reject, and an unhandled rejection inside the
|
|
// listen callback would be routed by `safeCallbackInvoke` to
|
|
// `endpoint.destroy(err)` and surface as a process-level
|
|
// unhandled-rejection crash before the test body has had a chance
|
|
// to attach its own rejection handler.
|
|
const serverSessionReady = Promise.withResolvers();
|
|
let serverSession;
|
|
const serverEndpoint = await listen(mustCall((session) => {
|
|
serverSession = session;
|
|
serverSessionReady.resolve();
|
|
}), { transportParams });
|
|
|
|
const clientSession = await connect(serverEndpoint.address, {
|
|
transportParams,
|
|
});
|
|
await clientSession.opened;
|
|
await serverSessionReady.promise;
|
|
|
|
// Open a stream so the destroy cascade has work to do; if the
|
|
// validation hoist regresses and side effects run before the throw,
|
|
// this stream will be destroyed by the failed destroy() and the
|
|
// `mustNotCall` handler will fire.
|
|
const stream = await clientSession.createBidirectionalStream();
|
|
stream.onerror = mustNotCall(
|
|
'stream.onerror must not fire when destroy() throws on bad options');
|
|
|
|
// Subscribe to diagnostics channels so we can assert nothing is
|
|
// published before the validation throw.
|
|
const errSub = mustNotCall(
|
|
'quic.session.error must not publish when destroy() throws on bad options');
|
|
diagnostics_channel.subscribe('quic.session.error', errSub);
|
|
|
|
clientSession.onerror = mustNotCall(
|
|
'session.onerror must not fire when destroy() throws on bad options');
|
|
|
|
const goodError = new Error('intended teardown');
|
|
|
|
// 1. options is not an object -> throws ERR_INVALID_ARG_TYPE.
|
|
assert.throws(() => clientSession.destroy(goodError, 'not an object'), {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
});
|
|
assert.strictEqual(clientSession.destroyed, false);
|
|
assert.strictEqual(stream.destroyed, false);
|
|
|
|
// 2. options.code is the wrong type -> throws ERR_INVALID_ARG_TYPE.
|
|
assert.throws(() => clientSession.destroy(goodError, { code: 'oops' }), {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
});
|
|
assert.strictEqual(clientSession.destroyed, false);
|
|
assert.strictEqual(stream.destroyed, false);
|
|
|
|
// 3. options.type is not in the allowed set -> throws ERR_INVALID_ARG_VALUE.
|
|
assert.throws(() => clientSession.destroy(goodError, { type: 'bogus' }), {
|
|
code: 'ERR_INVALID_ARG_VALUE',
|
|
});
|
|
assert.strictEqual(clientSession.destroyed, false);
|
|
assert.strictEqual(stream.destroyed, false);
|
|
|
|
// 4. options.reason is the wrong type -> throws ERR_INVALID_ARG_TYPE.
|
|
assert.throws(() => clientSession.destroy(goodError, { reason: 42 }), {
|
|
code: 'ERR_INVALID_ARG_TYPE',
|
|
});
|
|
assert.strictEqual(clientSession.destroyed, false);
|
|
assert.strictEqual(stream.destroyed, false);
|
|
|
|
// 5. options.code does not fit in a QUIC varint -> throws ERR_OUT_OF_RANGE,
|
|
// from both destroy() and close().
|
|
for (const code of [-1, 1.5, NaN, Infinity, 2 ** 62, -1n, 2n ** 62n]) {
|
|
assert.throws(() => clientSession.destroy(goodError, { code }), {
|
|
code: 'ERR_OUT_OF_RANGE',
|
|
});
|
|
assert.throws(() => clientSession.close({ code }), {
|
|
code: 'ERR_OUT_OF_RANGE',
|
|
});
|
|
}
|
|
assert.strictEqual(clientSession.destroyed, false);
|
|
assert.strictEqual(clientSession.closing, false);
|
|
assert.strictEqual(stream.destroyed, false);
|
|
|
|
// Now switch the handlers to expect the real teardown so the final
|
|
// destroy with valid options can run cleanly.
|
|
diagnostics_channel.unsubscribe('quic.session.error', errSub);
|
|
clientSession.onerror = mustCall((err) => { assert.strictEqual(err, goodError); });
|
|
stream.onerror = mustCall((err) => { assert.strictEqual(err, goodError); });
|
|
|
|
// Pre-attach rejection handlers on both sides BEFORE triggering the
|
|
// final destroy, so the rejections do not race ahead of any awaits in
|
|
// the test body. The client rejects with the original `goodError`;
|
|
// the server decodes the CONNECTION_CLOSE frame transport code into
|
|
// an `ERR_QUIC_TRANSPORT_ERROR`. The code is the largest one that fits
|
|
// in a QUIC varint, so it must be accepted and arrive unchanged.
|
|
const maxCode = 2n ** 62n - 1n;
|
|
const clientClosedAssertion = assert.rejects(clientSession.closed, goodError);
|
|
const serverClosedAssertion = assert.rejects(serverSession.closed, mustCall((err) => {
|
|
assert.strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR');
|
|
assert.strictEqual(err.errorCode, maxCode);
|
|
return true;
|
|
}));
|
|
|
|
// 6. Valid options after the failed attempts -> session destroys
|
|
// normally, the underlying handle sends CONNECTION_CLOSE with the
|
|
// supplied transport code, and the local closed promise rejects
|
|
// with the original error.
|
|
clientSession.destroy(goodError, {
|
|
code: maxCode,
|
|
type: 'transport',
|
|
reason: 'after validation throw',
|
|
});
|
|
assert.strictEqual(clientSession.destroyed, true);
|
|
assert.strictEqual(stream.destroyed, true);
|
|
|
|
await clientClosedAssertion;
|
|
await serverClosedAssertion;
|
|
await serverEndpoint.close();
|