Files
PaperClipAI/packages/db/src/test-embedded-postgres.ts
T
DottaandPaperclip 33fc7fcddc fix(db): clean up owned PostgreSQL test servers (#18627)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Database, server, and CLI tests use real PostgreSQL fixtures.
> - Each fixture must own its server until PostgreSQL has stopped.
> - The old teardown returned after five seconds even when its server
remained alive.
> - A startup failure could then mark the host unsupported and skip
database coverage.
> - This pull request waits for owned shutdown and makes supported-host
startup failures fail collection.
> - A shared-server command reduces process creation while keeping each
fixture's database isolated.

## Linked Issues or Issue Description

Refs: #7756 for related development-process ownership. That
development-server issue remains outside this test-fixture fix.
Related PR: #16799 collects macOS startup diagnostics. This PR changes
test shutdown and failure behavior.

**What happened?**

Repeated test runs can exhaust macOS PostgreSQL semaphore resources. The
fixture abandoned slow shutdown and swallowed stop errors. The support
probe converted ordinary startup failures into skipped integration
coverage.

**Expected behavior**

Tests must stop their PostgreSQL servers, retain files if stop fails,
and report resource exhaustion as a failure. Fixtures on a shared server
must not close another database's clients.

**Steps to reproduce**

Start a fixture whose PostgreSQL stop exceeds five seconds, or make a
supported-host startup fail with semaphore exhaustion. The former helper
returned from teardown or reported unsupported coverage. The new
regression tests exercise both paths without exhausting host resources.

**Paperclip version or commit**

Source checkout based on current master.

**Deployment mode**

Local source development and CI tests.

## What Changed

- Add explicit ownership for test PostgreSQL children. Wait for fast
shutdown, then request immediate PostgreSQL shutdown if needed. Keep
cleanup idempotent and fail when exit cannot be confirmed.
- Publish reusable templates only after clean shutdown. Do not publish a
crashed or immediately stopped cluster.
- Stop owned children on POSIX worker signals and process exit. Preserve
test directories when shutdown fails. Remove conflicting dependency exit
hooks.
- Retry port collisions only. Fail collection for resource exhaustion,
unavailable configured servers, and other startup failures on supported
platforms. Preserve skips for explicitly unsupported platforms.
- Move administrative-server support into the common fixture used by
database, server, and CLI tests. Close clients at database scope and
migrate cloned templates before use.
- Add a directly invoked shared-server runner to own one test server and
run suites against separate disposable databases. Preserve child exit
status and support an existing disposable server.
- Unify cancellation: stop and wait for the test process group before
PostgreSQL teardown. Add nested-process coverage, real worker-exit
coverage, shutdown regressions, shared-database isolation checks, and
setup documentation. Replace ignored heartbeat completion polls with the
existing execution drain before asserting persisted outcomes.
- Finish owned cluster-directory cleanup after signal-driven
cancellation. Preserve prepared templates across shared-fixture tests.
Remove stopped directories asynchronously, using native removal on POSIX
with a Node fallback.
- Align database and CLI fixture hook budgets with the existing
30-second server budget. This lets the ten-second shutdown protocol
finish and report errors. The idle-sleep suite uses the existing
90-second fixture budget for its unusually large final directory
cleanup.

## Verification

- The semaphore-exhaustion regression failed against the old helper
because it resolved to `supported: false` instead of rejecting.
- All 37 focused tests pass on the final runtime code (`e4de00ab40`).
They include real worker exit, SIGTERM, nested-process SIGINT/SIGTERM
cancellation with PostgreSQL available during child cleanup, failed
template shutdown, unsupported platforms, stale SQL templates, and
directory removal. A child exiting with status 7 makes the shared-server
runner return status 7 after cleanup.
- Full local `pnpm -r typecheck` and `pnpm build` pass on the final
runtime code (`e4de00ab40`). Subsequent changes only adjust server-test
lifecycle waits, the idle-sleep cleanup budget, and documentation; the
server typecheck also passes with those final tests.
- A fresh shared-server run on `90b65fd8ad` passed all 68 database files
and 297 tests. Earlier preparation runs had two ten-second teardown
timeouts and a stale-template failure; the final helper migrates cloned
templates and permits the full shutdown budget. These earlier failures
remain accounted for here.
- The local full repository attempt stopped on a Discord receiver-lease
test after 338 passing chat tests. That exact case passed in isolation.
This full-suite attempt is not a pass.
- On `90b65fd8ad`, [server shard
6](https://github.com/paperclipai/paperclip/actions/runs/41496495253/job/125452949369)
and its one unchanged retry reported two 15-second cached-input
heartbeat timeouts and a 30-second idle-sleep teardown timeout. All ten
cached-input cases then passed locally with their original deadlines. A
local parallel run of the full files reported eight behavioral/hook
failures and two suite teardown timeouts (281 tests passed).
- Serial idle-sleep runs passed all 225 test assertions but overran the
30-second final hook. Tracing measured client closure in 4 ms,
PostgreSQL exit after 5 seconds, and synchronous directory removal
taking another 35 seconds. Asynchronous Node removal and native removal
also exceeded that hook under the full workload. Cleanup now remains
asynchronous and awaited, and only this heavy suite's final hook uses
the existing 90-second fixture budget. No PostgreSQL shutdown deadline
or test assertion was weakened. The serial run then passed the complete
idle-sleep file, including all 225 tests and teardown. Its heartbeat
file passed 63/64 cases, exposing an ignored three-second completion
poll. After replacing that poll with an execution drain, another timer
case exposed the same pattern (63/64 passed). All remaining ignored
completion polls now await execution drain without changing test
deadlines. The complete heartbeat file then passed all 64 tests. The
intentional asserted lock-contention poll is unchanged. The final server
typecheck passes, and a process audit found no PostgreSQL server left by
the completed local runs.
- On final head `2cf316d3f571e4516890cba743ecffeb9703cce3`, the
previously failing server shard 6, database lane, full typecheck, and
build passed. The [first CI
attempt](https://github.com/paperclipai/paperclip/actions/runs/41506358136)
then lost self-hosted runners in the canary and serialized-server lanes,
with additional jobs cancelled. The server-2 lane had one 30-second
timeout in the unchanged workspace-recovery HTTP Retry test (2,282 other
tests passed). That exact test passed locally with its original deadline
(28.78 seconds including imports). One unchanged retry of only failed
and cancelled jobs completed successfully on the exact final commit
(workflow attempt 2). The workspace-recovery timeout case and every
interrupted lane passed; successful first-attempt jobs were retained.
The additional Paperclip Review check remains queued, waiting for its
assigned agent; it has no unresolved GitHub review threads. Greptile
completed review on this exact final head at 5/5, with zero unresolved
review threads. All four original review findings and their threads are
resolved; additional Windows shutdown, cancellation-directory, and
template-reuse findings are fixed.
- `pnpm check:module-boundaries` and `git diff --check` passed. Post-run
process checks found no PostgreSQL servers left by the completed
verification runs.

## Risks

- Immediate PostgreSQL shutdown can require crash recovery. This
fallback applies to disposable test clusters and runs only after fast
shutdown times out.
- No process can handle `SIGKILL` or machine power loss. Host resource
cleanup remains a separate backstop.
- Directory cleanup on cancellation applies to the shared-server
runner's own cluster. Directly terminated test workers stop their
PostgreSQL process but retain disposable files for inspection. The
process-exit guard cannot await file removal.
- The two-second `pg_ctl -W` deadline bounds signal dispatch. It is
separate from the five-second and thirty-second server-exit waits. A
control-command failure fails cleanup and retains its directory; it does
not report successful cleanup or force-delete live files.
- Shared fixtures require a disposable PostgreSQL server and a role that
can create databases. The directly invoked shared command is opt-in; the
existing `pnpm test` command remains available.
- These changes do not stop unrelated development servers or delete
host-wide IPC resources. Clean shutdown uses the owned `pg_ctl` binary
on every supported platform. Its real control path was tested on macOS.
A Windows host was not available for live verification; POSIX signal
tests skip on Windows.

## Model Used

- OpenAI Codex, based on GPT-6. The runtime does not expose the exact
serving variant or context-window size. The agent used reasoning, shell
execution, code editing, and GitHub tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-02 14:24:48 -06:00

698 lines
29 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { applyPendingMigrations, closeRegisteredClients, ensurePostgresDatabase } from "./client.js";
import {
createEmbeddedPostgresLogBuffer,
formatEmbeddedPostgresError,
} from "./embedded-postgres-error.js";
import { prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js";
import { loadWithoutEmbeddedPostgresExitHooks } from "./embedded-postgres-lifecycle.js";
import { ownTestPostgres, TestPostgresShutdownError, type TestPostgresInstance } from "./test-postgres-owner.js";
import { checkTestPostgresAdmin, startTestPostgresDatabase, testPostgresAdminUrl } from "./test-postgres-admin.js";
// Time budget (ms) for a vitest test in the embedded-Postgres cost class: a
// test that starts an embedded Postgres cluster and runs migrations. Measured
// evidence: this cost class normally finishes in well under 10s. Under a
// contended CI runner the same test took up to 4.9x longer. This budget
// gives about 10x headroom over the clean time, so a contended run still
// passes while a genuine hang still fails fast.
export const EMBEDDED_POSTGRES_TEST_TIMEOUT_MS = 90_000;
type EmbeddedPostgresInstance = ReturnType<typeof ownTestPostgres>;
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => TestPostgresInstance;
export type EmbeddedPostgresTestSupport = {
supported: boolean;
reason?: string;
};
export type EmbeddedPostgresTestDatabase = {
connectionString: string;
cleanup(): Promise<void>;
};
let embeddedPostgresSupportPromise: Promise<EmbeddedPostgresTestSupport> | null = null;
const DEFAULT_PAPERCLIP_EMBEDDED_POSTGRES_PORT = 54329;
function getReservedTestPorts(): Set<number> {
const configuredPorts = [
DEFAULT_PAPERCLIP_EMBEDDED_POSTGRES_PORT,
Number.parseInt(process.env.PAPERCLIP_EMBEDDED_POSTGRES_PORT ?? "", 10),
...String(process.env.PAPERCLIP_TEST_POSTGRES_RESERVED_PORTS ?? "")
.split(",")
.map((value) => Number.parseInt(value.trim(), 10)),
];
return new Set(configuredPorts.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535));
}
type EmbeddedPostgresCtorProvider = () => Promise<EmbeddedPostgresCtor>;
async function loadEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await loadWithoutEmbeddedPostgresExitHooks(() => import("embedded-postgres"));
await prepareEmbeddedPostgresNativeRuntime();
return mod.default as unknown as EmbeddedPostgresCtor;
}
let embeddedPostgresCtorProvider: EmbeddedPostgresCtorProvider = loadEmbeddedPostgresCtor;
// Test seam. Replace the embedded-postgres constructor provider so a test can
// simulate a failed start without the native runtime. Pass `null` to restore
// the default provider. This module is test support only, so the seam is safe.
export function __setEmbeddedPostgresCtorProviderForTests(
provider: EmbeddedPostgresCtorProvider | null,
): void {
embeddedPostgresCtorProvider = provider ?? loadEmbeddedPostgresCtor;
}
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
return await embeddedPostgresCtorProvider();
}
async function getAvailablePort(): Promise<number> {
const reservedPorts = getReservedTestPorts();
for (let attempt = 0; attempt < 20; attempt += 1) {
const port = await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
if (!reservedPorts.has(port)) return port;
}
throw new Error(
`Failed to allocate embedded Postgres test port outside reserved Paperclip ports: ${[
...reservedPorts,
].join(", ")}`,
);
}
const EMBEDDED_POSTGRES_USER = "paperclip";
const EMBEDDED_POSTGRES_PASSWORD = "paperclip";
const EMBEDDED_POSTGRES_INITDB_FLAGS = ["--encoding=UTF8", "--locale=C", "--lc-messages=C"];
async function createEmbeddedPostgresTestInstance(tempDirPrefix: string) {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix));
try {
return await createEmbeddedPostgresTestInstanceAt(dataDir);
} catch (error) {
await cleanupEmbeddedPostgresTestDirs(dataDir);
throw error;
}
}
// Wraps an embedded-postgres instance around `dataDir`. The caller decides
// whether the directory still needs `initialise()` (a fresh cluster) or already
// holds a copied cluster that only needs `start()`.
async function createEmbeddedPostgresTestInstanceAt(dataDir: string) {
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
// Postgres writes the true reason for a failed start to its output, for
// example `could not bind IPv4 address "127.0.0.1": Address already in use`.
// The `start()` rejection carries an empty message, so we capture the output
// in a bounded buffer and surface it in the thrown error.
const logBuffer = createEmbeddedPostgresLogBuffer();
const instance = ownTestPostgres(new EmbeddedPostgres({
databaseDir: dataDir,
user: EMBEDDED_POSTGRES_USER,
password: EMBEDDED_POSTGRES_PASSWORD,
port,
persistent: true,
initdbFlags: EMBEDDED_POSTGRES_INITDB_FLAGS,
onLog: (message) => logBuffer.append(message),
onError: (message) => logBuffer.append(message),
}), dataDir);
return { dataDir, port, instance, getRecentLogs: () => logBuffer.getRecentLogs() };
}
function adminConnectionStringFor(port: number): string {
return `postgres://${EMBEDDED_POSTGRES_USER}:${EMBEDDED_POSTGRES_PASSWORD}@127.0.0.1:${port}/postgres`;
}
function databaseConnectionStringFor(port: number): string {
return `postgres://${EMBEDDED_POSTGRES_USER}:${EMBEDDED_POSTGRES_PASSWORD}@127.0.0.1:${port}/paperclip`;
}
async function cleanupEmbeddedPostgresTestDirs(dataDir: string): Promise<void> {
if (process.platform !== "win32") {
try {
await promisify(execFile)("/bin/rm", ["-rf", "--", dataDir], { maxBuffer: 64 * 1024 });
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
await fs.promises.rm(dataDir, { recursive: true, force: true });
}
async function stopEmbeddedPostgresBounded(
instance: EmbeddedPostgresInstance | null,
cleanupFn?: () => Promise<void>,
): Promise<void> {
await instance?.stop();
await cleanupFn?.();
}
// Upper bound on start attempts. `getAvailablePort` uses a check-then-use probe:
// it binds port 0, reads the assigned port, closes the probe, then Postgres binds
// that port. Under load another process can take the port in that window, so the
// bind fails with "Address already in use" and `start()` rejects. Each retry uses
// a fresh port and a fresh data directory, so a transient collision clears.
const EMBEDDED_POSTGRES_START_MAX_ATTEMPTS = 5;
// Start one embedded Postgres cluster with a bounded retry. Each attempt gets a
// fresh port and a fresh data directory. On a failed attempt we stop the cluster
// and remove its data directory before the next attempt. After the last attempt
// we throw with the real Postgres output so the failure is loud and diagnosable.
async function startEmbeddedPostgresWithRetry(tempDirPrefix: string): Promise<{
port: number;
dataDir: string;
instance: EmbeddedPostgresInstance;
}> {
let lastError = new Error("embedded Postgres startup failed");
for (let attempt = 1; attempt <= EMBEDDED_POSTGRES_START_MAX_ATTEMPTS; attempt += 1) {
const created = await createEmbeddedPostgresTestInstance(tempDirPrefix);
try {
await created.instance.initialise();
await created.instance.start();
return { port: created.port, dataDir: created.dataDir, instance: created.instance };
} catch (error) {
lastError = formatEmbeddedPostgresError(error, {
fallbackMessage: "embedded Postgres startup failed",
recentLogs: created.getRecentLogs(),
});
// Stop the failed cluster and remove its data directory. The next attempt
// allocates a fresh port and a fresh data directory.
await stopEmbeddedPostgresBounded(created.instance, () =>
cleanupEmbeddedPostgresTestDirs(created.dataDir),
);
if (!/address already in use/i.test(lastError.message)) throw lastError;
}
}
throw new Error(
`Failed to start embedded PostgreSQL test database after ${EMBEDDED_POSTGRES_START_MAX_ATTEMPTS} attempts: ${lastError.message}`,
);
}
// ---------------------------------------------------------------------------
// Template cluster cache
//
// Every suite that calls `startEmbeddedPostgresTestDatabase` used to pay for
// `initdb`, a cluster start, and all pending migrations (331 files at the time
// of writing) before its first test ran, and the `getEmbeddedPostgresTestSupport`
// probe started and stopped a second throwaway cluster per test file. On the
// serial CI shards that was 5-6s per suite plus 2.5-3s per probe; measured
// locally it is 3.2s per suite plus 0.75s per probe.
//
// A cluster data directory that was shut down cleanly can be copied and started
// again as an independent cluster. So the first caller on a host builds one
// fully migrated cluster, stops it, and publishes the data directory as a
// template under the OS temp directory. Every later caller copies the template
// (about 90 MB, ~0.2s) and starts Postgres on the copy (~0.05s), then still runs
// `ensurePostgresDatabase` and `applyPendingMigrations` so the returned database
// is exactly what the fresh path produces; both are no-ops on a template copy.
//
// The template key hashes everything that determines the cluster's contents:
// the embedded-postgres package version, the initdb flags, the user, and the
// name and content of every migration file plus the journal. A changed
// migration therefore builds a new template instead of reusing a stale one.
// Publishing is a rename, which is atomic on the same filesystem, so a
// concurrent builder either wins the rename or discards its copy and uses the
// winner's; a template that exists is always complete.
//
// A template is a Postgres data directory, and starting one executes its
// configuration (for example `shared_preload_libraries`). The cache root is
// therefore private to the current OS user: its name carries the user id, it
// is created with mode 0700, and it is only used when it is a real directory
// (not a symlink) owned by this user with no group or world write bit. Each
// template is checked the same way before it is copied. On a host with a
// shared temp directory another user can pre-create the predictable path, and
// those checks reject it. Windows has no uid; its temp directory is already
// per user, so the ownership and mode checks are skipped there.
//
// Set PAPERCLIP_TEST_POSTGRES_TEMPLATE=0 to bypass the cache and run the fresh
// path for every suite. Any failure on the template path (root checks, build,
// copy, start, or publish) falls back to the fresh path, so a broken cache can
// make a suite slower but never fail it or skip it: the fresh path still
// reports a migration that cannot apply or a cluster that cannot start.
// ---------------------------------------------------------------------------
const TEMPLATE_ROOT_DIR_NAME = "paperclip-embedded-postgres-templates";
// Templates whose key no longer matches are pruned once they have not been
// used for this long. Every reuse refreshes the template's timestamp.
const TEMPLATE_PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export type EmbeddedPostgresStartMode = "template" | "fresh";
function isTemplateCacheDisabled(): boolean {
return process.env.PAPERCLIP_TEST_POSTGRES_TEMPLATE === "0";
}
function migrationsDirectory(): string {
return fileURLToPath(new URL("./migrations/", import.meta.url));
}
function embeddedPostgresPackageVersion(): string {
try {
const require = createRequire(import.meta.url);
const manifest = require("embedded-postgres/package.json") as { version?: unknown };
return typeof manifest.version === "string" ? manifest.version : "unknown";
} catch {
return "unknown";
}
}
// Hash of everything that determines the contents of a migrated cluster. Reads
// every migration file, which costs a few milliseconds per process.
export function computeEmbeddedPostgresTemplateKey(
migrationsDir: string = migrationsDirectory(),
input: { packageVersion?: string; initdbFlags?: string[]; user?: string } = {},
): string {
const hash = createHash("sha256");
hash.update(`embedded-postgres=${input.packageVersion ?? embeddedPostgresPackageVersion()}\n`);
hash.update(`initdb=${(input.initdbFlags ?? EMBEDDED_POSTGRES_INITDB_FLAGS).join(" ")}\n`);
hash.update(`user=${input.user ?? EMBEDDED_POSTGRES_USER}\n`);
const journalPath = path.join(migrationsDir, "meta", "_journal.json");
if (fs.existsSync(journalPath)) {
hash.update(`journal\n`);
hash.update(fs.readFileSync(journalPath));
hash.update(`\n`);
}
const migrationFiles = fs
.readdirSync(migrationsDir)
.filter((name) => name.endsWith(".sql"))
.sort((a, b) => a.localeCompare(b));
for (const name of migrationFiles) {
hash.update(`${name}\n`);
hash.update(fs.readFileSync(path.join(migrationsDir, name)));
hash.update(`\n`);
}
return hash.digest("hex").slice(0, 24);
}
// The uid on POSIX, else the user name. Part of the cache root's name so two
// users on one host never share a root.
function templateOwnerTag(): string {
const uid = process.getuid?.();
if (uid !== undefined) return String(uid);
let username = "user";
try {
username = os.userInfo().username;
} catch {
// Fall through to the generic tag.
}
return username.replace(/[^A-Za-z0-9_.-]/g, "_");
}
function templateRootDir(): string {
return path.join(os.tmpdir(), `${TEMPLATE_ROOT_DIR_NAME}-${templateOwnerTag()}`);
}
// Why `dir` cannot be trusted as a private directory of the current user, or
// null when it can. Uses `lstat` so a symlink planted at the path is rejected
// rather than followed. On Windows (no uid) only the directory check applies.
function describePrivateDirectoryProblem(dir: string): string | null {
let stat: fs.Stats;
try {
stat = fs.lstatSync(dir);
} catch (error) {
return `cannot be inspected (${(error as NodeJS.ErrnoException).code ?? "unknown error"})`;
}
if (stat.isSymbolicLink()) return "is a symbolic link";
if (!stat.isDirectory()) return "is not a directory";
const uid = process.getuid?.();
if (uid === undefined) return null;
if (stat.uid !== uid) return `is owned by uid ${stat.uid}, not the current user (uid ${uid})`;
if ((stat.mode & 0o022) !== 0) {
return `is writable by group or others (mode ${(stat.mode & 0o777).toString(8)})`;
}
return null;
}
// Creates the cache root with mode 0700 and verifies it is private to the
// current user. Returns null, after one warning, when it is not: the cache is
// then unused for this process and every suite takes the fresh path.
function prepareTemplateRootDir(): string | null {
const rootDir = templateRootDir();
try {
fs.mkdirSync(rootDir, { recursive: true, mode: 0o700 });
const problem = describePrivateDirectoryProblem(rootDir);
if (problem !== null) {
warnTemplateOnce("root", `template cache root ${rootDir} ${problem}; using fresh clusters`);
return null;
}
// A root created by an earlier version may be wider than 0700. It is
// ours (checked above), so narrow it.
if (process.getuid !== undefined && (fs.lstatSync(rootDir).mode & 0o777) !== 0o700) {
fs.chmodSync(rootDir, 0o700);
}
return rootDir;
} catch (error) {
warnTemplateOnce(
"root",
`template cache root ${rootDir} could not be prepared: ${(error as Error).message}; using fresh clusters`,
);
return null;
}
}
// A template is usable when it is a private directory of the current user
// that holds a cleanly stopped cluster: `PG_VERSION` present, no
// `postmaster.pid`. The ownership check repeats the root's check so a
// template is never copied from a directory this user does not own.
function isUsableTemplate(dir: string): boolean {
if (describePrivateDirectoryProblem(dir) !== null) return false;
return fs.existsSync(path.join(dir, "PG_VERSION")) && !fs.existsSync(path.join(dir, "postmaster.pid"));
}
// Records a use of the template so `pruneStaleTemplates` measures idle time,
// not age. Best effort.
function touchTemplate(dir: string): void {
try {
const now = new Date();
fs.utimesSync(dir, now, now);
} catch {
// The template still works without the refreshed timestamp.
}
}
const warnedTemplateTopics = new Set<string>();
function warnTemplateOnce(topic: string, message: string): void {
if (warnedTemplateTopics.has(topic)) return;
warnedTemplateTopics.add(topic);
console.warn(`[embedded-postgres] ${message}`);
}
// Best-effort removal of templates for other keys that have not been used for
// a week, plus abandoned build directories. Every reuse refreshes a template's
// timestamp, so a template that any process resolved in the last week stays.
// A suite never depends on the template after its copy is made, so pruning
// can only affect a process that resolved the path and has not copied yet;
// that process then rebuilds the template instead of failing. Never throws.
function pruneStaleTemplates(rootDir: string, keepDir: string): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(rootDir, { withFileTypes: true });
} catch {
return;
}
const now = Date.now();
for (const entry of entries) {
const entryPath = path.join(rootDir, entry.name);
if (entryPath === keepDir || !entry.isDirectory()) continue;
try {
const age = now - fs.statSync(entryPath).mtimeMs;
if (age > TEMPLATE_PRUNE_AGE_MS) fs.rmSync(entryPath, { recursive: true, force: true });
} catch {
// Another process may be using or removing it; leave it alone.
}
}
}
// Waits for the raw `stop()` so the builder cluster has really exited before
// its data directory is published. Returns false if the stop overran.
async function stopForTemplatePublish(instance: EmbeddedPostgresInstance): Promise<boolean> {
try {
await instance.stopGracefully();
return true;
} catch { return false; }
}
// Builds a migrated cluster and publishes its data directory at `templateDir`.
// Throws when the cluster cannot start or a migration cannot apply. Returns
// false when the cluster started but the template could not be published;
// callers then use the fresh path for this process.
async function buildEmbeddedPostgresTemplate(templateDir: string): Promise<boolean> {
const rootDir = path.dirname(templateDir);
const { port, dataDir, instance } = await startEmbeddedPostgresWithRetry(
"paperclip-embedded-postgres-template-build-",
);
let published = false;
try {
await ensurePostgresDatabase(adminConnectionStringFor(port), "paperclip");
await applyPendingMigrations(databaseConnectionStringFor(port));
const stopped = await stopForTemplatePublish(instance);
if (!stopped || !isUsableTemplate(dataDir)) return false;
try {
fs.renameSync(dataDir, templateDir);
published = true;
} catch (error) {
// Another process published the same key first. Use theirs.
const code = (error as NodeJS.ErrnoException).code;
if (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") {
return isUsableTemplate(templateDir);
}
throw error;
}
pruneStaleTemplates(rootDir, templateDir);
return true;
} finally {
if (!published) {
await stopEmbeddedPostgresBounded(instance, () => cleanupEmbeddedPostgresTestDirs(dataDir));
}
}
}
let templateDirPromise: Promise<string | null> | null = null;
async function getEmbeddedPostgresTemplateDir(): Promise<string | null> {
if (isTemplateCacheDisabled()) return null;
if (!templateDirPromise) {
templateDirPromise = (async () => {
const rootDir = prepareTemplateRootDir();
if (rootDir === null) return null;
const templateDir = path.join(rootDir, computeEmbeddedPostgresTemplateKey());
if (isUsableTemplate(templateDir)) {
touchTemplate(templateDir);
return templateDir;
}
try {
const built = await buildEmbeddedPostgresTemplate(templateDir);
return built && isUsableTemplate(templateDir) ? templateDir : null;
} catch (error) {
if (error instanceof TestPostgresShutdownError) throw error;
warnTemplateOnce(
"build",
`template build failed, using fresh clusters: ${(error as Error).message}`,
);
return null;
}
})();
}
return await templateDirPromise;
}
// Forgets the resolved template so the next caller in this process checks the
// cache again. Used after a template start failed, for example because the
// template was pruned by another process after this one resolved its path.
function resetTemplateDirCache(): void {
templateDirPromise = null;
}
// Copies the template and starts a cluster on the copy. Each attempt uses a
// fresh copy and a fresh port, mirroring `startEmbeddedPostgresWithRetry`. A
// template that is no longer usable (pruned, or no longer private) is not
// retried: the caller falls back and the next caller rebuilds it.
async function startEmbeddedPostgresFromTemplateWithRetry(
templateDir: string,
tempDirPrefix: string,
): Promise<{ port: number; dataDir: string; instance: EmbeddedPostgresInstance }> {
let lastError = new Error("embedded Postgres startup from template failed");
for (let attempt = 1; attempt <= EMBEDDED_POSTGRES_START_MAX_ATTEMPTS; attempt += 1) {
if (!isUsableTemplate(templateDir)) {
throw new Error(`embedded Postgres template ${templateDir} is no longer usable`);
}
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix));
const dataDir = path.join(rootDir, "data");
let created: Awaited<ReturnType<typeof createEmbeddedPostgresTestInstanceAt>> | null = null;
try {
fs.cpSync(templateDir, dataDir, { recursive: true });
// Postgres refuses a data directory that is group or world accessible.
fs.chmodSync(dataDir, 0o700);
created = await createEmbeddedPostgresTestInstanceAt(dataDir);
await created.instance.start();
return { port: created.port, dataDir: rootDir, instance: created.instance };
} catch (error) {
lastError = formatEmbeddedPostgresError(error, {
fallbackMessage: "embedded Postgres startup from template failed",
recentLogs: created?.getRecentLogs(),
});
await stopEmbeddedPostgresBounded(created?.instance ?? null, () =>
cleanupEmbeddedPostgresTestDirs(rootDir),
);
if (!/address already in use/i.test(lastError.message)) throw lastError;
}
}
throw new Error(
`Failed to start embedded PostgreSQL from template after ${EMBEDDED_POSTGRES_START_MAX_ATTEMPTS} attempts: ${lastError.message}`,
);
}
let templateFallbackWarned = false;
let lastStartMode: EmbeddedPostgresStartMode | null = null;
// Test-only accessors. Production callers use `startEmbeddedPostgresTestDatabase`
// or `getEmbeddedPostgresTestSupport`. A test drives the bounded retry directly
// so it does not need a real Postgres connection.
export const __startEmbeddedPostgresWithRetryForTests = startEmbeddedPostgresWithRetry;
export const __embeddedPostgresStartMaxAttemptsForTests = EMBEDDED_POSTGRES_START_MAX_ATTEMPTS;
export const __embeddedPostgresTemplateRootDirForTests = templateRootDir;
export const __isUsableEmbeddedPostgresTemplateForTests = isUsableTemplate;
export function __lastEmbeddedPostgresStartModeForTests(): EmbeddedPostgresStartMode | null {
return lastStartMode;
}
// Forgets the support probe result, the resolved template, and the one-time
// warnings so a test can drive the probe and the cache again in one process.
export function __resetEmbeddedPostgresTemplateStateForTests(): void {
embeddedPostgresSupportPromise = null;
templateDirPromise = null;
templateFallbackWarned = false;
warnedTemplateTopics.clear();
lastStartMode = null;
}
async function probeEmbeddedPostgresSupport(): Promise<EmbeddedPostgresTestSupport> {
if ((await getEmbeddedPostgresTemplateDir()) !== null) return { supported: true };
let started: { dataDir: string; instance: EmbeddedPostgresInstance } | null = null;
try {
started = await startEmbeddedPostgresWithRetry("paperclip-embedded-postgres-probe-");
return { supported: true };
} catch (error) {
const failure = formatEmbeddedPostgresError(error, {
fallbackMessage: "embedded Postgres startup failed",
});
if (/Unsupported (?:arch|platform) "/.test(failure.message)) {
return { supported: false, reason: failure.message };
}
throw failure;
} finally {
if (started) {
const { dataDir, instance } = started;
await stopEmbeddedPostgresBounded(instance, () => cleanupEmbeddedPostgresTestDirs(dataDir));
}
}
}
export async function getEmbeddedPostgresTestSupport(): Promise<EmbeddedPostgresTestSupport> {
const adminUrl = testPostgresAdminUrl();
if (adminUrl) {
await checkTestPostgresAdmin(adminUrl);
return { supported: true };
}
if (!embeddedPostgresSupportPromise) {
embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport();
}
return await embeddedPostgresSupportPromise;
}
// Starts the cluster for one suite: from the host template when one is
// available, otherwise a fresh `initdb`. Template failures fall back to the
// fresh path so the cache can only make a suite faster, never fail it.
async function startEmbeddedPostgresClusterForSuite(
tempDirPrefix: string,
): Promise<{ port: number; dataDir: string; instance: EmbeddedPostgresInstance; mode: EmbeddedPostgresStartMode }> {
const templateDir = await getEmbeddedPostgresTemplateDir();
if (templateDir !== null) {
try {
const started = await startEmbeddedPostgresFromTemplateWithRetry(templateDir, tempDirPrefix);
return { ...started, mode: "template" };
} catch (error) {
if (error instanceof TestPostgresShutdownError) throw error;
// The next caller in this process re-resolves the template: if it was
// pruned it is rebuilt, instead of retrying a path that is gone.
resetTemplateDirCache();
if (!templateFallbackWarned) {
templateFallbackWarned = true;
console.warn(
`[embedded-postgres] template start failed, using a fresh cluster: ${(error as Error).message}`,
);
}
}
}
// The bounded retry hardens the cluster start against the port race. It throws
// with the real Postgres output if every attempt fails.
const started = await startEmbeddedPostgresWithRetry(tempDirPrefix);
return { ...started, mode: "fresh" };
}
export async function startEmbeddedPostgresTestDatabase(
tempDirPrefix: string,
): Promise<EmbeddedPostgresTestDatabase> {
const adminUrl = testPostgresAdminUrl();
if (adminUrl) return startTestPostgresDatabase(adminUrl);
const { port, dataDir, instance, mode } = await startEmbeddedPostgresClusterForSuite(tempDirPrefix);
lastStartMode = mode;
try {
// Both calls are no-ops on a template copy and keep the returned database
// identical to the fresh path even if the template were ever stale.
await ensurePostgresDatabase(adminConnectionStringFor(port), "paperclip");
const connectionString = databaseConnectionStringFor(port);
await applyPendingMigrations(connectionString);
return {
connectionString,
cleanup: async () => {
// End every client a caller created against this cluster first. A
// client that still holds a reserved connection when the cluster
// stops can crash the process: the stop kills the backend socket,
// but a queued write on that connection still fires later and finds
// a null socket.
try { await closeRegisteredClients(connectionString); }
finally {
await stopEmbeddedPostgresBounded(instance, () => cleanupEmbeddedPostgresTestDirs(dataDir));
}
},
};
} catch (error) {
await stopEmbeddedPostgresBounded(instance, () => cleanupEmbeddedPostgresTestDirs(dataDir));
throw new Error(
`Failed to start embedded PostgreSQL test database: ${
formatEmbeddedPostgresError(error, {
fallbackMessage: "embedded Postgres startup failed",
}).message
}`,
);
}
}