From 9355b030a2c5d1082fcbc03b801d3cc02fa40d47 Mon Sep 17 00:00:00 2001 From: Li-Chieh Kung Date: Tue, 22 Sep 2026 13:06:47 -0400 Subject: [PATCH] Handle Postgres pool errors instead of crashing An idle client disconnected by a database restart or failover makes pg.Pool emit `error`; with no listener, Node throws and the API and worker processes exit. Register a listener that logs via backgroundFailure (error name only, no connection details). Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/db.ts | 10 +++++++++- tests/persistence.test.ts | 13 ++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index a40c9a2a7..290753221 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -2,6 +2,7 @@ import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import { PGlite } from "@electric-sql/pglite"; import pg from "pg"; +import { backgroundFailure } from "./log.ts"; type Row = { data: Record }; interface Database { @@ -111,12 +112,19 @@ export class Store { } } +/** Idle clients can be disconnected by a database restart; without a listener pg's `error` event crashes the process. */ +export function createPool(connectionString: string) { + const pool = new pg.Pool({ connectionString, max: 5 }); + pool.on("error", (error) => backgroundFailure("postgres pool", error)); + return pool; +} + export async function createStore( options: { dataDir?: string; databaseUrl?: string } = {}, ): Promise { let database: Database; if (options.databaseUrl) { - const pool = new pg.Pool({ connectionString: options.databaseUrl, max: 5 }); + const pool = createPool(options.databaseUrl); database = { query: async (sql, params) => pool.query(sql, params), close: () => pool.end() }; } else { if (options.dataDir) await mkdir(dirname(options.dataDir), { recursive: true, mode: 0o700 }); diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts index 73eaac487..e371655cb 100644 --- a/tests/persistence.test.ts +++ b/tests/persistence.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { createStore } from "../apps/server/src/db.ts"; +import { createPool, createStore } from "../apps/server/src/db.ts"; test("fresh nested data directory starts and survives a database restart", async () => { const root = await mkdtemp(join(tmpdir(), "openmuse-db-")); @@ -20,3 +20,14 @@ test("fresh nested data directory starts and survives a database restart", async await rm(root, { recursive: true, force: true }); } }); + +test("idle Postgres client errors are logged instead of crashing the process", async (t) => { + const logged = t.mock.method(console, "error", () => {}); + const pool = createPool("postgres://127.0.0.1:1/openmuse"); + try { + assert.doesNotThrow(() => pool.emit("error", new Error("terminating connection"))); + assert.equal(logged.mock.callCount(), 1); + } finally { + await pool.end(); + } +});