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:
gsxdsm
2026-07-15 20:07:25 -07:00
parent 0b332816d5
commit e33039ad0f
3 changed files with 104 additions and 27 deletions

View 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.

View File

@@ -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");

View File

@@ -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: