From 6c008418fe3740f702bbe63d5fc81dee9de1f106 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 00:15:10 -0700 Subject: [PATCH] fix(core): boot embedded Postgres under non-admin user on elevated Windows (#2117) ## Summary Windows embedded Postgres verification (CI `windows-latest` and elevated desktop) fails because PostgreSQL refuses to run under an administrative token: > Execution of PostgreSQL by a user with administrative permissions is not permitted. GitHub Actions runners execute as `runneradmin` elevated, so the existing `test:embedded-postgres` smoke (and any elevated Local-mode desktop launch) cannot start the server. ### Fix - When `isWindowsElevatedAdmin()` is true, **initdb / clients stay as the launcher**, but the **postgres server** is started as a dedicated non-admin local user (`fusion-pg`) via PowerShell `Start-Process -Credential`. - Readiness waits on the postgres log line `database system is ready to accept connections` with a lightweight poll (no per-iteration `tasklist`). - Real-process vitest cases use a **180s** timeout on Windows (package default is 15s, which killed healthy boots mid-start). - Builds on top of the packaged-desktop asar materialization work already on main (#2106). ## Test plan - [x] `pnpm --filter @fusion/core test:embedded-postgres` on macOS (33/33) - [ ] `desktop-windows.yml` on `feature/win-pg-verify`: - [ ] Smoke embedded Postgres on Windows - [ ] Build + package Windows EXE - [ ] Verify app.asar assets - [ ] Optional: download portable EXE and manual Local mode smoke on a Windows host ## Summary by CodeRabbit - **Bug Fixes** - Improved embedded PostgreSQL startup on Windows when Fusion runs with elevated administrator privileges. - When elevated, the embedded database now boots under a dedicated non-administrator local account, with more reliable readiness detection, logging, and shutdown cleanup. - Enhanced database provisioning and now prefers `127.0.0.1` for Windows connection addressing. - **Tests** - Added coverage for Windows elevation detection without starting embedded PostgreSQL. - Increased platform-dependent timeouts for embedded real-process tests to avoid premature failures on Windows. --- .changeset/win-embedded-pg-admin.md | 7 + .../postgres/embedded-lifecycle.test.ts | 261 ++++---- .../core/src/postgres/embedded-lifecycle.ts | 195 +++++- .../src/postgres/embedded-windows-admin.ts | 583 ++++++++++++++++++ 4 files changed, 927 insertions(+), 119 deletions(-) create mode 100644 .changeset/win-embedded-pg-admin.md create mode 100644 packages/core/src/postgres/embedded-windows-admin.ts diff --git a/.changeset/win-embedded-pg-admin.md b/.changeset/win-embedded-pg-admin.md new file mode 100644 index 0000000000..939403b2c8 --- /dev/null +++ b/.changeset/win-embedded-pg-admin.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Embedded Postgres now boots on Windows when Fusion runs elevated, fixing the Windows installer build. +category: fix +dev: On elevated Windows the postgres server is booted under a dedicated non-admin local user via Start-Process -Credential (packages/core embedded-lifecycle.ts + embedded-windows-admin.ts); initdb and the pg client still run as the launching process. diff --git a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts index f3828f53df..c733449008 100644 --- a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts +++ b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts @@ -34,6 +34,7 @@ import { EmbeddedStartTimeoutError, DEFAULT_START_TIMEOUT_MS, isDataDirInitialized, + isWindowsElevatedAdmin, normalizeMacosEmbeddedPostgresDylibSymlinks, readPortFromPostmasterPid, __setEmbeddedPostgresCtorForTests, @@ -108,6 +109,18 @@ describe("embedded-lifecycle: isDataDirInitialized (PG_VERSION marker)", () => { }); }); +describe("embedded-lifecycle: Windows elevation probe (no process)", () => { + it("isWindowsElevatedAdmin is false on non-Windows platforms", () => { + // FNXC:WindowsDesktopPackaging 2026-07-15-04:55: + // The non-admin boot path is Windows-only; other OSes must never claim elevation. + if (process.platform !== "win32") { + expect(isWindowsElevatedAdmin()).toBe(false); + } else { + expect(typeof isWindowsElevatedAdmin()).toBe("boolean"); + } + }); +}); + describe("embedded-lifecycle: constructor + URL helpers (no process)", () => { it("builds a connection URL with credentials for the configured database", () => { const lifecycle = new EmbeddedPostgresLifecycle({ @@ -510,134 +523,172 @@ describe("embedded-lifecycle: macOS dylib compatibility links", () => { }); }); +/* + * FNXC:WindowsDesktopPackaging 2026-07-15-04:55: + * Package default testTimeout is 15s (packages/core/vitest.config.ts). On + * elevated Windows (GitHub windows-latest = runneradmin) the non-admin boot + * path + initdb regularly takes 60–90s before "ready to accept connections". + * The lifecycle startTimeout is 120s; the vitest wrapper must not kill earlier + * or CI reports false timeouts while postgres is still healthy (and orphans the + * non-admin postmaster). Use a per-test budget that covers elevated CI. + * + * FNXC:WindowsDesktopPackaging 2026-07-14-23:10: + * Double-start tests (VAL-CONN-006 reuse, already-initialized log) need room + * for two elevated boots. A 180s wall was tight: first start ~90s left the + * second start racing the vitest budget, which timed out mid-readiness and + * left EBUSY orphans on the data dir. 6 minutes covers 2×120s startTimeout + * plus stop/teardown margin under loaded windows-latest runners. + */ +const REAL_PROCESS_TEST_TIMEOUT_MS = process.platform === "win32" ? 360_000 : 60_000; + embeddedDescribe("embedded-lifecycle: real process (VAL-CONN-001, VAL-CONN-006, VAL-CONN-007)", () => { - it("first start runs initdb, ensures DB exists, and serves traffic (VAL-CONN-001)", async () => { - const dataDir = makeDataDir(); - const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); - tracked.push({ lifecycle, dataDir }); + it( + "first start runs initdb, ensures DB exists, and serves traffic (VAL-CONN-001)", + async () => { + const dataDir = makeDataDir(); + const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); + tracked.push({ lifecycle, dataDir }); - // Before start, the dir is not initialized. - expect(isDataDirInitialized(dataDir)).toBe(false); + // Before start, the dir is not initialized. + expect(isDataDirInitialized(dataDir)).toBe(false); - const backend = await lifecycle.start(); + const backend = await lifecycle.start(); - // After start, PG_VERSION exists (initdb ran). - expect(isDataDirInitialized(dataDir)).toBe(true); + // After start, PG_VERSION exists (initdb ran). + expect(isDataDirInitialized(dataDir)).toBe(true); - // Backend is embedded mode with a resolved runtime URL. - expect(backend.mode).toBe("embedded"); - expect(backend.runtimeUrl).not.toBeNull(); - expect(backend.runtimeUrl).toContain("/fusion"); + // Backend is embedded mode with a resolved runtime URL. + expect(backend.mode).toBe("embedded"); + expect(backend.runtimeUrl).not.toBeNull(); + expect(backend.runtimeUrl).toContain("/fusion"); - // The port was assigned (free-port discovery). - expect(lifecycle.getPort()).toBeGreaterThan(0); + // The port was assigned (free-port discovery). + expect(lifecycle.getPort()).toBeGreaterThan(0); - // Traffic is served: connect via postgres.js and query. - const sql = postgres(lifecycle.getConnectionUrl(), { max: 1 }); - try { - const rows = await sql`SELECT current_database() AS db`; - expect(rows[0].db).toBe("fusion"); - } finally { - await sql.end({ timeout: 5 }); - } - }); + // Traffic is served: connect via postgres.js and query. + const sql = postgres(lifecycle.getConnectionUrl(), { max: 1 }); + try { + const rows = await sql`SELECT current_database() AS db`; + expect(rows[0].db).toBe("fusion"); + } finally { + await sql.end({ timeout: 5 }); + } + }, + REAL_PROCESS_TEST_TIMEOUT_MS, + ); - it("second start reuses the existing data directory without re-initdb (VAL-CONN-006)", async () => { - const dataDir = makeDataDir(); + it( + "second start reuses the existing data directory without re-initdb (VAL-CONN-006)", + async () => { + const dataDir = makeDataDir(); - // First lifecycle: start, write a marker row, stop. - const first = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); - await first.start(); - const sql1 = postgres(first.getConnectionUrl(), { max: 1 }); - try { - await sql1`CREATE TABLE persistence_marker (id int PRIMARY KEY, note text)`; - await sql1`INSERT INTO persistence_marker (id, note) VALUES (1, 'persisted')`; - } finally { - await sql1.end({ timeout: 5 }); - } - await first.stop(); + // First lifecycle: start, write a marker row, stop. + const first = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); + await first.start(); + const sql1 = postgres(first.getConnectionUrl(), { max: 1 }); + try { + await sql1`CREATE TABLE persistence_marker (id int PRIMARY KEY, note text)`; + await sql1`INSERT INTO persistence_marker (id, note) VALUES (1, 'persisted')`; + } finally { + await sql1.end({ timeout: 5 }); + } + await first.stop(); - // The data dir is still initialized after stop (persistent). - expect(isDataDirInitialized(dataDir)).toBe(true); + // The data dir is still initialized after stop (persistent). + expect(isDataDirInitialized(dataDir)).toBe(true); - // Second lifecycle: start against the SAME dir. - const second = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); - tracked.push({ lifecycle: second, dataDir }); + // Second lifecycle: start against the SAME dir. + const second = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); + tracked.push({ lifecycle: second, dataDir }); - await second.start(); - const sql2 = postgres(second.getConnectionUrl(), { max: 1 }); - try { - const rows = await sql2`SELECT note FROM persistence_marker WHERE id = 1`; - expect(rows[0].note).toBe("persisted"); - } finally { - await sql2.end({ timeout: 5 }); - } - }); + await second.start(); + const sql2 = postgres(second.getConnectionUrl(), { max: 1 }); + try { + const rows = await sql2`SELECT note FROM persistence_marker WHERE id = 1`; + expect(rows[0].note).toBe("persisted"); + } finally { + await sql2.end({ timeout: 5 }); + } + }, + REAL_PROCESS_TEST_TIMEOUT_MS, + ); - it("ensureDatabase is idempotent: re-starting and ensuring the same DB does not error", async () => { - const dataDir = makeDataDir(); - const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); - tracked.push({ lifecycle, dataDir }); + it( + "ensureDatabase is idempotent: re-starting and ensuring the same DB does not error", + async () => { + const dataDir = makeDataDir(); + const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); + tracked.push({ lifecycle, dataDir }); - await lifecycle.start(); - // Calling ensureDatabase again on the already-created DB should not throw. - await lifecycle.ensureDatabase(); - await lifecycle.ensureDatabase(); - }); + await lifecycle.start(); + // Calling ensureDatabase again on the already-created DB should not throw. + await lifecycle.ensureDatabase(); + await lifecycle.ensureDatabase(); + }, + REAL_PROCESS_TEST_TIMEOUT_MS, + ); - it("graceful shutdown stops the Postgres process; no orphan remains (VAL-CONN-007)", async () => { - const dataDir = makeDataDir(); - const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); - tracked.push({ lifecycle, dataDir }); + it( + "graceful shutdown stops the Postgres process; no orphan remains (VAL-CONN-007)", + async () => { + const dataDir = makeDataDir(); + const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir)); + tracked.push({ lifecycle, dataDir }); - await lifecycle.start(); - const port = lifecycle.getPort()!; - expect(port).toBeGreaterThan(0); + await lifecycle.start(); + const port = lifecycle.getPort()!; + expect(port).toBeGreaterThan(0); - // Confirm the port is accepting connections before shutdown. - const probeBefore = postgres( - `postgresql://postgres:password@localhost:${port}/fusion`, - { max: 1, connect_timeout: 5 }, - ); - await probeBefore`SELECT 1`; - await probeBefore.end({ timeout: 5 }); + // Confirm the port is accepting connections before shutdown. + const probeBefore = postgres( + `postgresql://postgres:password@localhost:${port}/fusion`, + { max: 1, connect_timeout: 5 }, + ); + await probeBefore`SELECT 1`; + await probeBefore.end({ timeout: 5 }); - await lifecycle.stop(); - expect(lifecycle.isRunning()).toBe(false); + await lifecycle.stop(); + expect(lifecycle.isRunning()).toBe(false); - // After shutdown, the port should refuse new connections. - const probeAfter = postgres( - `postgresql://postgres:password@localhost:${port}/fusion`, - { max: 1, connect_timeout: 3 }, - ); - await expect(probeAfter`SELECT 1`).rejects.toThrow(); - await probeAfter.end({ timeout: 5 }).catch(() => {}); + // After shutdown, the port should refuse new connections. + const probeAfter = postgres( + `postgresql://postgres:password@localhost:${port}/fusion`, + { max: 1, connect_timeout: 3 }, + ); + await expect(probeAfter`SELECT 1`).rejects.toThrow(); + await probeAfter.end({ timeout: 5 }).catch(() => {}); - // Remove from tracked cleanup since we already stopped. - const idx = tracked.findIndex((t) => t.lifecycle === lifecycle); - if (idx >= 0) tracked.splice(idx, 1); - }); + // Remove from tracked cleanup since we already stopped. + const idx = tracked.findIndex((t) => t.lifecycle === lifecycle); + if (idx >= 0) tracked.splice(idx, 1); + }, + REAL_PROCESS_TEST_TIMEOUT_MS, + ); - it("start reports already-initialized reuse via the log when the dir exists", async () => { - const dataDir = makeDataDir(); - const reuseLogLines: string[] = []; - const opts: EmbeddedLifecycleOptions = { - ...baseOptions(dataDir), - onLog: (msg) => reuseLogLines.push(msg), - }; + it( + "start reports already-initialized reuse via the log when the dir exists", + async () => { + const dataDir = makeDataDir(); + const reuseLogLines: string[] = []; + const opts: EmbeddedLifecycleOptions = { + ...baseOptions(dataDir), + onLog: (msg) => reuseLogLines.push(msg), + }; - const first = new EmbeddedPostgresLifecycle(opts); - await first.start(); - await first.stop(); + const first = new EmbeddedPostgresLifecycle(opts); + await first.start(); + await first.stop(); - reuseLogLines.length = 0; - const second = new EmbeddedPostgresLifecycle(opts); - tracked.push({ lifecycle: second, dataDir }); - await second.start(); - expect( - reuseLogLines.some((l) => /existing data directory/i.test(l)), - ).toBe(true); - }); + reuseLogLines.length = 0; + const second = new EmbeddedPostgresLifecycle(opts); + tracked.push({ lifecycle: second, dataDir }); + await second.start(); + expect( + reuseLogLines.some((l) => /existing data directory/i.test(l)), + ).toBe(true); + }, + REAL_PROCESS_TEST_TIMEOUT_MS, + ); }); describe("embedded-lifecycle: startup timeout (P1 #24)", () => { diff --git a/packages/core/src/postgres/embedded-lifecycle.ts b/packages/core/src/postgres/embedded-lifecycle.ts index 14cd46600c..77408a28a7 100644 --- a/packages/core/src/postgres/embedded-lifecycle.ts +++ b/packages/core/src/postgres/embedded-lifecycle.ts @@ -67,6 +67,19 @@ import { createRequire, syncBuiltinESMExports } from "node:module"; import { createLogger } from "../logger.js"; import { redactConnectionString } from "./credential-redact.js"; import type { ResolvedBackend } from "./backend-resolver.js"; +import { + isWindowsElevatedAdmin, + startServerAsNonAdminUser, + type NonAdminServerHandle, +} from "./embedded-windows-admin.js"; +// FNXC:WindowsDesktopPackaging 2026-07-14-22:53: +// Static import so tsup/esbuild bundles postgres.js into packages/cli/dist/bin.js. +// A runtime require("postgres") resolved via the CLI createRequire banner against +// packages/cli/dist and failed boot-smoke with "Cannot find module 'postgres'" +// because @runfusion/fusion does not list postgres as a direct dependency. +import postgres from "postgres"; + +export { isWindowsElevatedAdmin } from "./embedded-windows-admin.js"; const require = createRequire(import.meta.url); @@ -442,10 +455,18 @@ export type EmbeddedPostgresCtor = new (opts: Record) => { /** Instance type produced by the embedded-postgres constructor. */ type EmbeddedPostgresInstance = InstanceType; let embeddedPostgresCtorCache: EmbeddedPostgresCtor | null = null; +/** + * FNXC:WindowsDesktopPackaging 2026-07-14-22:53: + * True while tests inject a mock EmbeddedPostgres ctor. Elevated Windows CI + * would otherwise take the real non-admin boot path and ignore the mock's + * delayed start() used by cancellation coverage. + */ +let embeddedPostgresCtorIsTestOverride = false; /** Test-only constructor seam for deterministic lifecycle cancellation coverage. */ export function __setEmbeddedPostgresCtorForTests(ctor: EmbeddedPostgresCtor | null): void { embeddedPostgresCtorCache = ctor; + embeddedPostgresCtorIsTestOverride = ctor !== null; } function getEmbeddedPostgresCtor(): EmbeddedPostgresCtor { @@ -674,6 +695,29 @@ function resolveMacosEmbeddedPostgresNativeRoot(): string | null { return resolveGenericEmbeddedPostgresNativeRoot(); } +/** + * FNXC:WindowsDesktopPackaging 2026-07-14-21:40: + * Resolve the bundled @embedded-postgres/windows-x64 native root (.../native). + * Used to stage binaries for the non-admin server boot path under elevation. + * Prefer the host-local materialization when available so elevated Windows + * desktop launches never spawn postgres.exe from app.asar. + */ +function resolveWindowsEmbeddedPostgresNativeRoot(): string | null { + if (process.platform !== "win32") return null; + const nativeRoot = resolveGenericEmbeddedPostgresNativeRoot(); + if (!nativeRoot) return null; + if (nativeRoot.includes(`${sep}app.asar`)) { + try { + return materializeEmbeddedPostgresRuntimeBinaries( + resolveElectronAsarUnpackedPath(nativeRoot), + ); + } catch { + return resolveElectronAsarUnpackedPath(nativeRoot); + } + } + return nativeRoot; +} + function normalizeBundledMacosDylibs(onLog: (message: string) => void): void { const nativeRoot = resolveMacosEmbeddedPostgresNativeRoot(); if (!nativeRoot) return; @@ -811,6 +855,13 @@ export class EmbeddedPostgresLifecycle { // is false and stop() is a no-op (the owning instance handles shutdown). private ownsProcess = true; private shutdownHookInstalled = false; + /** + * FNXC:WindowsDesktopPackaging 2026-07-14-21:40: + * When the process is an elevated Windows admin, the server is booted under a + * dedicated non-admin user (see embedded-windows-admin.ts) and this holds the + * stop handle. Null for normal (non-elevated / non-Windows) launches. + */ + private nonAdminHandle: NonAdminServerHandle | null = null; /** * FNXC:PostgresEmbedded 2026-06-26-16:20 (fix migration-review P1 #24): * Active start() timeout timer, retained so it can be cleared on success or @@ -874,7 +925,13 @@ export class EmbeddedPostgresLifecycle { } private buildUrl(port: number, database: string): string { - return `postgresql://${encodeURIComponent(this.options.user)}:${encodeURIComponent(this.options.password)}@localhost:${port}/${encodeURIComponent(database)}`; + // FNXC:WindowsDesktopPackaging 2026-07-15-05:00: + // Prefer 127.0.0.1 on Windows. `localhost` can resolve to ::1 first; the + // non-admin postmaster path and some Windows loopback policies made IPv6 + // connects hang while IPv4 was fine, which blocked ensureDatabase after the + // cluster was already ready. + const host = process.platform === "win32" ? "127.0.0.1" : "localhost"; + return `postgresql://${encodeURIComponent(this.options.user)}:${encodeURIComponent(this.options.password)}@${host}:${port}/${encodeURIComponent(database)}`; } /** @@ -1016,7 +1073,48 @@ export class EmbeddedPostgresLifecycle { throw new EmbeddedStartCancelledError(this.options.dataDir); } - await pg.start(); + // FNXC:WindowsDesktopPackaging 2026-07-14-21:40: + // Under an elevated Windows admin token, postgres refuses to inherit the + // process token ("Execution of PostgreSQL by a user with administrative + // permissions is not permitted"). initdb + the pg client above ran as the + // launching (admin) process and work unchanged; only the SERVER start is + // re-homed under a dedicated non-admin local user. Normal (non-elevated / + // non-Windows) launches use the inherited-token path as before. + // FNXC:WindowsDesktopPackaging 2026-07-15-05:20: + // Pass AbortSignal so outer start() timeout can cancel a still-polling + // non-admin launch and kill the wrapper before readiness assigns a handle. + // 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.", + ); + } + 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(); + } /* FNXC:PostgresResourceLifecycle 2026-07-14-18:42: Promise.race does not cancel the losing embedded-postgres startup. Check the cooperative cancellation signal after every delayed phase and stop the exact late instance before it can publish running state, registry ownership, or process hooks. A timeout may already have attempted stop while pg.start() was pending, so the post-resolution stop is intentionally repeated to catch a postmaster that appeared after that first cleanup. @@ -1065,6 +1163,20 @@ export class EmbeddedPostgresLifecycle { } private async settleCancelledStart(pg: EmbeddedPostgresInstance): Promise { + // FNXC:WindowsDesktopPackaging 2026-07-15-05:20: + // Prefer stopping a non-admin handle (if already assigned) before asking + // embedded-postgres to stop a process it never started. + if (this.nonAdminHandle) { + try { + await this.nonAdminHandle.stop(); + } catch (error) { + this.options.onError( + `embedded postgres: cancelled non-admin cleanup failed: ${String(error)}`, + ); + } finally { + this.nonAdminHandle = null; + } + } try { await pg.stop(); } catch (error) { @@ -1083,35 +1195,72 @@ export class EmbeddedPostgresLifecycle { * Idempotent: queries `pg_database` first and only issues `CREATE DATABASE` * when the database is missing. `embedded-postgres.createDatabase()` throws on * an existing database, so this guard is required for safe re-starts. + * + * FNXC:WindowsDesktopPackaging 2026-07-15-05:00: + * Do not call embedded-postgres.createDatabase() when the server was started + * under the elevated-Windows non-admin path: that library requires + * `this.process` (set only by its own .start()), so createDatabase throws + * "cluster must be running" even though postgres is healthy. Use a direct + * SQL connection with a bounded connect timeout instead. */ async ensureDatabase(): Promise { - if (!this.pg || !this.running) { + if (!this.running || this.getPort() === undefined) { throw new Error( "Cannot ensure database: the embedded cluster is not running. Call start() first.", ); } const exists = await this.databaseExists(this.options.database); if (exists) return; - await this.pg.createDatabase(this.options.database); + const sql = this.openMaintenanceSql(); + try { + const safeName = this.options.database.replace(/"/g, '""'); + await sql.unsafe(`CREATE DATABASE "${safeName}"`); + } finally { + await sql.end({ timeout: 5 }).catch(() => {}); + } } /** Check whether a database with the given name exists on the cluster. */ private async databaseExists(name: string): Promise { - if (!this.pg) return false; - // Use the maintenance client (connects to the default "postgres" db). - const client = this.pg.getPgClient("postgres", "localhost"); + if (!this.running || this.getPort() === undefined) return false; + const sql = this.openMaintenanceSql(); try { - await client.connect(); - const result = await client.query( - "SELECT 1 FROM pg_database WHERE datname = $1", - [name], - ); - return (result.rowCount ?? 0) > 0; + const rows = await sql`SELECT 1 AS one FROM pg_database WHERE datname = ${name}`; + return rows.length > 0; + } catch { + return false; } finally { - await client.end().catch(() => {}); + await sql.end({ timeout: 5 }).catch(() => {}); } } + /** + * Open a short-lived maintenance connection to the embedded cluster's + * built-in `postgres` database (for CREATE DATABASE / existence checks). + * + * FNXC:WindowsDesktopPackaging 2026-07-14-22:53: + * Uses the statically imported postgres.js client (bundled into CLI) rather + * than embedded-postgres getPgClient, which requires this.process set by + * library start() — unavailable on the elevated Windows non-admin path. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private openMaintenanceSql(): any { + const port = this.getPort(); + if (port === undefined) { + throw new Error("openMaintenanceSql: no port assigned"); + } + const host = process.platform === "win32" ? "127.0.0.1" : "localhost"; + return postgres({ + host, + port, + user: this.options.user, + password: this.options.password, + database: "postgres", + max: 1, + connect_timeout: 10, + }); + } + /** * Stop the embedded PostgreSQL process. Safe to call multiple times. * After stop, the data directory is preserved (persistent), so a subsequent @@ -1128,6 +1277,24 @@ export class EmbeddedPostgresLifecycle { return; } + // FNXC:WindowsDesktopPackaging 2026-07-14-21:40: + // Elevated Windows path: the postmaster was started under a dedicated + // non-admin user; stop it via the handle instead of embedded-postgres + // (which never called .start() and has no process handle). + if (this.nonAdminHandle) { + try { + await this.nonAdminHandle.stop(); + } catch (err) { + this.options.onError(`embedded postgres: error during non-admin stop: ${String(err)}`); + } finally { + this.nonAdminHandle = null; + this.pg = null; + this.running = false; + runningInstances.delete(this.options.dataDir); + } + return; + } + if (!this.pg) { this.running = false; // Clean up the registry even if pg is null diff --git a/packages/core/src/postgres/embedded-windows-admin.ts b/packages/core/src/postgres/embedded-windows-admin.ts new file mode 100644 index 0000000000..b488509685 --- /dev/null +++ b/packages/core/src/postgres/embedded-windows-admin.ts @@ -0,0 +1,583 @@ +// FNXC:WindowsDesktopPackaging 2026-07-14-21:30: +// Embedded PostgreSQL refuses to start under a Windows process token whose +// Administrators group is ENABLED (a high-integrity / elevated token). It exits +// immediately with "Execution of PostgreSQL by a user with administrative +// permissions is not permitted." The bundled embedded-postgres server is +// spawned as a DIRECT child of the Node process (see embedded-postgres +// dist/index.js start()), so it inherits that elevated token and cannot boot. +// This only affects ELEVATED launches: GitHub windows-latest runners execute +// jobs as `runneradmin` with a fully elevated token (the smoke build fails +// here), and an end user who explicitly "Run as administrator" hits the same +// refusal. A normal Electron asInvoker launch uses a filtered/medium token +// which Postgres accepts. +// +// Fix: when the current process is elevated, boot the postgres SERVER process +// (only) under a freshly-created NON-ADMIN local user via Start-Process +// -Credential (CreateProcessWithLogonW). That user's token has no enabled +// Administrators group, so Postgres accepts it. initdb / the pg client / +// createDatabase still run as the (admin) launching process and work unchanged; +// only the server start is re-homed. Proven on the windows-2025-vs2026 runner: +// postgres reached "database system is ready to accept connections" under the +// dedicated non-admin user (broker diagnostic run 29382479266, job 87248898326). +// +// Access model: Windows grants "Bypass traverse checking" to Everyone by +// default, so the non-admin user does NOT need permission on parent dirs — only +// on the target dirs themselves. We grant the user RX on the native binary root +// and full control on the data dir. + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createConnection } from "node:net"; +import { join, dirname } from "node:path"; + +/** Handle returned by {@link startServerAsNonAdminUser}; call stop() to kill it. */ +export interface NonAdminServerHandle { + /** + * Best-effort OS pid of the running postgres server (from postmaster.pid when + * available). May be the cmd wrapper pid until postmaster.pid appears. + */ + readonly postgresPid: number; + /** Stop the non-admin postgres process (taskkill). Safe to call once. */ + stop(): Promise; +} + +export interface NonAdminStartOptions { + /** .../native dir containing bin/postgres.exe + lib + share. */ + readonly nativeRoot: string; + /** The initialized PG data directory. */ + readonly dataDir: string; + /** TCP port postgres should listen on. */ + readonly port: number; + /** Extra flags forwarded to postgres.exe (same semantics as embedded-postgres). */ + readonly postgresFlags: readonly string[]; + readonly onLog: (message: string) => void; + readonly onError: (messageOrError: string | Error | unknown) => void; + /** Hard timeout (ms) on reaching "ready to accept connections". */ + readonly startTimeoutMs: number; + /** + * FNXC:WindowsDesktopPackaging 2026-07-15-05:20: + * Cooperative cancellation from EmbeddedPostgresLifecycle.start()'s AbortController. + * When aborted during readiness polling, kill the wrapper/postmaster immediately. + */ + readonly signal?: AbortSignal; + /** + * Invoked as soon as the cmd wrapper PID is known (before readiness) so the + * lifecycle can stop orphans if the outer start() timeout wins the race. + */ + readonly onLaunched?: (handle: NonAdminServerHandle) => void; +} + +let elevatedCache: boolean | null = null; + +/** + * True only on Windows when the current process holds an elevated admin token. + * `net session` succeeds (exit 0) exclusively under an elevated admin token, so + * it is a reliable elevation probe that does not depend on UAC EnableLUA. + */ +export function isWindowsElevatedAdmin(): boolean { + if (process.platform !== "win32") return false; + if (elevatedCache !== null) return elevatedCache; + const r = spawnSync("net", ["session"], { encoding: "utf8", shell: true }); + elevatedCache = r.status === 0; + return elevatedCache; +} + +const DEDICATED_USER = "fusion-pg"; +let dedicatedPassword: string | null = null; + +/** + * FNXC:WindowsDesktopPackaging 2026-07-15-05:25: + * Fully randomized password with all four complexity classes and no fixed + * prefix/suffix (review feedback: constant frames reduce entropy). Avoids the + * account-name token so Windows complexity policy accepts it. + */ +function generatePassword(): string { + const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + const lower = "abcdefghijkmnopqrstuvwxyz"; + const digits = "23456789"; + const symbols = "!@#$%^*_-+=?"; + const all = upper + lower + digits + symbols; + const seed = + spawnSync( + "powershell", + ["-NoProfile", "-Command", "[BitConverter]::ToString([guid]::NewGuid().ToByteArray()) + [BitConverter]::ToString([guid]::NewGuid().ToByteArray())"], + { encoding: "utf8" }, + ).stdout ?? Math.random().toString(36) + Math.random().toString(36); + + const required = [ + upper[Math.floor(Math.random() * upper.length)]!, + lower[Math.floor(Math.random() * lower.length)]!, + digits[Math.floor(Math.random() * digits.length)]!, + symbols[Math.floor(Math.random() * symbols.length)]!, + ]; + let body = ""; + for (const ch of seed) { + if (/[a-zA-Z0-9]/.test(ch)) { + const idx = parseInt(ch.toLowerCase(), 16); + if (Number.isFinite(idx)) body += all[idx % all.length]!; + } + if (body.length >= 20) break; + } + while (body.length < 20) body += all[Math.floor(Math.random() * all.length)]!; + const chars = [...required, ...body.split("")]; + for (let i = chars.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = chars[i]!; + chars[i] = chars[j]!; + chars[j] = tmp; + } + return chars.join(""); +} + +/** + * Ensure the dedicated non-admin local user exists and we know its password. + * Idempotent: creates the user if absent, or resets its password if present + * (so a leftover account from a prior run still works). Always strips + * Administrators membership so a reused account cannot stay elevated. + */ +function ensureNonAdminUser(): { user: string; password: string } { + if (dedicatedPassword) return { user: DEDICATED_USER, password: dedicatedPassword }; + const password = generatePassword(); + const add = spawnSync("net", ["user", DEDICATED_USER, password, "/add", "/y"], { + encoding: "utf8", + }); + if (add.status !== 0) { + // Likely already exists from a prior run: reset its password so we can log on. + const reset = spawnSync("net", ["user", DEDICATED_USER, password, "/y"], { + encoding: "utf8", + }); + if (reset.status !== 0) { + throw new Error( + `embedded postgres: could not create/reset non-admin user '${DEDICATED_USER}' ` + + `(net user add status=${add.status}: ${(add.stderr || "").trim()}; ` + + `reset status=${reset.status}: ${(reset.stderr || "").trim()}).`, + ); + } + } + // FNXC:WindowsDesktopPackaging 2026-07-15-05:25: + // A leftover fusion-pg that was manually promoted to Administrators would + // still be refused by postgres. Demote and fail closed unless the account is + // already not a member (review: ignore silent demote failures). + // FNXC:WindowsDesktopPackaging 2026-07-14-22:53: + // net localgroup /delete status 0 = removed; non-zero is OK only when the + // account was already not in Administrators ("not a member" / "could not find"). + const demote = spawnSync( + "net", + ["localgroup", "Administrators", DEDICATED_USER, "/delete"], + { encoding: "utf8" }, + ); + if (demote.status !== 0) { + const demoteOut = `${demote.stdout || ""}\n${demote.stderr || ""}`.toLowerCase(); + const alreadyNotMember = + demoteOut.includes("not a member") || + demoteOut.includes("could not find") || + demoteOut.includes("no such") || + demoteOut.includes("does not exist"); + if (!alreadyNotMember) { + throw new Error( + `embedded postgres: failed to remove '${DEDICATED_USER}' from Administrators ` + + `(net localgroup status=${demote.status}): ` + + `${(demote.stderr || demote.stdout || "").trim().slice(0, 400)}. ` + + "PostgreSQL refuses to start under an administrative token; demote the " + + "account or run Fusion non-elevated.", + ); + } + } + dedicatedPassword = password; + return { user: DEDICATED_USER, password }; +} + +/** + * FNXC:WindowsDesktopPackaging 2026-07-14-22:30: + * Grant traverse (RX) on each ancestor dir of `leaf` up to the drive root, so + * the non-admin user can reach `leaf` even when "Bypass traverse checking" is + * restricted (the windows-2025 runner) or a profile ACL would deny traversal. + * RX is applied folder-by-folder as a non-inheriting ACE so sibling contents + * are not over-granted. Best-effort: ancestors that already allow traverse + * (e.g. C:\) reject harmlessly, and a blocked path surfaces later via the + * Start-Process error (which carries the full stderr). + */ +function grantTraverseChain(user: string, leaf: string): void { + let dir = dirname(leaf); + for (let depth = 0; depth < 16; depth += 1) { + const parent = dirname(dir); + if (parent === dir) break; // drive root reached + if (existsSync(dir)) { + spawnSync("icacls", [dir, "/grant", `${user}:(RX)`, "/C"], { encoding: "utf8" }); + } + dir = parent; + } +} + +/** + * Grant the non-admin user full control on the data dir (postgres writes + * there), read+execute on the native binary root, and traverse on each parent + * ancestor of both so the user can reach them. F/RX grants fail fast; the + * traverse walk is best-effort. /T applies the F/RX grants recursively; /C + * keeps going on non-fatal errors (e.g. unreadable sibling files). + */ +function grantNonAdminAccess(user: string, nativeRoot: string, dataDir: string): void { + for (const [target, perm] of [ + [dataDir, "(OI)(CI)F"], + [nativeRoot, "(OI)(CI)RX"], + ] as const) { + if (!existsSync(target)) { + throw new Error(`embedded postgres: non-admin grant target does not exist: ${target}`); + } + const r = spawnSync("icacls", [target, "/grant", `${user}:${perm}`, "/T", "/C"], { + encoding: "utf8", + }); + if (r.status !== 0) { + throw new Error( + `embedded postgres: failed to grant '${user}' ${perm} on ${target} ` + + `(icacls status=${r.status}): ${(r.stderr || "").trim().slice(0, 400)}`, + ); + } + } + grantTraverseChain(user, dataDir); + grantTraverseChain(user, nativeRoot); +} + +function readPostgresPid(dataDir: string): number | null { + try { + const lines = readFileSync(join(dataDir, "postmaster.pid"), "utf-8").split("\n"); + const pid = parseInt((lines[0] ?? "").trim(), 10); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function readTail(file: string, max: number): string { + try { + const content = readFileSync(file, "utf-8"); + return content.length > max ? "…" + content.slice(-max) : content; + } catch { + return "(no log file)"; + } +} + +/** + * FNXC:WindowsDesktopPackaging 2026-07-15-05:25: + * Reject postgresFlags that would break cmd.exe quoting or enable injection + * when embedded into launch.bat (review: arbitrary flags with % " & | etc.). + */ +function sanitizePostgresFlags(flags: readonly string[]): string[] { + const safe: string[] = []; + for (const flag of flags) { + if (typeof flag !== "string" || flag.length === 0) { + throw new Error(`embedded postgres: invalid postgresFlags entry (empty/non-string)`); + } + if (/[\r\n"%&|<>^!]/.test(flag)) { + throw new Error( + `embedded postgres: postgresFlags entry contains cmd.exe-sensitive characters: ${JSON.stringify(flag)}`, + ); + } + safe.push(flag); + } + return safe; +} + +/** Quote a path for cmd.exe double-quoted args (escape embedded quotes). */ +function cmdQuote(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +let pwshCache: string | null | undefined; +/** + * FNXC:WindowsDesktopPackaging 2026-07-14-22:10: + * Resolve the PowerShell binary used to launch the non-admin server. Prefer + * PowerShell 7 (`pwsh`): the windows-2025 runner runs Windows PowerShell 5.1 + * (`powershell.exe`) in Constrained Language Mode, where the + * Microsoft.PowerShell.Security module cannot load (ConvertTo-SecureString + * fails). pwsh runs unconstrained and is what the proven broker diagnostic + * used. Fall back to powershell.exe for end-user boxes that only have 5.1 in + * Full Language Mode. + */ +function resolvePowerShell(): string { + if (pwshCache !== undefined) return pwshCache as string; + const pf = process.env.PROGRAMFILES; + const pf86 = process.env["ProgramFiles(x86)"]; + const candidates = [ + pf ? join(pf, "PowerShell", "7", "pwsh.exe") : null, + pf86 ? join(pf86, "PowerShell", "7", "pwsh.exe") : null, + ].filter((v): v is string => v !== null); + for (const c of candidates) { + if (existsSync(c)) { + pwshCache = c; + return c; + } + } + const where = spawnSync("where", ["pwsh"], { encoding: "utf8", shell: true }); + if (where.status === 0) { + const found = (where.stdout || "") + .split(/\r?\n/) + .map((s) => s.trim()) + .find(Boolean); + if (found) { + pwshCache = found; + return found; + } + } + pwshCache = "powershell.exe"; + return pwshCache; +} + +/** + * Start postgres.exe under the dedicated non-admin user and resolve once it is + * accepting connections. Rejects with a clear error (including the postgres log + * tail) on timeout or early exit. The returned handle's stop() kills the server. + */ +export async function startServerAsNonAdminUser( + opts: NonAdminStartOptions, +): Promise { + const { user, password } = ensureNonAdminUser(); + grantNonAdminAccess(user, opts.nativeRoot, opts.dataDir); + + const pgExe = join(opts.nativeRoot, "bin", "postgres.exe"); + const runDir = join(opts.dataDir, ".pgrunner"); + mkdirSync(runDir, { recursive: true }); + const logFile = join(runDir, "postgres.log"); + // FNXC:WindowsDesktopPackaging 2026-07-14-22:50: + // Separate wrapper log: the bat echoes whoami / cwd / the exact postgres + // command / the exit code here (cmd's own output), while postgres's output + // goes to logFile. This distinguishes "bat never ran", "postgres exited", + // and "postgres running but not listening" — postgres.log alone can be empty + // when the bat never reaches the postgres command. + const wrapperLog = join(runDir, "wrapper.log"); + // FNXC:WindowsDesktopPackaging 2026-07-15-05:05: + // Truncate logs each launch. The bat appends (>>) so a prior stop that wrote + // `exit=1` would make the readiness poll throw "exited before becoming ready" + // on the next start against a reused data directory (VAL-CONN-006). + writeFileSync(logFile, "", "utf8"); + writeFileSync(wrapperLog, "", "utf8"); + const bat = join(runDir, "launch.bat"); + const safeFlags = sanitizePostgresFlags(opts.postgresFlags); + const args = ["-D", opts.dataDir, "-p", String(opts.port), ...safeFlags]; + // Set TMP/TEMP inside the granted data dir so the non-admin postgres process + // never writes outside an accessible location. + const argStr = args.map((a) => cmdQuote(a)).join(" "); + // FNXC:WindowsDesktopPackaging 2026-07-15-05:25: + // UTF-8 + chcp 65001 so non-ASCII profile paths (e.g. C:\Users\José) are not + // corrupted when cmd.exe reads the bat (review: ASCII encoding broke paths). + writeFileSync( + bat, + [ + "@echo off", + "chcp 65001 >nul", + `set "TMP=${runDir}"`, + `set "TEMP=${runDir}"`, + `call :main >> ${cmdQuote(wrapperLog)} 2>&1`, + "exit /b", + ":main", + "echo launch-start", + "whoami", + "cd", + `echo cmd: ${cmdQuote(pgExe)} ${argStr}`, + `${cmdQuote(pgExe)} ${argStr} > ${cmdQuote(logFile)} 2>&1`, + "echo exit=%ERRORLEVEL%", + "", + ].join("\r\n"), + "utf8", + ); + + const computerName = process.env.COMPUTERNAME ?? ""; + const domainUser = computerName ? `${computerName}\\${user}` : user; + // Launch detached under the non-admin credential. Start-Process returns at + // once with a process object (postgres keeps running in the background). + // + // FNXC:WindowsDesktopPackaging 2026-07-14-21:50: + // Use a parametrized launcher .ps1 invoked with -File + params, NOT an inline + // -Command string. The bat path contains backslashes (literal in a PS + // single-quoted string — doubling them would corrupt it to C:\\...) and the + // password contains ! and #; passing each as a discrete argv token via -File + // params is robust across Node's Windows arg escaping and PowerShell parsing. + const launcherPs1 = join(runDir, "launch.ps1"); + writeFileSync( + launcherPs1, + [ + "param([string]$User,[string]$Password,[string]$DomainUser,[string]$Bat)", + "$ErrorActionPreference='Stop'", + // FNXC:WindowsDesktopPackaging 2026-07-14-22:15: + // Build the SecureString char-by-char instead of ConvertTo-SecureString, + // which lives in Microsoft.PowerShell.Security — a module that fails to + // load under Windows PowerShell 5.1 Constrained Language Mode. System. + // Security.SecureString + PSCredential are core SMA/.NET types available + // without that module. + "$s = New-Object System.Security.SecureString", + "foreach ($ch in $Password.ToCharArray()) { [void]$s.AppendChar($ch) }", + "$s.MakeReadOnly()", + "$c = New-Object System.Management.Automation.PSCredential($DomainUser,$s)", + "$p = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c',$Bat -Credential $c -WindowStyle Hidden -PassThru", + "Write-Output $p.Id", + "", + ].join("\r\n"), + "utf8", + ); + const powerShell = resolvePowerShell(); + const launch = spawnSync( + powerShell, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPs1, + "-User", + user, + "-Password", + password, + "-DomainUser", + domainUser, + "-Bat", + bat, + ], + { encoding: "utf8" }, + ); + const wrapperPid = parseInt((launch.stdout || "").trim(), 10); + if (!Number.isFinite(wrapperPid)) { + throw new Error( + `embedded postgres: failed to launch non-admin postgres ` + + `(${powerShell} status=${launch.status} ` + + `stdout=${(launch.stdout || "").trim().slice(0, 500)} ` + + `stderr=${(launch.stderr || "").trim().slice(0, 2000)}).`, + ); + } + + let stopped = false; + const killAll = (): void => { + if (stopped) return; + stopped = true; + const pid = readPostgresPid(opts.dataDir); + if (pid) spawnSync("taskkill", ["/pid", String(pid), "/f", "/t"], { encoding: "utf8" }); + spawnSync("taskkill", ["/pid", String(wrapperPid), "/f", "/t"], { encoding: "utf8" }); + }; + + // FNXC:WindowsDesktopPackaging 2026-07-15-05:20: + // Publish a stop handle immediately so lifecycle timeout cleanup can kill the + // wrapper even while readiness is still polling (review: orphan on timeout). + const handle: NonAdminServerHandle = { + get postgresPid() { + return readPostgresPid(opts.dataDir) ?? wrapperPid; + }, + async stop() { + killAll(); + }, + }; + opts.onLaunched?.(handle); + + opts.onLog( + `embedded postgres: launched postgres as non-admin user '${user}' (wrapper pid ${wrapperPid}); ` + + `waiting for port ${opts.port}`, + ); + + // Poll for readiness until the server accepts connections or the timeout hits. + // FNXC:WindowsDesktopPackaging 2026-07-14-22:53: + // startTimeoutMs <= 0 means unbounded (matches outer lifecycle: 0 disables + // the start timeout). Math.max(..., 1000) previously forced a 1s deadline and + // killed elevated boots when callers disabled the timeout (review feedback). + const hasDeadline = + opts.startTimeoutMs > 0 && Number.isFinite(opts.startTimeoutMs); + const deadline = hasDeadline + ? Date.now() + opts.startTimeoutMs + : Number.POSITIVE_INFINITY; + let ready = false; + let lastSnapshot = ""; + while (Date.now() < deadline) { + if (opts.signal?.aborted) { + killAll(); + throw new Error( + `embedded postgres: non-admin launch cancelled before ready (wrapper pid ${wrapperPid}).`, + ); + } + // FNXC:WindowsDesktopPackaging 2026-07-14-23:05: + // Lightweight poll: readFileSync only. Do NOT spawn tasklist/probePort in + // the hot loop — a synchronous tasklist per iteration blocked ~16s between + // polls on windows-2025, blowing the test's 15s budget before postgres's + // "ready" marker was observed (and orphaning servers when start() never + // returned). Readiness = the postgres log "ready to accept connections" + // marker (the same one embedded-postgres watches). Exit = the wrapper bat's + // "exit=" line (written only once postgres returns). Errors = a FATAL in + // the postgres log. Logs are emitted only on change to avoid per-poll spam. + const tail = readTail(logFile, 3000); + const wrapperTail = readTail(wrapperLog, 1500); + const snapshot = `${wrapperTail}\u0000${tail.slice(-400)}`; + if (snapshot !== lastSnapshot) { + lastSnapshot = snapshot; + opts.onLog(`non-admin poll wrapper={${wrapperTail}} pg={${tail.slice(-400)}}`); + } + if (/database system is ready to accept connections/.test(tail)) { + // FNXC:WindowsDesktopPackaging 2026-07-15-05:00: + // Log readiness alone is not enough: confirm TCP accept on 127.0.0.1 so + // ensureDatabase cannot hang on a connect that never completes (IPv6 / + // cross-session loopback quirks). Probe is async and cheap. + if (await probeTcpPort(opts.port, 500)) { + ready = true; + break; + } + } + if (/\bFATAL\b|\bPANIC\b|could not (bind|start|create|access|connect|load)|not permitted|Permission denied|is not the owner/i.test(tail)) { + killAll(); + throw new Error( + `embedded postgres: non-admin postgres reported a startup error before opening the port.\n${tail}`, + ); + } + if (/^exit=/m.test(wrapperTail)) { + killAll(); + throw new Error( + `embedded postgres: non-admin postgres exited before becoming ready.\nwrapper={${wrapperTail}}\npg={${tail}}`, + ); + } + // Avoid Promise.withResolvers (needs lib es2024); package tsconfig stays on es2022. + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + } + + if (!ready) { + const tail = readTail(logFile, 1500); + killAll(); + throw new Error( + `embedded postgres: non-admin postgres did not become ready` + + (hasDeadline ? ` within ${opts.startTimeoutMs}ms` : "") + + `.\n${tail}`, + ); + } + + if (opts.signal?.aborted) { + killAll(); + throw new Error( + `embedded postgres: non-admin launch cancelled after ready (wrapper pid ${wrapperPid}).`, + ); + } + + const postgresPid = readPostgresPid(opts.dataDir); + if (!postgresPid) { + opts.onError("embedded postgres: started but could not read postmaster.pid"); + } + + const resolvedPid = postgresPid ?? wrapperPid; + opts.onLog( + `embedded postgres: non-admin server ready on 127.0.0.1:${opts.port} (pid ${resolvedPid})`, + ); + return handle; +} + +/** True when a TCP accept is available on 127.0.0.1:port within timeoutMs. */ +function probeTcpPort(port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host: "127.0.0.1", port }); + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.destroy(); + resolve(ok); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); +}