fix(db): bound pool disconnect so teardown can't eat CLI output (#1972)

pool.end() against PgBouncer transaction-mode never drained, so disconnect
blocked until the CLI's 10s force-exit fired and process.exit()'d mid-write,
truncating stdout (e.g. #1959's relational query returned empty). Add a
gbrain-owned endPoolBounded(pool): Promise.race of pool.end({timeout}) against
a hard timer, so teardown is bounded regardless of what postgres.js does and is
testable. connection-manager ends its direct + read pools concurrently so the
per-pool bounds don't stack. PGLite disconnect is unaffected.
This commit is contained in:
Garry Tan
2026-06-09 08:35:49 -07:00
parent 2f9e2136da
commit 752e2d2d0d
5 changed files with 98 additions and 6 deletions
+9 -3
View File
@@ -37,7 +37,7 @@
*/
import postgres from 'postgres';
import { resolvePrepare, resolveSessionTimeouts, resolvePoolSize } from './db.ts';
import { resolvePrepare, resolveSessionTimeouts, resolvePoolSize, endPoolBounded } from './db.ts';
import { redactPgUrl } from './url-redact.ts';
import { logConnectionEvent } from './connection-audit.ts';
@@ -400,15 +400,21 @@ export class ConnectionManager {
* (db.ts singleton path). Direct pool is always ours.
*/
async disconnect(): Promise<void> {
// #1972: end both pools concurrently with a gbrain-owned hard bound, so the
// per-pool drains don't STACK (sequential 2s waits → ~4-6s once the
// instance/module pool is added downstream) and neither can hang teardown
// past the CLI's 10s force-exit. endPoolBounded never throws.
const ends: Promise<void>[] = [];
if (this._directPool) {
try { await this._directPool.end(); } catch { /* idempotent */ }
ends.push(endPoolBounded(this._directPool));
this._directPool = null;
this._directInit = null;
}
if (this._readPool && !this._readPoolOwnedExternally) {
try { await this._readPool.end(); } catch { /* idempotent */ }
ends.push(endPoolBounded(this._readPool));
this._readPool = null;
}
await Promise.all(ends);
}
/**
+42 -1
View File
@@ -7,6 +7,47 @@ import { verifySchema } from './schema-verify.ts';
let sql: ReturnType<typeof postgres> | null = null;
let connectedUrl: string | null = null;
/**
* #1972: hard upper bound (seconds) on a single pool `.end()` drain. postgres.js
* accepts `{ timeout }` but applies it internally — against PgBouncer
* transaction-mode the drain can still hang, and a stubbed `.end()` ignores it
* entirely. So `endPoolBounded` ALSO wraps each end in a gbrain-owned
* Promise.race and passes this value as the postgres.js hint so a healthy drain
* still finishes fast.
*/
export const POOL_END_TIMEOUT_SECONDS = 2;
/**
* #1972: end a postgres.js pool with a gbrain-owned hard bound. Resolves as soon
* as `.end()` settles OR after POOL_END_TIMEOUT_SECONDS + a small slack — so
* teardown never hangs (the prior bare `.end()` blocked until the CLI's 10s
* force-exit fired, which `process.exit()`s and truncated pending stdout, e.g.
* #1959's relational query came back empty). Never throws: a teardown that
* rejects is worse than one that races past a stuck socket. The race timer is
* the real guarantee; `{ timeout }` just lets a healthy drain return in ms.
*
* Note callers that close MULTIPLE pools should `Promise.all` them rather than
* awaiting sequentially, so the per-pool bounds run concurrently instead of
* stacking.
*/
export async function endPoolBounded(
pool: { end: (opts?: { timeout?: number }) => Promise<void> },
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
const guard = new Promise<void>((resolve) => {
timer = setTimeout(resolve, POOL_END_TIMEOUT_SECONDS * 1000 + 500);
timer.unref?.();
});
try {
await Promise.race([
pool.end({ timeout: POOL_END_TIMEOUT_SECONDS }).catch(() => { /* idempotent / already-closed */ }),
guard,
]);
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Default pool size for Postgres connections. Users on the Supabase transaction
* pooler (port 6543) or any multi-tenant pooler can lower this to avoid
@@ -258,7 +299,7 @@ export async function disconnect(): Promise<void> {
const s = sql;
sql = null;
connectedUrl = null;
if (s) await s.end();
if (s) await endPoolBounded(s);
}
export async function initSchema(): Promise<void> {
+3 -1
View File
@@ -248,7 +248,9 @@ export class PostgresEngine implements BrainEngine {
this.connectionManager = null;
}
if (this._sql) {
await this._sql.end();
// #1972: gbrain-owned hard bound so a PgBouncer drain that never settles
// can't block teardown until the CLI's 10s force-exit truncates stdout.
await db.endPoolBounded(this._sql);
this._sql = null;
// After this point, _connectionStyle stays 'instance' so a second
// disconnect() is a no-op rather than falling through and clearing