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)", () => {
|
describe("embedded-lifecycle: startup timeout (P1 #24)", () => {
|
||||||
it("EmbeddedStartTimeoutError carries the timeout and data dir", () => {
|
it("EmbeddedStartTimeoutError carries the timeout and data dir", () => {
|
||||||
const err = new EmbeddedStartTimeoutError(5000, "/tmp/data");
|
const err = new EmbeddedStartTimeoutError(5000, "/tmp/data");
|
||||||
|
|||||||
@@ -1086,34 +1086,58 @@ export class EmbeddedPostgresLifecycle {
|
|||||||
// FNXC:WindowsDesktopPackaging 2026-07-14-22:53:
|
// FNXC:WindowsDesktopPackaging 2026-07-14-22:53:
|
||||||
// Skip the real non-admin path when tests inject a mock ctor so delayed
|
// Skip the real non-admin path when tests inject a mock ctor so delayed
|
||||||
// start/cancellation coverage exercises pg.start() even on elevated CI.
|
// start/cancellation coverage exercises pg.start() even on elevated CI.
|
||||||
if (isWindowsElevatedAdmin() && !embeddedPostgresCtorIsTestOverride) {
|
try {
|
||||||
const nativeRoot = resolveWindowsEmbeddedPostgresNativeRoot();
|
if (isWindowsElevatedAdmin() && !embeddedPostgresCtorIsTestOverride) {
|
||||||
if (!nativeRoot) {
|
const nativeRoot = resolveWindowsEmbeddedPostgresNativeRoot();
|
||||||
throw new Error(
|
if (!nativeRoot) {
|
||||||
"embedded postgres: the process is running elevated on Windows, where " +
|
throw new Error(
|
||||||
"PostgreSQL refuses to start under an administrative token, and the " +
|
"embedded postgres: the process is running elevated on Windows, where " +
|
||||||
"non-admin boot path could not locate the bundled " +
|
"PostgreSQL refuses to start under an administrative token, and the " +
|
||||||
"@embedded-postgres/windows-x64 native binaries to stage. Run Fusion " +
|
"non-admin boot path could not locate the bundled " +
|
||||||
"non-elevated, or ensure the embedded-postgres platform package is installed.",
|
"@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({
|
} catch (error) {
|
||||||
nativeRoot,
|
// FNXC:PostgresStartupRace 2026-07-15-15:00: Another Fusion process can
|
||||||
dataDir: this.options.dataDir,
|
// create postmaster.pid after the preflight singleton check but before
|
||||||
port,
|
// this process starts Postgres. Re-read that lock and join its instance
|
||||||
postgresFlags: this.options.postgresFlags,
|
// rather than surfacing the expected lock-file collision to the TUI.
|
||||||
onLog: this.options.onLog,
|
const existing = isAlreadyRunning(this.options.dataDir);
|
||||||
onError: this.options.onError,
|
if (!existing) throw error;
|
||||||
startTimeoutMs: this.options.startTimeoutMs,
|
|
||||||
signal,
|
this.pg = null;
|
||||||
// Assign handle as soon as the wrapper PID is known so outer start()
|
this.nonAdminHandle = null;
|
||||||
// timeout cleanup can taskkill orphans mid-readiness poll.
|
this.resolvedPort = existing.port;
|
||||||
onLaunched: (handle) => {
|
this.ownsProcess = false;
|
||||||
this.nonAdminHandle = handle;
|
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`,
|
||||||
});
|
);
|
||||||
} else {
|
const runtimeUrl = this.buildUrl(existing.port, this.options.database);
|
||||||
await pg.start();
|
return {
|
||||||
|
mode: "embedded",
|
||||||
|
runtimeUrl,
|
||||||
|
migrationUrl: runtimeUrl,
|
||||||
|
migrationUrlOverridden: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
FNXC:PostgresResourceLifecycle 2026-07-14-18:42:
|
FNXC:PostgresResourceLifecycle 2026-07-14-18:42:
|
||||||
|
|||||||
Reference in New Issue
Block a user