FN-9158: recover Windows antivirus-blocked PostgreSQL payloads
Recover quarantined or truncated embedded PostgreSQL runtime files and explain Windows Defender remediation. - Validate materialized payload inventory with v3 markers and repair mismatches atomically. - Surface actionable error 4551 diagnostics through startup failures. - Add regression coverage, operator documentation, and a patch changeset. Files changed: ...-9158-windows-av-blocked-embedded-pg-payload.md | 7 + ...ndows-antivirus-blocks-embedded-postgres-dll.md | 27 +++ docs/storage.md | 4 + .../__tests__/postgres/embedded-lifecycle.test.ts | 139 +++++++++++++- .../src/__tests__/postgres/startup-factory.test.ts | 19 ++ packages/core/src/postgres/embedded-lifecycle.ts | 208 ++++++++++++++++++++- packages/core/src/postgres/startup-factory.ts | 30 +-- 7 files changed, 414 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-9158 Fusion-Task-Lineage: ec2c97d7-0797-49c1-b875-6b147ff26917 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Recover automatically when Windows antivirus blocks a bundled PostgreSQL library.
|
||||
category: fix
|
||||
dev: Marker v3 verifies cached payload inventory, failed verification leaves no marker, and reports EmbeddedPostgresPayloadBlockedError.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
category: database-issues
|
||||
module: embedded-postgres
|
||||
tags: [windows, antivirus, postgresql, embedded-postgres]
|
||||
problem_type: installation
|
||||
applies_when: Windows startup reports `could not load library` with `unknown error 4551` for an embedded PostgreSQL DLL.
|
||||
---
|
||||
|
||||
# Windows antivirus blocks an embedded PostgreSQL DLL
|
||||
|
||||
## Symptom
|
||||
|
||||
Fusion fails during embedded PostgreSQL initialization with a message such as `could not load library .../lib/dict_snowball.dll: unknown error 4551`.
|
||||
|
||||
## Cause
|
||||
|
||||
Windows error 4551 is `ERROR_VIRUS_DELETED`: Windows Defender or another antivirus product quarantined a DLL from Fusion's host-local embedded PostgreSQL runtime payload. See [issue #3489](https://github.com/Runfusion/Fusion/issues/3489).
|
||||
|
||||
Earlier versions trusted a marker that identified the source payload but did not verify the copied destination. A quarantined destination DLL could therefore remain cached across every restart.
|
||||
|
||||
## Remedy
|
||||
|
||||
1. In **Windows Security**, open **Virus & threat protection** → **Manage settings** → **Exclusions** and add `%USERPROFILE%\.fusion\embedded-postgres`.
|
||||
2. Restore the quarantined DLL from **Protection history**.
|
||||
3. Restart Fusion.
|
||||
|
||||
Fusion verifies the runtime-bin payload on startup. Once the exclusion permits the copy, Fusion automatically re-materializes missing or truncated files and clears the recovery diagnosis.
|
||||
@@ -847,6 +847,10 @@ Project-scoped structured recall records for durable decisions, preferences, and
|
||||
|
||||
- Knowledge-graph artifact: `<rootDir>/.fusion-knowledge/graph/` (`nodes.json`, `edges.json`, and `manifest.json`). This is deliberately outside ignored `.fusion` and may be committed at the operator's discretion.
|
||||
|
||||
### Embedded PostgreSQL on Windows — antivirus and the runtime-bin payload
|
||||
|
||||
If Windows startup reports `unknown error 4551` while loading an embedded PostgreSQL DLL such as `dict_snowball.dll`, antivirus quarantined part of `%USERPROFILE%\\.fusion\\embedded-postgres`. Add that directory as a Windows Security exclusion, restore the file from Protection history, and restart Fusion. Fusion verifies and automatically re-copies the runtime payload; see [Windows antivirus blocks an embedded PostgreSQL DLL](solutions/database-issues/windows-antivirus-blocks-embedded-postgres-dll.md).
|
||||
|
||||
### Bounded task-intake lookups
|
||||
|
||||
Recommendation proposal claims use the indexed `findTaskByProposalClaimId` read (`uqTasksProjectProposalClaimId`), and same-agent intake reads only matching source lineage (`idxTasksProjectSourceAgentId` and `idxTasksSourceParentTaskId`). Do not replace either read with a `listTasks()` scan. Workflow terminal flags for intake duplicate checks are derived from workflow definitions, not board rows. Guarded-intake near-duplicate checks must remain bounded to their candidates (the fallback is `limit: 50`) and must not hydrate the full board.
|
||||
|
||||
@@ -56,6 +56,14 @@ import {
|
||||
materializeEmbeddedPostgresRuntimeBinaries,
|
||||
installElectronAsarNativePathPatch,
|
||||
uninstallElectronAsarNativePathPatchForTests,
|
||||
isWindowsBlockedNativeLibraryError,
|
||||
describeWindowsBlockedNativeLibraryError,
|
||||
EmbeddedPostgresPayloadBlockedError,
|
||||
recordEmbeddedPayloadIntegrityFailure,
|
||||
getEmbeddedPayloadIntegrityFailure,
|
||||
clearEmbeddedPayloadIntegrityFailure,
|
||||
decorateWindowsBlockedNativeLibraryError,
|
||||
embeddedPostgresRuntimeBinRoot,
|
||||
type EmbeddedLifecycleOptions,
|
||||
} from "../../postgres/embedded-lifecycle.js";
|
||||
|
||||
@@ -75,6 +83,7 @@ afterEach(async () => {
|
||||
__setWindowsElevatedAdminForTests(null);
|
||||
__setWindowsEmbeddedPostgresNativeRootForTests(null);
|
||||
__setWindowsLauncherForTests(null);
|
||||
clearEmbeddedPayloadIntegrityFailure();
|
||||
vi.useRealTimers();
|
||||
while (tracked.length > 0) {
|
||||
const { lifecycle, dataDir } = tracked.pop()!;
|
||||
@@ -124,6 +133,27 @@ describe("embedded-lifecycle: isDataDirInitialized (PG_VERSION marker)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded-lifecycle: Windows blocked native library classifier", () => {
|
||||
const issueOutput = `ERROR OUTPUT: 2026-08-19 09:20:48.546 CEST [23152] FATAL: could not load library "C:/Users/ppp/.fusion/embedded-postgres/runtime-bin/win32-x64/lib/dict_snowball.dll": unknown error 4551`;
|
||||
|
||||
it("classifies the Defender ERROR_VIRUS_DELETED failure from issue #3489", () => {
|
||||
expect(isWindowsBlockedNativeLibraryError(issueOutput)).toBe(true);
|
||||
expect(describeWindowsBlockedNativeLibraryError(issueOutput)).toContain("dict_snowball.dll");
|
||||
expect(describeWindowsBlockedNativeLibraryError(issueOutput)).toContain(
|
||||
"%USERPROFILE%\\.fusion\\embedded-postgres",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not misclassify unrelated library or encoding errors", () => {
|
||||
expect(isWindowsBlockedNativeLibraryError(
|
||||
"could not load library: The specified module could not be found",
|
||||
)).toBe(false);
|
||||
expect(isWindowsBlockedNativeLibraryError(
|
||||
"invalid byte sequence for encoding UTF8",
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded-lifecycle: Windows elevation probe (no process)", () => {
|
||||
it("isWindowsElevatedAdmin is false on non-Windows platforms", () => {
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-15-04:55:
|
||||
@@ -303,6 +333,33 @@ describe("embedded-lifecycle: Electron asar unpacked path rewrite", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded-lifecycle: payload integrity diagnosis lifecycle", () => {
|
||||
const verification = { acceptable: false, mismatches: ["lib/dict_snowball.dll"], mismatchCount: 1 };
|
||||
|
||||
it("is root-scoped, latest-wins, and resettable", () => {
|
||||
const first = new EmbeddedPostgresPayloadBlockedError("/native-a", "/runtime-a", verification);
|
||||
const second = new EmbeddedPostgresPayloadBlockedError("/native-b", "/runtime-b", verification);
|
||||
recordEmbeddedPayloadIntegrityFailure(first);
|
||||
recordEmbeddedPayloadIntegrityFailure(second);
|
||||
expect(getEmbeddedPayloadIntegrityFailure("/runtime-a")).toBeNull();
|
||||
expect(getEmbeddedPayloadIntegrityFailure("/runtime-b")).toBe(second);
|
||||
clearEmbeddedPayloadIntegrityFailure();
|
||||
expect(getEmbeddedPayloadIntegrityFailure()).toBeNull();
|
||||
});
|
||||
|
||||
it("decorates only matching-root start failures and preserves unrelated failures", () => {
|
||||
const root = embeddedPostgresRuntimeBinRoot();
|
||||
const failure = new EmbeddedPostgresPayloadBlockedError("/native", root, verification);
|
||||
recordEmbeddedPayloadIntegrityFailure(failure);
|
||||
expect(decorateWindowsBlockedNativeLibraryError(new Error("initdb failed"), root).message).toContain(
|
||||
"Windows antivirus blocked a bundled PostgreSQL library",
|
||||
);
|
||||
expect(decorateWindowsBlockedNativeLibraryError(new Error("initdb failed"), "/other-root").message).toBe(
|
||||
"initdb failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded-lifecycle: materialize runtime binaries (update-safe marker)", () => {
|
||||
/*
|
||||
* FNXC:DesktopEmbeddedPostgres 2026-07-15-02:55:
|
||||
@@ -327,6 +384,7 @@ describe("embedded-lifecycle: materialize runtime binaries (update-safe marker)"
|
||||
writeFileSync(join(root, "bin", initdbBin), "initdb-stub");
|
||||
writeFileSync(join(root, "bin", pgCtlBin), "pg_ctl-stub");
|
||||
writeFileSync(join(root, "lib", "postgresql", "plpgsql.so"), "ext-v1");
|
||||
writeFileSync(join(root, "lib", "dict_snowball.dll"), "snowball-v1");
|
||||
writeFileSync(join(root, "share", "postgresql", "postgres.bki"), "share-v1");
|
||||
}
|
||||
|
||||
@@ -400,7 +458,7 @@ describe("embedded-lifecycle: materialize runtime binaries (update-safe marker)"
|
||||
seedNativeRoot(nativeRoot, "postgres-body");
|
||||
const marker = buildEmbeddedPostgresMaterializationMarker(nativeRoot);
|
||||
const fingerprint = fingerprintEmbeddedPostgresNativeRoot(nativeRoot);
|
||||
expect(marker.startsWith("v2\n")).toBe(true);
|
||||
expect(marker.startsWith("v3\n")).toBe(true);
|
||||
expect(marker).toContain(nativeRoot);
|
||||
expect(marker).toContain(fingerprint);
|
||||
// Path alone must not equal the full marker (legacy path-only markers rematerialize).
|
||||
@@ -437,6 +495,62 @@ describe("embedded-lifecycle: materialize runtime binaries (update-safe marker)"
|
||||
}
|
||||
});
|
||||
|
||||
it("clears a recorded integrity failure after a verified repair", () => {
|
||||
const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-repair-src-"));
|
||||
const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-repair-dst-"));
|
||||
try {
|
||||
seedNativeRoot(nativeRoot, "postgres-stable");
|
||||
recordEmbeddedPayloadIntegrityFailure(
|
||||
new EmbeddedPostgresPayloadBlockedError(nativeRoot, destRoot, {
|
||||
acceptable: false,
|
||||
mismatches: ["lib/dict_snowball.dll"],
|
||||
mismatchCount: 1,
|
||||
}),
|
||||
);
|
||||
materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot });
|
||||
expect(existsSync(join(destRoot, ".materialized-from"))).toBe(true);
|
||||
expect(getEmbeddedPayloadIntegrityFailure(destRoot)).toBeNull();
|
||||
expect(decorateWindowsBlockedNativeLibraryError(new Error("unrelated failure"), destRoot).message).toBe(
|
||||
"unrelated failure",
|
||||
);
|
||||
} finally {
|
||||
rmSync(nativeRoot, { recursive: true, force: true });
|
||||
rmSync(destRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores a Defender-quarantined dict_snowball.dll on the next launch", () => {
|
||||
const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-av-src-"));
|
||||
const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-av-dst-"));
|
||||
try {
|
||||
seedNativeRoot(nativeRoot, "postgres-stable");
|
||||
materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot });
|
||||
rmSync(join(destRoot, "lib", "dict_snowball.dll"));
|
||||
|
||||
materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot });
|
||||
expect(readFileSync(join(destRoot, "lib", "dict_snowball.dll"), "utf8")).toBe("snowball-v1");
|
||||
} finally {
|
||||
rmSync(nativeRoot, { recursive: true, force: true });
|
||||
rmSync(destRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores a zero-byte truncated postgres executable", () => {
|
||||
const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-av-bin-src-"));
|
||||
const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-av-bin-dst-"));
|
||||
try {
|
||||
seedNativeRoot(nativeRoot, "postgres-stable");
|
||||
materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot });
|
||||
writeFileSync(join(destRoot, "bin", postgresBin), "");
|
||||
|
||||
materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot });
|
||||
expect(readFileSync(join(destRoot, "bin", postgresBin), "utf8")).toBe("postgres-stable");
|
||||
} finally {
|
||||
rmSync(nativeRoot, { recursive: true, force: true });
|
||||
rmSync(destRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("re-copies when payload changes even though nativeRoot path is unchanged", () => {
|
||||
const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-update-"));
|
||||
const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-update-dst-"));
|
||||
@@ -463,6 +577,29 @@ describe("embedded-lifecycle: materialize runtime binaries (update-safe marker)"
|
||||
}
|
||||
});
|
||||
|
||||
it("treats v2 markers as stale and rematerializes", () => {
|
||||
const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-v2-"));
|
||||
const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-v2-dst-"));
|
||||
try {
|
||||
seedNativeRoot(nativeRoot, "postgres-current");
|
||||
materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot });
|
||||
writeFileSync(
|
||||
join(destRoot, ".materialized-from"),
|
||||
buildEmbeddedPostgresMaterializationMarker(nativeRoot).replace("v3\n", "v2\n"),
|
||||
);
|
||||
writeFileSync(join(destRoot, ".reuse-sentinel"), "must-be-removed");
|
||||
|
||||
materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot });
|
||||
expect(existsSync(join(destRoot, ".reuse-sentinel"))).toBe(false);
|
||||
expect(readFileSync(join(destRoot, ".materialized-from"), "utf8")).toBe(
|
||||
buildEmbeddedPostgresMaterializationMarker(nativeRoot),
|
||||
);
|
||||
} finally {
|
||||
rmSync(nativeRoot, { recursive: true, force: true });
|
||||
rmSync(destRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("treats legacy path-only markers as stale and rematerializes", () => {
|
||||
const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-legacy-"));
|
||||
const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-legacy-dst-"));
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
EMBEDDED_PG_ENV,
|
||||
NO_EMBEDDED_PG_ENV,
|
||||
resolveStartupDatabaseOptions,
|
||||
formatPostgresSchemaBackendBootError,
|
||||
} from "../../postgres/startup-factory.js";
|
||||
import { resolveBackend } from "../../postgres/backend-resolver.js";
|
||||
|
||||
@@ -265,6 +266,24 @@ encoding-conversion failure raised when a non-UTF-8 cluster (WIN1252/WIN1254
|
||||
from a pre-fix initdb on a non-UTF-8 OS locale) receives the UTF-8 schema
|
||||
SQL — and nothing else, so ordinary schema errors never delete a data dir.
|
||||
*/
|
||||
describe("startup-factory Windows antivirus boot hint (#3489)", () => {
|
||||
const issueOutput = 'ERROR OUTPUT: FATAL: could not load library "C:/Users/ppp/.fusion/embedded-postgres/runtime-bin/win32-x64/lib/dict_snowball.dll": unknown error 4551';
|
||||
|
||||
it("adds exactly one actionable hint to the wrapped initdb failure", async () => {
|
||||
const message = await formatPostgresSchemaBackendBootError(
|
||||
new Error(`startup-factory: failed to start embedded PostgreSQL: ${issueOutput}`),
|
||||
);
|
||||
expect(message).toMatch(/^startup-factory: failed to initialize PostgreSQL schema backend:/);
|
||||
expect(message).toContain("dict_snowball.dll");
|
||||
expect(message.match(/Windows antivirus blocked a bundled PostgreSQL library/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not add the antivirus hint to unrelated boot failures", async () => {
|
||||
const message = await formatPostgresSchemaBackendBootError(new Error("connection refused"));
|
||||
expect(message).not.toContain("Windows antivirus blocked a bundled PostgreSQL library");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEncodingConversionError (#2286 recovery trigger)", () => {
|
||||
it("matches the encoding-conversion failure from a non-UTF-8 cluster", () => {
|
||||
expect(
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
import { createHash } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { createServer, type Server } from "node:net";
|
||||
import { dirname, join, basename, sep } from "node:path";
|
||||
import { dirname, join, basename, sep, resolve } from "node:path";
|
||||
import { createRequire, syncBuiltinESMExports } from "node:module";
|
||||
import { createLogger } from "../process/logger.js";
|
||||
import { redactConnectionString } from "./credential-redact.js";
|
||||
@@ -202,7 +202,7 @@ const EMBEDDED_PG_BIN_NAMES = new Set([
|
||||
* host-local caches always rematerialize after a desktop update that ships a
|
||||
* new fingerprinting strategy (e.g. content-hashing lib/share, not path+size).
|
||||
*/
|
||||
const MATERIALIZATION_MARKER_VERSION = 2;
|
||||
const MATERIALIZATION_MARKER_VERSION = 3;
|
||||
|
||||
/**
|
||||
* FNXC:DesktopEmbeddedPostgres 2026-07-14-18:30:
|
||||
@@ -361,6 +361,40 @@ function hashPayloadTreeContents(
|
||||
* binaries instead of reusing the previous release's host-local cache.
|
||||
* Legacy path-only markers fail equality and force rematerialization.
|
||||
*/
|
||||
/*
|
||||
* FNXC:PostgresEmbedded 2026-08-20-01:11:
|
||||
* Issue #3489 reports Windows Defender removing dict_snowball.dll while PostgreSQL
|
||||
* initializes. Win32 4550/4551 are the virus-infected/deleted codes, so match both
|
||||
* signatures narrowly; generic library-load errors have unrelated remediation.
|
||||
*/
|
||||
export function isWindowsBlockedNativeLibraryError(errorOrText: unknown): boolean {
|
||||
const text = errorOrText instanceof Error ? errorOrText.message : String(errorOrText ?? "");
|
||||
return (/could not load library/i.test(text) && /unknown error 455[01]/i.test(text))
|
||||
|| /ERROR_VIRUS_(?:INFECTED|DELETED)/i.test(text);
|
||||
}
|
||||
|
||||
/** Creates the shared, actionable Windows antivirus recovery instruction. */
|
||||
export function describeWindowsBlockedNativeLibraryError(errorOrText: unknown): string {
|
||||
const text = errorOrText instanceof Error ? errorOrText.message : String(errorOrText ?? "");
|
||||
const blockedPath = text.match(/could not load library\s+["']([^"']+)["']/i)?.[1];
|
||||
const pathDetail = blockedPath ? ` Blocked file: ${blockedPath}.` : "";
|
||||
return `${pathDetail} HINT: Windows antivirus blocked a bundled PostgreSQL library. Add an exclusion for %USERPROFILE%\\.fusion\\embedded-postgres in Windows Security → Virus & threat protection → Manage settings → Exclusions, restore the quarantined file from Protection history, then restart Fusion. Fusion re-copies the runtime automatically.`;
|
||||
}
|
||||
|
||||
export function decorateWindowsBlockedNativeLibraryError(error: unknown, destRoot?: string): Error {
|
||||
const recorded = getEmbeddedPayloadIntegrityFailure(destRoot);
|
||||
if (!isWindowsBlockedNativeLibraryError(error) && !recorded) {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Windows antivirus blocked a bundled PostgreSQL library")) {
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
return new Error(`${message}${recorded ? ` ${recorded.message}` : describeWindowsBlockedNativeLibraryError(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildEmbeddedPostgresMaterializationMarker(nativeRoot: string): string {
|
||||
const fingerprint = fingerprintEmbeddedPostgresNativeRoot(nativeRoot);
|
||||
return `v${MATERIALIZATION_MARKER_VERSION}\n${nativeRoot}\n${fingerprint}\n`;
|
||||
@@ -374,6 +408,134 @@ function resolveMaterializedEmbeddedPostgresBinary(filePath: string): string | n
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
type PayloadInventoryResult = {
|
||||
readonly acceptable: boolean;
|
||||
readonly mismatches: readonly string[];
|
||||
readonly mismatchCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* FNXC:PostgresEmbedded 2026-08-20-01:11:
|
||||
* A marker fingerprints only the source. Defender can remove a copied DLL after
|
||||
* materialization, leaving that marker truthful but the destination unusable.
|
||||
* Compare the bounded source inventory on reuse; exhaustion and unreadable source
|
||||
* fail open so a healthy host never incurs an unbounded copy or boot refusal.
|
||||
*/
|
||||
function verifyEmbeddedPostgresPayloadInventory(
|
||||
nativeRoot: string,
|
||||
destRoot: string,
|
||||
): PayloadInventoryResult {
|
||||
const budget = { remaining: 4096 };
|
||||
const mismatches: string[] = [];
|
||||
let mismatchCount = 0;
|
||||
let sourceUnreadable = false;
|
||||
const report = (relativePath: string) => {
|
||||
mismatchCount += 1;
|
||||
if (mismatches.length < 10) mismatches.push(relativePath);
|
||||
};
|
||||
const visit = (sourcePath: string, destPath: string, relativePath: string): void => {
|
||||
if (budget.remaining <= 0 || sourceUnreadable) return;
|
||||
let sourceEntries: string[];
|
||||
try {
|
||||
sourceEntries = readdirSync(sourcePath).sort();
|
||||
} catch {
|
||||
sourceUnreadable = true;
|
||||
return;
|
||||
}
|
||||
for (const entry of sourceEntries) {
|
||||
if (budget.remaining <= 0 || sourceUnreadable) return;
|
||||
const sourceEntry = join(sourcePath, entry);
|
||||
const destEntry = join(destPath, entry);
|
||||
const relativeEntry = relativePath ? `${relativePath}/${entry}` : entry;
|
||||
try {
|
||||
const sourceStat = lstatSync(sourceEntry);
|
||||
if (sourceStat.isDirectory()) {
|
||||
visit(sourceEntry, destEntry, relativeEntry);
|
||||
} else if (sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
budget.remaining -= 1;
|
||||
let destStat: ReturnType<typeof lstatSync>;
|
||||
try {
|
||||
destStat = lstatSync(destEntry);
|
||||
} catch {
|
||||
report(relativeEntry);
|
||||
continue;
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
if (!destStat.isFile() || destStat.size !== sourceStat.size) report(relativeEntry);
|
||||
} else {
|
||||
if (!destStat.isSymbolicLink()) report(relativeEntry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
sourceUnreadable = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const tree of ["bin", "lib", "share"] as const) {
|
||||
const sourceTree = join(nativeRoot, tree);
|
||||
try {
|
||||
if (lstatSync(sourceTree).isDirectory()) visit(sourceTree, join(destRoot, tree), tree);
|
||||
} catch {
|
||||
sourceUnreadable = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
acceptable: sourceUnreadable || budget.remaining <= 0 || mismatchCount === 0,
|
||||
mismatches,
|
||||
mismatchCount,
|
||||
};
|
||||
}
|
||||
|
||||
export class EmbeddedPostgresPayloadBlockedError extends Error {
|
||||
readonly destRoot: string;
|
||||
readonly nativeRoot: string;
|
||||
readonly affectedPaths: readonly string[];
|
||||
readonly affectedPathCount: number;
|
||||
|
||||
constructor(nativeRoot: string, destRoot: string, verification: PayloadInventoryResult) {
|
||||
const affected = verification.mismatches.join(", ") || "unknown payload entry";
|
||||
super(
|
||||
`Embedded PostgreSQL runtime payload is incomplete (${affected}${verification.mismatchCount > verification.mismatches.length ? ", …" : ""}).${describeWindowsBlockedNativeLibraryError(affected)}`,
|
||||
);
|
||||
this.name = "EmbeddedPostgresPayloadBlockedError";
|
||||
this.nativeRoot = nativeRoot;
|
||||
this.destRoot = destRoot;
|
||||
this.affectedPaths = verification.mismatches;
|
||||
this.affectedPathCount = verification.mismatchCount;
|
||||
}
|
||||
}
|
||||
|
||||
type EmbeddedPayloadIntegrityFailure = {
|
||||
readonly destRoot: string;
|
||||
readonly error: EmbeddedPostgresPayloadBlockedError;
|
||||
readonly recordedAt: number;
|
||||
};
|
||||
|
||||
let embeddedPayloadIntegrityFailure: EmbeddedPayloadIntegrityFailure | null = null;
|
||||
|
||||
/** Records only the newest root-scoped incomplete-payload diagnosis. */
|
||||
export function recordEmbeddedPayloadIntegrityFailure(error: EmbeddedPostgresPayloadBlockedError): void {
|
||||
embeddedPayloadIntegrityFailure = { destRoot: resolve(error.destRoot), error, recordedAt: Date.now() };
|
||||
}
|
||||
|
||||
/** Returns the diagnostic only for its originating materialized runtime root. */
|
||||
export function getEmbeddedPayloadIntegrityFailure(destRoot?: string): EmbeddedPostgresPayloadBlockedError | null {
|
||||
if (!embeddedPayloadIntegrityFailure) return null;
|
||||
if (destRoot && resolve(destRoot) !== embeddedPayloadIntegrityFailure.destRoot) return null;
|
||||
return embeddedPayloadIntegrityFailure.error;
|
||||
}
|
||||
|
||||
/** Clears the test-resettable, in-memory payload diagnostic. */
|
||||
export function clearEmbeddedPayloadIntegrityFailure(): void {
|
||||
embeddedPayloadIntegrityFailure = null;
|
||||
}
|
||||
|
||||
function clearEmbeddedPayloadIntegrityFailureForRoot(destRoot: string): void {
|
||||
if (embeddedPayloadIntegrityFailure?.destRoot === resolve(destRoot)) {
|
||||
clearEmbeddedPayloadIntegrityFailure();
|
||||
}
|
||||
}
|
||||
|
||||
export interface MaterializeEmbeddedPostgresOptions {
|
||||
/**
|
||||
* Override the host-local dest root (tests). Defaults to
|
||||
@@ -413,8 +575,10 @@ export function materializeEmbeddedPostgresRuntimeBinaries(
|
||||
existsSync(marker) &&
|
||||
readFileSync(marker, "utf8") === sourceMarker &&
|
||||
existsSync(join(destBin, process.platform === "win32" ? "postgres.exe" : "postgres")) &&
|
||||
existsSync(join(destRoot, "lib", "postgresql"))
|
||||
existsSync(join(destRoot, "lib", "postgresql")) &&
|
||||
verifyEmbeddedPostgresPayloadInventory(nativeRoot, destRoot).acceptable
|
||||
) {
|
||||
clearEmbeddedPayloadIntegrityFailureForRoot(destRoot);
|
||||
return destRoot;
|
||||
}
|
||||
|
||||
@@ -424,6 +588,12 @@ export function materializeEmbeddedPostgresRuntimeBinaries(
|
||||
* payload cannot linger beside the updated binaries (force-copy alone does not
|
||||
* delete orphans).
|
||||
*/
|
||||
// Never let a prior marker certify a partially copied payload after a crash or AV action.
|
||||
try {
|
||||
unlinkSync(marker);
|
||||
} catch {
|
||||
// best-effort; recursive destination removal below is the normal cleanup path
|
||||
}
|
||||
if (existsSync(destRoot)) {
|
||||
rmSync(destRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -446,7 +616,19 @@ export function materializeEmbeddedPostgresRuntimeBinaries(
|
||||
}
|
||||
// Re-apply macOS ABI compatibility links against the materialized lib dir.
|
||||
normalizeMacosEmbeddedPostgresDylibSymlinks(destRoot);
|
||||
const verification = verifyEmbeddedPostgresPayloadInventory(nativeRoot, destRoot);
|
||||
if (!verification.acceptable) {
|
||||
try {
|
||||
unlinkSync(marker);
|
||||
} catch {
|
||||
// No marker is the retry contract; ignore an already-absent marker.
|
||||
}
|
||||
const error = new EmbeddedPostgresPayloadBlockedError(nativeRoot, destRoot, verification);
|
||||
recordEmbeddedPayloadIntegrityFailure(error);
|
||||
throw error;
|
||||
}
|
||||
writeFileSync(marker, sourceMarker, "utf8");
|
||||
clearEmbeddedPayloadIntegrityFailureForRoot(destRoot);
|
||||
return destRoot;
|
||||
}
|
||||
|
||||
@@ -525,7 +707,10 @@ export function installElectronAsarNativePathPatch(): void {
|
||||
materializeEmbeddedPostgresRuntimeBinaries(sourceRoot);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof EmbeddedPostgresPayloadBlockedError) {
|
||||
recordEmbeddedPayloadIntegrityFailure(error);
|
||||
}
|
||||
// Materialization is best-effort; path rewrite still helps when possible.
|
||||
}
|
||||
|
||||
@@ -970,7 +1155,10 @@ function resolveWindowsEmbeddedPostgresNativeRoot(): string | null {
|
||||
return materializeEmbeddedPostgresRuntimeBinaries(
|
||||
resolveElectronAsarUnpackedPath(nativeRoot),
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof EmbeddedPostgresPayloadBlockedError) {
|
||||
recordEmbeddedPayloadIntegrityFailure(error);
|
||||
}
|
||||
return resolveElectronAsarUnpackedPath(nativeRoot);
|
||||
}
|
||||
}
|
||||
@@ -1531,7 +1719,11 @@ export class EmbeddedPostgresLifecycle {
|
||||
this.options.onLog(
|
||||
`embedded postgres: initializing new data directory at ${this.options.dataDir} (initdb)`,
|
||||
);
|
||||
await pg.initialise();
|
||||
try {
|
||||
await pg.initialise();
|
||||
} catch (error) {
|
||||
throw decorateWindowsBlockedNativeLibraryError(error, embeddedPostgresRuntimeBinRoot());
|
||||
}
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
@@ -1603,7 +1795,9 @@ export class EmbeddedPostgresLifecycle {
|
||||
then failed later read back its OWN postmaster.pid, "join itself" with ownsProcess=false,
|
||||
and orphan a live postmaster nothing would ever stop. See isPostgresLockCollisionError.
|
||||
*/
|
||||
if (!isPostgresLockCollisionError(error)) throw error;
|
||||
if (!isPostgresLockCollisionError(error)) {
|
||||
throw decorateWindowsBlockedNativeLibraryError(error, embeddedPostgresRuntimeBinRoot());
|
||||
}
|
||||
const existing = await isAlreadyRunning(this.options.dataDir, this.options.onLog);
|
||||
if (!existing) throw error;
|
||||
|
||||
|
||||
@@ -130,6 +130,19 @@ function fallbackProjectIdForRoot(rootDir: string): string {
|
||||
* kept for backward compatibility with scripts/docs that still set it. It
|
||||
* cannot force embedded mode when DATABASE_URL is set (external always wins).
|
||||
*/
|
||||
export async function formatPostgresSchemaBackendBootError(err: unknown): Promise<string> {
|
||||
const chain = describeErrorChain(err);
|
||||
const encodingHint = /has no equivalent in encoding/i.test(chain)
|
||||
? " HINT: this embedded PostgreSQL cluster was created with a non-UTF-8 encoding inherited from the OS locale by an earlier Fusion version. It cannot be converted in place — stop Fusion, delete the embedded data directory (default: ~/.fusion/embedded-postgres/default), and start again so the cluster is recreated as UTF-8."
|
||||
: "";
|
||||
const { isWindowsBlockedNativeLibraryError, describeWindowsBlockedNativeLibraryError } =
|
||||
await import("./embedded-lifecycle.js");
|
||||
const blockedLibraryHint = isWindowsBlockedNativeLibraryError(chain)
|
||||
? describeWindowsBlockedNativeLibraryError(chain)
|
||||
: "";
|
||||
return `startup-factory: failed to initialize PostgreSQL schema backend: ${chain}${encodingHint}${blockedLibraryHint}`;
|
||||
}
|
||||
|
||||
export const EMBEDDED_PG_ENV = "FUSION_EMBEDDED_PG";
|
||||
|
||||
/**
|
||||
@@ -869,20 +882,13 @@ export async function createTaskStoreForBackend(
|
||||
boot = await bootSchemaBackend(effectiveOptions);
|
||||
log.log(`startup phase backend.schemaBackend: ${Date.now() - schemaT0}ms`);
|
||||
} catch (err) {
|
||||
const chain = describeErrorChain(err);
|
||||
/*
|
||||
FNXC:PostgresEmbedded 2026-07-18-00:20:
|
||||
Issue #2286: a cluster initdb'd by an earlier version on a non-UTF-8 OS
|
||||
locale cannot store the UTF-8 schema SQL and cannot be converted in place.
|
||||
Newly created clusters are forced to UTF-8 (DEFAULT_EMBEDDED_INITDB_FLAGS);
|
||||
existing ones need a manual re-init, so say exactly that.
|
||||
FNXC:PostgresEmbedded 2026-08-20-01:11:
|
||||
Issue #3489 uses the same outer boot mapper as #2286's encoding guidance.
|
||||
initdb wraps PostgreSQL's FATAL before this boundary, so retain the chain
|
||||
while adding one actionable antivirus hint only for 4550/4551 signatures.
|
||||
*/
|
||||
const encodingHint = /has no equivalent in encoding/i.test(chain)
|
||||
? " HINT: this embedded PostgreSQL cluster was created with a non-UTF-8 encoding inherited from the OS locale by an earlier Fusion version. It cannot be converted in place — stop Fusion, delete the embedded data directory (default: ~/.fusion/embedded-postgres/default), and start again so the cluster is recreated as UTF-8."
|
||||
: "";
|
||||
throw new Error(
|
||||
`startup-factory: failed to initialize PostgreSQL schema backend: ${chain}${encodingHint}`,
|
||||
);
|
||||
throw new Error(await formatPostgresSchemaBackendBootError(err));
|
||||
}
|
||||
let { connections } = boot;
|
||||
const {
|
||||
|
||||
Reference in New Issue
Block a user