Compare commits

...

1 Commits

Author SHA1 Message Date
claude code agent 227
8274720281 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) <noreply@anthropic.com>
2026-06-29 04:11:51 +03:00
2 changed files with 31 additions and 19 deletions

View File

@@ -33,6 +33,11 @@ export class CollaborationGateway {
// @ts-ignore // @ts-ignore
private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null = private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null =
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; private readonly withRedis: boolean;
constructor( constructor(
@@ -57,16 +62,17 @@ export class CollaborationGateway {
}); });
if (this.withRedis) { 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 // @ts-ignore
this.redisSync = new RedisSyncExtension({ this.redisSync = new RedisSyncExtension({
redis: new RedisClient({ redis: this.redisClient,
host: this.redisConfig.host,
port: this.redisConfig.port,
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
retryStrategy: createRetryStrategy(),
}),
serverId: `collab-${os?.hostname()}-${nanoid(10)}`, serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
prefix: 'collab', prefix: 'collab',
pack, pack,
@@ -184,5 +190,10 @@ export class CollaborationGateway {
}); });
await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus }); 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;
} }
} }

View File

@@ -10,7 +10,6 @@ import {
PAGE_TEMPLATE_THROTTLER, PAGE_TEMPLATE_THROTTLER,
PUBLIC_SHARE_AI_THROTTLER, PUBLIC_SHARE_AI_THROTTLER,
} from './throttler-names'; } from './throttler-names';
import Redis from 'ioredis';
@Module({ @Module({
imports: [ imports: [
@@ -32,16 +31,18 @@ import Redis from 'ioredis';
{ name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 }, { name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 },
], ],
errorMessage: 'Too many requests', errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService( // Pass ioredis options (not a pre-built Redis instance) so
new Redis({ // ThrottlerStorageRedisService owns the connection and disconnects it
host: redisConfig.host, // in its onModuleDestroy. Passing an instance leaves disconnectRequired
port: redisConfig.port, // false, so the socket would leak on shutdown (e2e jest never exits).
password: redisConfig.password, storage: new ThrottlerStorageRedisService({
db: redisConfig.db, host: redisConfig.host,
family: redisConfig.family, port: redisConfig.port,
keyPrefix: 'throttle:', password: redisConfig.password,
}), db: redisConfig.db,
), family: redisConfig.family,
keyPrefix: 'throttle:',
}),
}; };
}, },
inject: [EnvironmentService], inject: [EnvironmentService],