diff --git a/src/QUICConnection.ts b/src/QUICConnection.ts index 93e53e5a..25478313 100644 --- a/src/QUICConnection.ts +++ b/src/QUICConnection.ts @@ -61,6 +61,7 @@ class QUICConnection extends EventTarget { protected logger: Logger; protected socket: QUICSocket; protected timer?: ReturnType; + protected keepAliveInterval?: ReturnType; public readonly closedP: Promise; protected resolveCloseP?: () => void; @@ -107,6 +108,7 @@ class QUICConnection extends EventTarget { new Error(`${type.toString()} ${code.toString()}`), maxReadableStreamBytes, maxWritableStreamBytes, + keepAliveDelay, logger = new Logger(`${this.name} ${scid}`), }: { scid: QUICConnectionId; @@ -117,6 +119,7 @@ class QUICConnection extends EventTarget { codeToReason?: StreamCodeToReason; maxReadableStreamBytes?: number; maxWritableStreamBytes?: number; + keepAliveDelay?: number; logger?: Logger; }) { logger.info(`Connect ${this.name}`); @@ -148,6 +151,7 @@ class QUICConnection extends EventTarget { codeToReason, maxReadableStreamBytes, maxWritableStreamBytes, + keepAliveDelay, logger, }); socket.connectionMap.set(connection.connectionId, connection); @@ -169,6 +173,7 @@ class QUICConnection extends EventTarget { new Error(`${type.toString()} ${code.toString()}`), maxReadableStreamBytes, maxWritableStreamBytes, + keepAliveDelay, logger = new Logger(`${this.name} ${scid}`), }: { scid: QUICConnectionId; @@ -180,6 +185,7 @@ class QUICConnection extends EventTarget { codeToReason?: StreamCodeToReason; maxReadableStreamBytes?: number; maxWritableStreamBytes?: number; + keepAliveDelay?: number; logger?: Logger; }): Promise { logger.info(`Accept ${this.name}`); @@ -211,6 +217,7 @@ class QUICConnection extends EventTarget { codeToReason, maxReadableStreamBytes, maxWritableStreamBytes, + keepAliveDelay, logger, }); socket.connectionMap.set(connection.connectionId, connection); @@ -228,6 +235,7 @@ class QUICConnection extends EventTarget { codeToReason, maxReadableStreamBytes, maxWritableStreamBytes, + keepAliveDelay, logger, }: { type: 'client' | 'server'; @@ -239,6 +247,7 @@ class QUICConnection extends EventTarget { codeToReason: StreamCodeToReason; maxReadableStreamBytes: number | undefined; maxWritableStreamBytes: number | undefined; + keepAliveDelay: number | undefined; logger: Logger; }) { super(); @@ -275,6 +284,14 @@ class QUICConnection extends EventTarget { const { p: handshakeP, resolveP: resolveHandshakeP } = utils.promise(); this.handshakeP = handshakeP; this.resolveHandshakeP = resolveHandshakeP; + // Setting up keep alive interval + if (keepAliveDelay != null) { + this.keepAliveInterval = setTimeout(async () => { + // Trigger an ping frame and send + this.conn.sendAckEliciting(); + await this.send(); + }, keepAliveDelay); + } } // Immediately call this after construction @@ -330,6 +347,8 @@ class QUICConnection extends EventTarget { force?: boolean; } = {}) { this.logger.info(`Destroy ${this.constructor.name}`); + // Clean up keep alive + if (this.keepAliveInterval != null) clearTimeout(this.keepAliveInterval); // Handle destruction concurrently const destroyProms: Array> = []; for (const stream of this.streamMap.values()) { diff --git a/src/native/napi/connection.rs b/src/native/napi/connection.rs index 4830ec12..35a5b126 100644 --- a/src/native/napi/connection.rs +++ b/src/native/napi/connection.rs @@ -1068,4 +1068,9 @@ impl Connection { |s| s.into() ).collect(); } + + #[napi] + pub fn send_ack_eliciting(&self) -> () { + self.0.send_ack_eliciting(); + } } diff --git a/src/native/types.ts b/src/native/types.ts index 34860a4c..f6c3b20c 100644 --- a/src/native/types.ts +++ b/src/native/types.ts @@ -129,6 +129,7 @@ interface Connection { localError(): ConnectionError | null; stats(): Stats; pathStats(): Array; + sendAckEliciting(): void; } interface ConnectionConstructor { diff --git a/tests/QUICClient.test.ts b/tests/QUICClient.test.ts index 3434b07e..dad256fa 100644 --- a/tests/QUICClient.test.ts +++ b/tests/QUICClient.test.ts @@ -1,7 +1,9 @@ import type { Crypto, Host, Port } from '@/types'; import type * as events from '@/events'; +import type QUICConnection from '@/QUICConnection'; import Logger, { LogLevel, StreamHandler, formatting } from '@matrixai/logger'; import { fc, testProp } from '@fast-check/jest'; +import { destroyed } from '@matrixai/async-init'; import QUICClient from '@/QUICClient'; import QUICServer from '@/QUICServer'; import * as errors from '@/errors'; @@ -10,9 +12,10 @@ import QUICSocket from '@/QUICSocket'; import * as testsUtils from './utils'; import { tlsConfigWithCaArb } from './tlsUtils'; import { sleep } from './utils'; +import * as fixtures from './fixtures/certFixtures'; describe(QUICClient.name, () => { - const logger = new Logger(`${QUICClient.name} Test`, LogLevel.WARN, [ + const logger = new Logger(`${QUICClient.name} Test`, LogLevel.DEBUG, [ new StreamHandler( formatting.format`${formatting.level}:${formatting.keys}:${formatting.msg}`, ), @@ -1086,4 +1089,74 @@ describe(QUICClient.name, () => { { numRuns: 1 }, ); }); + describe('keepalive', () => { + const tlsConfig = fixtures.tlsConfigMemRSA1; + test('connection can time out', async () => { + const connectionEventProm = promise(); + const server = new QUICServer({ + crypto, + logger: logger.getChild(QUICServer.name), + config: { + tlsConfig, + verifyPeer: false, + maxIdleTimeout: 100, + }, + }); + server.addEventListener( + 'connection', + (e: events.QUICServerConnectionEvent) => + connectionEventProm.resolveP(e.detail), + ); + await server.start({ + host: '127.0.0.1' as Host, + }); + const client = await QUICClient.createQUICClient({ + host: '::ffff:127.0.0.1' as Host, + port: server.port, + localHost: '::' as Host, + crypto, + logger: logger.getChild(QUICClient.name), + config: { + verifyPeer: false, + maxIdleTimeout: 100, + }, + }); + // Setting no keepalive should cause the connection to time out + // It has cleaned up due to timeout + const clientConnection = client.connection; + const clientTimeoutProm = promise(); + clientConnection.addEventListener( + 'error', + (event: events.QUICConnectionErrorEvent) => { + console.log(event.detail); + if (event.detail instanceof errors.ErrorQUICConnectionTimeout) { + console.log('RESOLVING'); + clientTimeoutProm.resolveP(); + } + }, + ); + console.log('waiting for client timeout'); + await clientTimeoutProm.p; + expect(clientConnection[destroyed]).toBeTrue(); + + // Server connection should time out as well + const serverConnection = await connectionEventProm.p; + const serverTimeoutProm = promise(); + serverConnection.addEventListener( + 'error', + (event: events.QUICConnectionErrorEvent) => { + console.log(event.detail); + if (event.detail instanceof errors.ErrorQUICConnectionTimeout) { + serverTimeoutProm.resolveP(); + } + }, + ); + console.log('waiting for server timeout'); + await serverTimeoutProm.p; + expect(serverConnection[destroyed]).toBeTrue(); + + await client.destroy(); + await server.stop(); + }); + }); });