fix(core): join competing postmaster when embedded Postgres startup races
Starting a second Fusion process could fail with `lock file "postmaster.pid" already exists`. The singleton preflight check and `pg.start()` are not atomic, so another process can create the lock in between — the loser surfaced the collision to the TUI as an error instead of simply joining the live instance. `EmbeddedPostgresLifecycle.start()` now wraps the start path in a try/catch. On failure it re-reads `postmaster.pid` via `isAlreadyRunning()`; when a live instance is found it connects to that port with `ownsProcess=false` (so this process never stops a server it did not start) and logs the race. Failures with no live instance rethrow unchanged, so genuine startup errors are unaffected. Regression test lives outside the real-process `embeddedDescribe` block — it uses a mocked ctor, and nesting it there would skip it under FUSION_EMBEDDED_TEST_SKIP=1 (the gate/CI default), leaving the fix unprotected. Verified: 35/35 embedded-lifecycle tests pass, core typecheck clean, lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/embedded-postgres-startup-race.md
Normal file
7
.changeset/embedded-postgres-startup-race.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Starting a second Fusion process no longer fails with a Postgres lock-file error.
|
||||
category: fix
|
||||
dev: `EmbeddedPostgresLifecycle.start()` wraps the start path in a try/catch; on failure it re-reads `postmaster.pid` via `isAlreadyRunning()` and, when a live instance exists, joins it (`ownsProcess=false`) instead of surfacing the expected lock collision. Closes the window between the preflight singleton check and `pg.start()` where a competing process can create the lock. Non-`isAlreadyRunning` failures still rethrow unchanged.
|
||||
@@ -691,6 +691,52 @@ embeddedDescribe("embedded-lifecycle: real process (VAL-CONN-001, VAL-CONN-006,
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresStartupRace 2026-07-15-17:05:
|
||||
The cross-process startup-race coverage uses a mocked EmbeddedPostgres ctor (no real
|
||||
Postgres), so it must live OUTSIDE the real-process `embeddedDescribe` block. Nesting it
|
||||
there made it skip under FUSION_EMBEDDED_TEST_SKIP=1 (the gate/CI default), silently
|
||||
leaving the join-the-competing-postmaster fix unprotected. A regular `describe` keeps it
|
||||
fast (~6ms) and always-on.
|
||||
*/
|
||||
describe("embedded-lifecycle: startup race (cross-process)", () => {
|
||||
it("joins the competing postmaster when startup loses a cross-process race", async () => {
|
||||
const dataDir = makeDataDir();
|
||||
writeFileSync(join(dataDir, "PG_VERSION"), "15\n");
|
||||
const logLines: string[] = [];
|
||||
|
||||
class RacingEmbeddedPostgres {
|
||||
initialise = vi.fn(async () => {});
|
||||
async start() {
|
||||
writeFileSync(
|
||||
join(dataDir, "postmaster.pid"),
|
||||
["12345", dataDir, "/tmp", "localhost", "55440", "5432101", String(Date.now())].join("\n") + "\n",
|
||||
);
|
||||
throw new Error('lock file "postmaster.pid" already exists');
|
||||
}
|
||||
stop = vi.fn(async () => {});
|
||||
}
|
||||
|
||||
__setEmbeddedPostgresCtorForTests(RacingEmbeddedPostgres as never);
|
||||
try {
|
||||
const lifecycle = new EmbeddedPostgresLifecycle({
|
||||
...baseOptions(dataDir),
|
||||
port: 55439,
|
||||
onLog: (message) => logLines.push(message),
|
||||
});
|
||||
|
||||
await expect(lifecycle.start()).resolves.toMatchObject({
|
||||
mode: "embedded",
|
||||
runtimeUrl: expect.stringContaining(":55440/"),
|
||||
});
|
||||
expect(lifecycle.isRunning()).toBe(false);
|
||||
expect(logLines.some((line) => /startup raced with an existing instance/i.test(line))).toBe(true);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded-lifecycle: startup timeout (P1 #24)", () => {
|
||||
it("EmbeddedStartTimeoutError carries the timeout and data dir", () => {
|
||||
const err = new EmbeddedStartTimeoutError(5000, "/tmp/data");
|
||||
|
||||
@@ -1086,34 +1086,58 @@ export class EmbeddedPostgresLifecycle {
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-14-22:53:
|
||||
// Skip the real non-admin path when tests inject a mock ctor so delayed
|
||||
// start/cancellation coverage exercises pg.start() even on elevated CI.
|
||||
if (isWindowsElevatedAdmin() && !embeddedPostgresCtorIsTestOverride) {
|
||||
const nativeRoot = resolveWindowsEmbeddedPostgresNativeRoot();
|
||||
if (!nativeRoot) {
|
||||
throw new Error(
|
||||
"embedded postgres: the process is running elevated on Windows, where " +
|
||||
"PostgreSQL refuses to start under an administrative token, and the " +
|
||||
"non-admin boot path could not locate the bundled " +
|
||||
"@embedded-postgres/windows-x64 native binaries to stage. Run Fusion " +
|
||||
"non-elevated, or ensure the embedded-postgres platform package is installed.",
|
||||
);
|
||||
try {
|
||||
if (isWindowsElevatedAdmin() && !embeddedPostgresCtorIsTestOverride) {
|
||||
const nativeRoot = resolveWindowsEmbeddedPostgresNativeRoot();
|
||||
if (!nativeRoot) {
|
||||
throw new Error(
|
||||
"embedded postgres: the process is running elevated on Windows, where " +
|
||||
"PostgreSQL refuses to start under an administrative token, and the " +
|
||||
"non-admin boot path could not locate the bundled " +
|
||||
"@embedded-postgres/windows-x64 native binaries to stage. Run Fusion " +
|
||||
"non-elevated, or ensure the embedded-postgres platform package is installed.",
|
||||
);
|
||||
}
|
||||
this.nonAdminHandle = await startServerAsNonAdminUser({
|
||||
nativeRoot,
|
||||
dataDir: this.options.dataDir,
|
||||
port,
|
||||
postgresFlags: this.options.postgresFlags,
|
||||
onLog: this.options.onLog,
|
||||
onError: this.options.onError,
|
||||
startTimeoutMs: this.options.startTimeoutMs,
|
||||
signal,
|
||||
// Assign handle as soon as the wrapper PID is known so outer start()
|
||||
// timeout cleanup can taskkill orphans mid-readiness poll.
|
||||
onLaunched: (handle) => {
|
||||
this.nonAdminHandle = handle;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await pg.start();
|
||||
}
|
||||
this.nonAdminHandle = await startServerAsNonAdminUser({
|
||||
nativeRoot,
|
||||
dataDir: this.options.dataDir,
|
||||
port,
|
||||
postgresFlags: this.options.postgresFlags,
|
||||
onLog: this.options.onLog,
|
||||
onError: this.options.onError,
|
||||
startTimeoutMs: this.options.startTimeoutMs,
|
||||
signal,
|
||||
// Assign handle as soon as the wrapper PID is known so outer start()
|
||||
// timeout cleanup can taskkill orphans mid-readiness poll.
|
||||
onLaunched: (handle) => {
|
||||
this.nonAdminHandle = handle;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await pg.start();
|
||||
} catch (error) {
|
||||
// FNXC:PostgresStartupRace 2026-07-15-15:00: Another Fusion process can
|
||||
// create postmaster.pid after the preflight singleton check but before
|
||||
// this process starts Postgres. Re-read that lock and join its instance
|
||||
// rather than surfacing the expected lock-file collision to the TUI.
|
||||
const existing = isAlreadyRunning(this.options.dataDir);
|
||||
if (!existing) throw error;
|
||||
|
||||
this.pg = null;
|
||||
this.nonAdminHandle = null;
|
||||
this.resolvedPort = existing.port;
|
||||
this.ownsProcess = false;
|
||||
this.options.onLog(
|
||||
`embedded postgres: startup raced with an existing instance on port ${existing.port} (data dir ${this.options.dataDir}), connecting without starting a new instance`,
|
||||
);
|
||||
const runtimeUrl = this.buildUrl(existing.port, this.options.database);
|
||||
return {
|
||||
mode: "embedded",
|
||||
runtimeUrl,
|
||||
migrationUrl: runtimeUrl,
|
||||
migrationUrlOverridden: false,
|
||||
};
|
||||
}
|
||||
/*
|
||||
FNXC:PostgresResourceLifecycle 2026-07-14-18:42:
|
||||
|
||||
Reference in New Issue
Block a user