From 82747202814cde8a01e4045023ba6c8302aa8f7f Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Mon, 29 Jun 2026 04:11:51 +0300 Subject: [PATCH] fix(server): close leaked redis sockets so e2e jest exits (#252) The full-AppModule e2e (apps/server/test/app.e2e-spec.ts) passed but jest never exited, burning CI to its timeout. Diagnosis (process._getActiveHandles after app.close()) showed exactly two ioredis sockets to :6379 still open after shutdown; everything else (BullMQ queues/workers, @nestjs/schedule intervals, nestjs-ioredis, nestjs-kysely pg pool, @nestjs/cache-manager Keyv store, hocuspocus pub/sub) already closes on app.close(). The two leaks were owned-but-never-closed clients: 1. ThrottleModule passed a pre-built `new Redis(...)` instance to ThrottlerStorageRedisService. With an instance, the lib sets disconnectRequired=false, so its onModuleDestroy never disconnects. Pass ioredis options instead so the service owns + disconnects the client. 2. CollaborationGateway created a source `new RedisClient(...)` that RedisSyncExtension only duplicates into pub/sub; the extension's onDestroy disconnects those duplicates but not the source. Keep a reference and disconnect it after the hocuspocus onDestroy hook in destroy(). Both are real lifecycle fixes (production shutdown is now clean too), so no --forceExit is needed. Verified against real Postgres+Redis: - test:e2e (no forceExit, --runInBand) exits 0 in ~18s (was: hung forever) - --detectOpenHandles exits 0 with no open-handle report - active handles after app.close(): none CI timeout-minutes safety nets left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../collaboration/collaboration.gateway.ts | 27 +++++++++++++------ .../integrations/throttle/throttle.module.ts | 23 ++++++++-------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index b46c13c8..b11f14b1 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -33,6 +33,11 @@ export class CollaborationGateway { // @ts-ignore private readonly redisSync: RedisSyncExtension | null = null; + // Source ioredis client that RedisSyncExtension duplicates into its pub/sub + // pair. The extension's onDestroy only disconnects those duplicates, so we + // keep a reference here and disconnect the source ourselves on shutdown + // (otherwise the socket leaks and jest never exits in e2e). + private redisClient: RedisClient | null = null; private readonly withRedis: boolean; constructor( @@ -57,16 +62,17 @@ export class CollaborationGateway { }); if (this.withRedis) { + this.redisClient = new RedisClient({ + host: this.redisConfig.host, + port: this.redisConfig.port, + password: this.redisConfig.password, + db: this.redisConfig.db, + family: this.redisConfig.family, + retryStrategy: createRetryStrategy(), + }); // @ts-ignore this.redisSync = new RedisSyncExtension({ - redis: new RedisClient({ - host: this.redisConfig.host, - port: this.redisConfig.port, - password: this.redisConfig.password, - db: this.redisConfig.db, - family: this.redisConfig.family, - retryStrategy: createRetryStrategy(), - }), + redis: this.redisClient, serverId: `collab-${os?.hostname()}-${nanoid(10)}`, prefix: 'collab', pack, @@ -184,5 +190,10 @@ export class CollaborationGateway { }); await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus }); + + // RedisSyncExtension.onDestroy (run via the hook above) disconnects only the + // duplicated pub/sub clients; the source client created here is ours to close. + this.redisClient?.disconnect(); + this.redisClient = null; } } diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts index 1cb0c41a..db197015 100644 --- a/apps/server/src/integrations/throttle/throttle.module.ts +++ b/apps/server/src/integrations/throttle/throttle.module.ts @@ -10,7 +10,6 @@ import { PAGE_TEMPLATE_THROTTLER, PUBLIC_SHARE_AI_THROTTLER, } from './throttler-names'; -import Redis from 'ioredis'; @Module({ imports: [ @@ -32,16 +31,18 @@ import Redis from 'ioredis'; { name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 }, ], errorMessage: 'Too many requests', - storage: new ThrottlerStorageRedisService( - new Redis({ - host: redisConfig.host, - port: redisConfig.port, - password: redisConfig.password, - db: redisConfig.db, - family: redisConfig.family, - keyPrefix: 'throttle:', - }), - ), + // Pass ioredis options (not a pre-built Redis instance) so + // ThrottlerStorageRedisService owns the connection and disconnects it + // in its onModuleDestroy. Passing an instance leaves disconnectRequired + // false, so the socket would leak on shutdown (e2e jest never exits). + storage: new ThrottlerStorageRedisService({ + host: redisConfig.host, + port: redisConfig.port, + password: redisConfig.password, + db: redisConfig.db, + family: redisConfig.family, + keyPrefix: 'throttle:', + }), }; }, inject: [EnvironmentService],