From 1f838141b5ed296de9979b1fc1f74e593ce2335f Mon Sep 17 00:00:00 2001 From: Will Manning Date: Thu, 27 Aug 2026 13:37:00 -0400 Subject: [PATCH 1/2] Handle idle database client errors Signed-off-by: Will Manning --- web/lib/db.test.ts | 9 +++++++++ web/lib/db.ts | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/web/lib/db.test.ts b/web/lib/db.test.ts index 508a9e2..e133c26 100644 --- a/web/lib/db.test.ts +++ b/web/lib/db.test.ts @@ -55,6 +55,15 @@ describe.skipIf(!dockerAvailable())('db pool roundtrip (testcontainers Postgres) expect(rows).toEqual([{ one: 1, greeting: 'hi' }]); }); + it('handles idle-client errors on the shared pool', () => { + const pool = getPool(); + const error = new Error('fixture idle-client disconnect'); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => pool.emit('error', error)).not.toThrow(); + expect(log).toHaveBeenCalledWith('bench: idle PostgreSQL client error', error); + }); + it('binds interpolated values as parameters rather than concatenating them', async () => { const hostile = '1); DROP TABLE x; --'; const rows = await sql<{ v: string }>`SELECT ${hostile}::text AS v`; diff --git a/web/lib/db.ts b/web/lib/db.ts index 892bf7d..bed2535 100644 --- a/web/lib/db.ts +++ b/web/lib/db.ts @@ -173,7 +173,7 @@ export function passwordProvider(config: DbConfig): () => Promise { } function createPool(config: DbConfig = readConfig()): Pool { - return new Pool({ + const pool = new Pool({ host: config.host, port: config.port, database: config.database, @@ -184,6 +184,13 @@ function createPool(config: DbConfig = readConfig()): Pool { idleTimeoutMillis: config.idleTimeoutMillis, statement_timeout: config.statementTimeoutMillis, }); + // pg emits idle-client connection failures on the pool. Without this + // listener, EventEmitter turns a recoverable disconnect into an uncaught + // exception. The pool removes the failed client and replaces it on demand. + pool.on('error', (error) => { + console.error('bench: idle PostgreSQL client error', error); + }); + return pool; } // A single pool is shared across warm serverless invocations and survives From 6a1e1eaec269094c78e50a99b46fbd07d46aeae5 Mon Sep 17 00:00:00 2001 From: Will Manning Date: Thu, 27 Aug 2026 13:47:50 -0400 Subject: [PATCH 2/2] Run pool error test without Docker Signed-off-by: Will Manning --- web/lib/db.test.ts | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/web/lib/db.test.ts b/web/lib/db.test.ts index e133c26..ecfa586 100644 --- a/web/lib/db.test.ts +++ b/web/lib/db.test.ts @@ -55,15 +55,6 @@ describe.skipIf(!dockerAvailable())('db pool roundtrip (testcontainers Postgres) expect(rows).toEqual([{ one: 1, greeting: 'hi' }]); }); - it('handles idle-client errors on the shared pool', () => { - const pool = getPool(); - const error = new Error('fixture idle-client disconnect'); - const log = vi.spyOn(console, 'error').mockImplementation(() => {}); - - expect(() => pool.emit('error', error)).not.toThrow(); - expect(log).toHaveBeenCalledWith('bench: idle PostgreSQL client error', error); - }); - it('binds interpolated values as parameters rather than concatenating them', async () => { const hostile = '1); DROP TABLE x; --'; const rows = await sql<{ v: string }>`SELECT ${hostile}::text AS v`; @@ -241,6 +232,14 @@ describe('createPool threads idleTimeoutMillis into the pg Pool (via getPool)', ] as const; const saved: Record = {}; + function configurePoolEnvironment(): void { + process.env.BENCH_DB_HOST = 'localhost'; + process.env.BENCH_DB_NAME = 'bench'; + process.env.BENCH_DB_USER = 'bench_reader'; + process.env.BENCH_DB_PASSWORD = 'fixture-pw'; + process.env.BENCH_DB_SSL = 'disable'; + } + beforeEach(async () => { for (const k of ENV_KEYS) saved[k] = process.env[k]; await resetPool(); @@ -255,11 +254,7 @@ describe('createPool threads idleTimeoutMillis into the pg Pool (via getPool)', }); it('uses the resolved timeouts as pool options', () => { - process.env.BENCH_DB_HOST = 'localhost'; - process.env.BENCH_DB_NAME = 'bench'; - process.env.BENCH_DB_USER = 'bench_reader'; - process.env.BENCH_DB_PASSWORD = 'fixture-pw'; // skips the IAM/Signer path - process.env.BENCH_DB_SSL = 'disable'; // avoids the BENCH_DB_CA requirement + configurePoolEnvironment(); process.env.BENCH_DB_IDLE_TIMEOUT_MS = '123456'; process.env.BENCH_DB_STATEMENT_TIMEOUT_MS = '23456'; @@ -271,6 +266,17 @@ describe('createPool threads idleTimeoutMillis into the pg Pool (via getPool)', expect(pool.options.idleTimeoutMillis).toBe(123456); expect(pool.options.statement_timeout).toBe(23456); }); + + it('handles idle-client errors on the shared pool', () => { + configurePoolEnvironment(); + const pool = getPool(); + const error = new Error('fixture idle-client disconnect'); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(pool.listenerCount('error')).toBe(1); + expect(() => pool.emit('error', error)).not.toThrow(); + expect(log).toHaveBeenCalledWith('bench: idle PostgreSQL client error', error); + }); }); describe('requireEnv', () => {