diff --git a/.changeset/embedded-postgres-startup-race.md b/.changeset/embedded-postgres-startup-race.md new file mode 100644 index 0000000000..78c3ab7f1e --- /dev/null +++ b/.changeset/embedded-postgres-startup-race.md @@ -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. diff --git a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts index c733449008..ae0731605e 100644 --- a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts +++ b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts @@ -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"); diff --git a/packages/core/src/postgres/embedded-lifecycle.ts b/packages/core/src/postgres/embedded-lifecycle.ts index 77408a28a7..7ee107c363 100644 --- a/packages/core/src/postgres/embedded-lifecycle.ts +++ b/packages/core/src/postgres/embedded-lifecycle.ts @@ -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: