diff --git a/packages/db/src/test-embedded-postgres.ts b/packages/db/src/test-embedded-postgres.ts index 929d17c5f7e..d88ca59c377 100644 --- a/packages/db/src/test-embedded-postgres.ts +++ b/packages/db/src/test-embedded-postgres.ts @@ -106,6 +106,61 @@ function cleanupEmbeddedPostgresTestDirs(dataDir: string) { fs.rmSync(dataDir, { recursive: true, force: true }); } +// Upper bound (ms) on how long we wait for the embedded Postgres cluster to +// stop gracefully before abandoning the wait and returning from the hook. +const EMBEDDED_POSTGRES_STOP_TIMEOUT_MS = 5000; + +// `embedded-postgres@18.1.0-beta.16` exposes only `stop(): Promise` — no +// shutdown-mode argument. Internally it SIGINTs the postgres process (already +// PostgreSQL "fast shutdown") and resolves *only* on the child's `exit` event, +// with no time bound of its own. Under the loaded serial server shard a slow +// shutdown checkpoint can push that past vitest's hookTimeout and hang the +// afterAll hook. So we bound the graceful stop: if it overruns, we stop waiting +// and return so the hook completes. The SIGINT has already been delivered, so +// the abandoned process still exits on its own (and again when the runner exits). +// Errors are swallowed, matching prior behavior. +// +// `cleanupFn` (data-dir reclaim) is chained on the raw `stop()` promise, not on +// the timeout race, so the disposable data dir is removed *only after* `stop()` +// actually settles — i.e. once the child Postgres process has exited. Removing +// it on the timeout path would pull the data files out from under a still-running +// cluster and provoke checkpoint / WAL I/O errors. In the fast path `cleanupFn` +// has run by the time this resolves; in the timeout path it runs asynchronously +// once the abandoned process finally exits. +async function stopEmbeddedPostgresBounded( + instance: EmbeddedPostgresInstance | null, + cleanupFn?: () => void, +): Promise { + if (!instance) { + cleanupFn?.(); + return; + } + let timer: ReturnType | undefined; + const stopped = instance + .stop() + .catch(() => { + // Swallow shutdown errors — the data dir is reclaimed regardless. + }) + .finally(() => { + try { + cleanupFn?.(); + } catch { + // Best-effort reclaim; ignore removal errors. + } + }); + try { + await Promise.race([ + stopped, + new Promise((resolve) => { + timer = setTimeout(resolve, EMBEDDED_POSTGRES_STOP_TIMEOUT_MS); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + function formatEmbeddedPostgresError(error: unknown): string { if (error instanceof Error && error.message.length > 0) return error.message; if (typeof error === "string" && error.length > 0) return error; @@ -131,8 +186,9 @@ async function probeEmbeddedPostgresSupport(): Promise {}); - if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir); + await stopEmbeddedPostgresBounded(instance, () => { + if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir); + }); } } @@ -165,13 +221,15 @@ export async function startEmbeddedPostgresTestDatabase( return { connectionString, cleanup: async () => { - await instance?.stop().catch(() => {}); - if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir); + await stopEmbeddedPostgresBounded(instance, () => { + if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir); + }); }, }; } catch (error) { - await instance?.stop().catch(() => {}); - if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir); + await stopEmbeddedPostgresBounded(instance, () => { + if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir); + }); throw new Error( `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, ); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index d07d5a3acb8..cd2fafdb89a 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -3,6 +3,15 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", + // Each server suite boots + tears down its own embedded Postgres in + // beforeAll/afterAll. Under the loaded serial shard (maxWorkers=1) the + // graceful shutdown can occasionally cross vitest's default 10s hookTimeout, + // producing flaky "Hook timed out in 10000ms" afterAll failures on CI. Give + // the boot/teardown hooks generous headroom; 30s is far above the observed + // worst-case teardown yet still catches a genuinely hung hook. teardownTimeout + // mirrors it for the same reason. + hookTimeout: 30000, + teardownTimeout: 30000, isolate: true, maxConcurrency: 1, maxWorkers: 1,