From c0d5e9596241b84bfa0409e6dfffab57c1960820 Mon Sep 17 00:00:00 2001 From: github-manager Date: Fri, 17 Apr 2026 23:15:21 -0400 Subject: [PATCH 01/12] fix: properly await worker teardown in integration tests to prevent open handles AfterAll was calling worker.close() with a callback but not awaiting it, so Cucumber resolved the hook immediately leaving the HTTP server, Redis, and DB connections open. Node.js would then hang indefinitely waiting for those handles to close. Co-Authored-By: Claude Sonnet 4.6 --- test/integration/features/shared.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index d515d00f..dda1cd6f 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -58,12 +58,15 @@ BeforeAll({ timeout: 1000 }, async function () { }) AfterAll(async function() { - worker.close(async () => { - await Promise.all([ - dbClient.destroy(), - rrDbClient.destroy(), - cacheClient.disconnect(), - ]) + await new Promise((resolve) => { + worker.close(async () => { + await Promise.all([ + cacheClient.disconnect(), + dbClient.destroy(), + rrDbClient.destroy(), + ]) + resolve() + }) }) }) From fac6ee65eaa50dc03ab253152cc49022a4bd3996 Mon Sep 17 00:00:00 2001 From: github-manager Date: Fri, 17 Apr 2026 23:19:15 -0400 Subject: [PATCH 02/12] fix: guard Redis disconnect and increase AfterAll timeout Redis client may not be connected when rate limits are disabled in tests, causing disconnect() to throw ClientClosedError. Guard with isOpen check. Also raise AfterAll timeout to 30s to allow server and DB to shut down. Co-Authored-By: Claude Sonnet 4.6 --- test/integration/features/shared.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index dda1cd6f..039bcf48 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -57,11 +57,11 @@ BeforeAll({ timeout: 1000 }, async function () { worker.run() }) -AfterAll(async function() { +AfterAll({ timeout: 30000 }, async function() { await new Promise((resolve) => { worker.close(async () => { await Promise.all([ - cacheClient.disconnect(), + cacheClient.isOpen ? cacheClient.disconnect() : Promise.resolve(), dbClient.destroy(), rrDbClient.destroy(), ]) From 827adf3c12630c26119167b124dd71967b743306 Mon Sep 17 00:00:00 2001 From: github-manager Date: Fri, 17 Apr 2026 23:25:05 -0400 Subject: [PATCH 03/12] fix: close all HTTP keep-alive connections before server shutdown http.Server.close() waits for keep-alive connections to drain naturally, which can prevent the close callback from ever firing. closeAllConnections() forces them closed immediately so the server shuts down reliably in CI. Co-Authored-By: Claude Sonnet 4.6 --- src/adapters/web-server-adapter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/adapters/web-server-adapter.ts b/src/adapters/web-server-adapter.ts index c56cb473..df1cdea8 100644 --- a/src/adapters/web-server-adapter.ts +++ b/src/adapters/web-server-adapter.ts @@ -42,6 +42,7 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter public close(callback?: () => void): void { debug('closing') + this.webServer.closeAllConnections() this.webServer.close(() => { this.webServer.removeAllListeners() this.removeAllListeners() From 170517befff9db1dfd580f80277ef91cf6ce074d Mon Sep 17 00:00:00 2001 From: github-manager Date: Fri, 17 Apr 2026 23:37:02 -0400 Subject: [PATCH 04/12] fix: remove ReplaySubject windowTime to prevent dangling timers after tests ReplaySubject(2, 1000) schedules recurring setTimeout calls to evict stale buffered events. These timers outlive the test run and keep Node.js alive after Cucumber finishes, preventing clean process exit on CI. Co-Authored-By: Claude Sonnet 4.6 --- test/integration/features/shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 039bcf48..384c86d5 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -106,7 +106,7 @@ Given(/someone called (\w+)/, async function(name: string) { const projection = (raw: MessageEvent) => JSON.parse(raw.data.toString('utf8')) - const replaySubject = new ReplaySubject(2, 1000) + const replaySubject = new ReplaySubject(2) fromEvent(connection, 'message').pipe(map(projection) as any,takeUntil(close)).subscribe(replaySubject) From 6e1547da90dab93920eb9100a23e15d43b252750 Mon Sep 17 00:00:00 2001 From: github-manager Date: Fri, 17 Apr 2026 23:41:37 -0400 Subject: [PATCH 05/12] fix: add --force-exit to cucumber to handle unclosed handles after tests The slidingWindowRateLimiterFactory creates a RedisAdapter singleton that calls client.connect() on first WebSocket connection and has no teardown path. --force-exit ensures the process exits cleanly after tests complete rather than hanging on the open Redis socket. Co-Authored-By: Claude Sonnet 4.6 --- cucumber.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cucumber.js b/cucumber.js index 2008e25c..6b93842a 100644 --- a/cucumber.js +++ b/cucumber.js @@ -4,6 +4,7 @@ const base = [ '--require test/integration/features/**/*.ts', '--require test/integration/features/*.ts', '--publish-quiet', + '--force-exit', ].join(' ') const config = [ From 1e08843ccec28244512cf686e067aaf290e887c3 Mon Sep 17 00:00:00 2001 From: github-manager Date: Fri, 17 Apr 2026 23:45:27 -0400 Subject: [PATCH 06/12] fix: explicitly exit after AfterAll cleanup instead of --force-exit --force-exit calls process.exit before nyc flushes coverage, causing integration coverage to drop. Instead, call process.exit(0) at the end of AfterAll after all cleanup awaits complete, giving nyc a clean exit. Co-Authored-By: Claude Sonnet 4.6 --- cucumber.js | 1 - test/integration/features/shared.ts | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cucumber.js b/cucumber.js index 6b93842a..2008e25c 100644 --- a/cucumber.js +++ b/cucumber.js @@ -4,7 +4,6 @@ const base = [ '--require test/integration/features/**/*.ts', '--require test/integration/features/*.ts', '--publish-quiet', - '--force-exit', ].join(' ') const config = [ diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 384c86d5..3fbc3ef4 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -68,6 +68,9 @@ AfterAll({ timeout: 30000 }, async function() { resolve() }) }) + // Rate limiter holds a Redis singleton with no teardown path; exit explicitly + // so nyc can flush coverage before the process is killed. + process.exit(0) }) Before(function () { From d0c083a747fe7e49557553c6c518268cb704ed71 Mon Sep 17 00:00:00 2001 From: github-manager Date: Fri, 17 Apr 2026 23:56:02 -0400 Subject: [PATCH 07/12] revert: remove unnecessary closeAllConnections and ReplaySubject windowTime changes These were exploratory fixes for the hang; the actual fix was process.exit(0) in AfterAll after cleanup completes. Co-Authored-By: Claude Sonnet 4.6 --- src/adapters/web-server-adapter.ts | 1 - test/integration/features/shared.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/adapters/web-server-adapter.ts b/src/adapters/web-server-adapter.ts index df1cdea8..c56cb473 100644 --- a/src/adapters/web-server-adapter.ts +++ b/src/adapters/web-server-adapter.ts @@ -42,7 +42,6 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter public close(callback?: () => void): void { debug('closing') - this.webServer.closeAllConnections() this.webServer.close(() => { this.webServer.removeAllListeners() this.removeAllListeners() diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 3fbc3ef4..7fa1ba02 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -109,7 +109,7 @@ Given(/someone called (\w+)/, async function(name: string) { const projection = (raw: MessageEvent) => JSON.parse(raw.data.toString('utf8')) - const replaySubject = new ReplaySubject(2) + const replaySubject = new ReplaySubject(2, 1000) fromEvent(connection, 'message').pipe(map(projection) as any,takeUntil(close)).subscribe(replaySubject) From a3d12a69820c03a6edbff1bcd5a3cf9455698195 Mon Sep 17 00:00:00 2001 From: github-manager Date: Sat, 18 Apr 2026 00:00:42 -0400 Subject: [PATCH 08/12] fix: use unref'd watchdog timer instead of immediate process.exit process.exit(0) in AfterAll kills the process before Cucumber prints its summary. An unref'd 2s timeout lets Cucumber finish output, then force-exits if the Redis singleton is still keeping the process alive. Co-Authored-By: Claude Sonnet 4.6 --- test/integration/features/shared.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 7fa1ba02..e149c54a 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -68,9 +68,10 @@ AfterAll({ timeout: 30000 }, async function() { resolve() }) }) - // Rate limiter holds a Redis singleton with no teardown path; exit explicitly - // so nyc can flush coverage before the process is killed. - process.exit(0) + // The rate limiter's Redis singleton has no teardown path and keeps the process + // alive. Schedule a watchdog exit so Cucumber can print its summary first; + // unref() means the timer won't prevent a natural exit if handles close sooner. + setTimeout(() => process.exit(0), 2000).unref() }) Before(function () { From 5d21b4ec5d2005fad22998e91afc7a29212a1a33 Mon Sep 17 00:00:00 2001 From: github-manager Date: Sat, 18 Apr 2026 00:05:04 -0400 Subject: [PATCH 09/12] fix: close cache client as part of worker shutdown Add closeCacheClient() to the cache module and call it inside AppWorker.close() after the adapter shuts down. This gives the Redis singleton a proper teardown path instead of relying on test-only workarounds. Co-Authored-By: Claude Sonnet 4.6 --- src/app/worker.ts | 6 +++++- src/cache/client.ts | 7 +++++++ test/integration/features/shared.ts | 14 +------------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/app/worker.ts b/src/app/worker.ts index b5cce811..1693595a 100644 --- a/src/app/worker.ts +++ b/src/app/worker.ts @@ -1,6 +1,7 @@ import { IRunnable } from '../@types/base' import { IWebSocketServerAdapter } from '../@types/adapters' +import { closeCacheClient } from '../cache/client' import { createLogger } from '../factories/logger-factory' import { FSWatcher } from 'fs' import { SettingsStatic } from '../utils/settings' @@ -57,7 +58,10 @@ export class AppWorker implements IRunnable { watcher.close() } } - this.adapter.close(callback) + this.adapter.close(async () => { + await closeCacheClient() + callback?.() + }) debug('closed') } } diff --git a/src/cache/client.ts b/src/cache/client.ts index 8c743753..3d3ce584 100644 --- a/src/cache/client.ts +++ b/src/cache/client.ts @@ -23,3 +23,10 @@ export const getCacheClient = (): CacheClient => { return instance } + +export const closeCacheClient = async (): Promise => { + if (instance?.isOpen) { + await instance.disconnect() + instance = undefined + } +} diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index e149c54a..768e77cb 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -16,10 +16,8 @@ import Sinon from 'sinon' import { connect, createIdentity, createSubscription, sendEvent } from './helpers' import { getMasterDbClient, getReadReplicaDbClient } from '../../../src/database/client' import { AppWorker } from '../../../src/app/worker' -import { CacheClient } from '../../../src/@types/cache' import { DatabaseClient } from '../../../src/@types/base' import { Event } from '../../../src/@types/event' -import { getCacheClient } from '../../../src/cache/client' import { SettingsStatic } from '../../../src/utils/settings' import { workerFactory } from '../../../src/factories/worker-factory' @@ -29,7 +27,6 @@ let worker: AppWorker let dbClient: DatabaseClient let rrDbClient: DatabaseClient -let cacheClient: CacheClient export const streams = new WeakMap>() @@ -38,7 +35,6 @@ BeforeAll({ timeout: 1000 }, async function () { process.env.SECRET = Math.random().toString().repeat(6) dbClient = getMasterDbClient() rrDbClient = getReadReplicaDbClient() - cacheClient = getCacheClient() await dbClient.raw('SELECT 1=1') await rrDbClient.raw('SELECT 1=1') Sinon.stub(SettingsStatic, 'watchSettings') @@ -60,18 +56,10 @@ BeforeAll({ timeout: 1000 }, async function () { AfterAll({ timeout: 30000 }, async function() { await new Promise((resolve) => { worker.close(async () => { - await Promise.all([ - cacheClient.isOpen ? cacheClient.disconnect() : Promise.resolve(), - dbClient.destroy(), - rrDbClient.destroy(), - ]) + await Promise.all([dbClient.destroy(), rrDbClient.destroy()]) resolve() }) }) - // The rate limiter's Redis singleton has no teardown path and keeps the process - // alive. Schedule a watchdog exit so Cucumber can print its summary first; - // unref() means the timer won't prevent a natural exit if handles close sooner. - setTimeout(() => process.exit(0), 2000).unref() }) Before(function () { From fdf5d3f48887e5a80aeab823c3059c9f2932fc1d Mon Sep 17 00:00:00 2001 From: github-manager Date: Sat, 18 Apr 2026 00:10:44 -0400 Subject: [PATCH 10/12] fix: add watchdog timer with open handle diagnostics in AfterAll Process still hangs after worker.close() + DB destroy. Watchdog fires after 2s, logs all active handles/requests via process._getActiveHandles() so we can identify the culprit, then force-exits. Co-Authored-By: Claude Sonnet 4.6 --- test/integration/features/shared.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 768e77cb..da140def 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -60,6 +60,15 @@ AfterAll({ timeout: 30000 }, async function() { resolve() }) }) + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handles = (process as any)._getActiveHandles() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requests = (process as any)._getActiveRequests() + console.error('[afterall] open handles:', handles.map((h: any) => h?.constructor?.name)) + console.error('[afterall] open requests:', requests.map((r: any) => r?.constructor?.name)) + process.exit(0) + }, 2000).unref() }) Before(function () { From ffb56e54d92a574b9fa84b16141456df0ba1e57c Mon Sep 17 00:00:00 2001 From: github-manager Date: Sat, 18 Apr 2026 00:16:15 -0400 Subject: [PATCH 11/12] chore: add remoteAddress/port to open handle diagnostics Co-Authored-By: Claude Sonnet 4.6 --- test/integration/features/shared.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index da140def..76ef5d49 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -65,7 +65,13 @@ AfterAll({ timeout: 30000 }, async function() { const handles = (process as any)._getActiveHandles() // eslint-disable-next-line @typescript-eslint/no-explicit-any const requests = (process as any)._getActiveRequests() - console.error('[afterall] open handles:', handles.map((h: any) => h?.constructor?.name)) + console.error('[afterall] open handles:', handles.map((h: any) => ({ + type: h?.constructor?.name, + remoteAddress: h?.remoteAddress, + remotePort: h?.remotePort, + localPort: h?.localPort, + servername: h?.servername, + }))) console.error('[afterall] open requests:', requests.map((r: any) => r?.constructor?.name)) process.exit(0) }, 2000).unref() From db344bdce587ef6d151c26f430ed683da1b245ac Mon Sep 17 00:00:00 2001 From: github-manager Date: Sat, 18 Apr 2026 00:21:28 -0400 Subject: [PATCH 12/12] fix: remove --publish to prevent hanging TLS connection to Cucumber cloud The --publish flag was uploading test results to reports.cucumber.io via a TLS connection to Cloudflare R2, which kept the process alive after tests completed. Reports are already saved locally as HTML and JSON. Co-Authored-By: Claude Sonnet 4.6 --- cucumber.js | 1 - test/integration/features/shared.ts | 15 --------------- 2 files changed, 16 deletions(-) diff --git a/cucumber.js b/cucumber.js index 2008e25c..82ec9847 100644 --- a/cucumber.js +++ b/cucumber.js @@ -11,7 +11,6 @@ const config = [ '--format @cucumber/pretty-formatter', '--format html:.test-reports/integration/report.html', '--format json:.test-reports/integration/report.json', - '--publish', ].join(' ') module.exports = { diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 76ef5d49..768e77cb 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -60,21 +60,6 @@ AfterAll({ timeout: 30000 }, async function() { resolve() }) }) - setTimeout(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handles = (process as any)._getActiveHandles() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const requests = (process as any)._getActiveRequests() - console.error('[afterall] open handles:', handles.map((h: any) => ({ - type: h?.constructor?.name, - remoteAddress: h?.remoteAddress, - remotePort: h?.remotePort, - localPort: h?.localPort, - servername: h?.servername, - }))) - console.error('[afterall] open requests:', requests.map((r: any) => r?.constructor?.name)) - process.exit(0) - }, 2000).unref() }) Before(function () {