Files
fusion/packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.tsx
gsxdsm e5d6be4123 Fix Windows embedded PostgreSQL 0xC0000142 crashes (issue #2411) (#2418)
## Summary

Two-part fix for #2411 — Windows embedded PostgreSQL backends dying with
exception `0xC0000142` and taking the whole dashboard down.

### 1. Crash hardening + recovery (FN-8522)
- Child-only native `PATH` hardening so forked backends can always
resolve their runtime DLLs.
- Non-blocking `.pgrunner` log monitoring (shared read), eliminating the
self-inflicted ~30s `sharing violation` retry window at boot.
- Detection of the ordered 0xC0000142 shutdown sequence with a single
automatic restart of owned clusters on their resolved port, plus
operator diagnostics.

### 2. Platform-aware `max_connections` default (follow-up from
[operator
report](https://github.com/Runfusion/Fusion/issues/2411#issuecomment-5054900702))
On Windows every PostgreSQL connection is a separate process; the
embedded cluster's unconfigured `max_connections=500` cap lets backend
spawn bursts exhaust the non-interactive desktop heap, which kills
forked backends with exactly `0xC0000142`. The reporter confirmed
stability after lowering the cap.

- `embeddedPostgresMaxConnections` is now schema-unset so the server can
distinguish "operator never set it" from an explicit choice
(`getSettings()` merges schema defaults, which previously pinned 500
unconditionally and made the runtime fallback dead code).
- New `resolveEmbeddedMaxConnections()` resolves the unset default
platform-aware: **150 on win32, 500 elsewhere**. Explicit settings are
honored on every platform, clamped to [32, 2000] as before.
- Settings UI renders the cap empty ("auto") with platform-aware help
copy across all six locales.
- Fixed a latent reset bug this exposed: global "Reset this menu" wrote
`undefined` for undefined-default keys, which JSON serialization drops —
the stored value silently survived reset. Now uses null-as-delete.

## Testing
- New unit tests for `resolveEmbeddedMaxConnections` (platform defaults,
clamping, non-integer handling).
- Updated settings-defaults, default-descriptions, and SettingsModal
tests; embedded lifecycle + recovery coverage from FN-8522.
- `@fusion/core` builds clean; changesets included for both parts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:41:48 -07:00

162 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useTranslation } from "react-i18next";
import type { BackupListResponse } from "../../../api";
import { SettingsToggleRow } from "../SettingsToggleRow";
import { SettingsNumberRow } from "../SettingsNumberRow";
import { SettingsTextRow } from "../SettingsTextRow";
import type { BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "@fusion/core";
import type { SectionBaseProps } from "./context";
import { LoadingSpinner } from "../../LoadingSpinner";
export interface DatabaseBackupsSectionProps extends SectionBaseProps {
backupInfo: BackupListResponse | null;
backupLoading: boolean;
onBackupNow: () => void;
}
/*
FNXC:SettingsBackups 2026-07-16-14:35:
The global PostgreSQL cluster contains every project, so database controls and the one shared backup destination live in this global section. Memory snapshots remain in the project section.
*/
export function DatabaseBackupsSection({ form, setForm, backupInfo, backupLoading, onBackupNow }: DatabaseBackupsSectionProps) {
const { t } = useTranslation("app");
const conflicts = form.backupSettingsMigrationConflicts ?? [];
const chooseCandidate = (conflict: BackupSettingsMigrationConflict, candidate: BackupSettingsMigrationCandidate) => {
setForm((current) => ({
...current,
[conflict.key]: candidate.value,
backupSettingsMigrationConflicts: conflicts.filter((entry) => entry.key !== conflict.key),
}));
};
return (<>
{conflicts.length > 0 && <div className="settings-notice settings-notice-warning" role="status">
<strong>{t("settings.backups.migrationConflict", "Choose database backup settings")}</strong>
<p>{t("settings.backups.migrationConflictHelp", "Existing projects used different backup settings. Choose the value to use globally.")}</p>
{conflicts.map((conflict) => <div key={conflict.key}>
<span>{conflict.key}</span>
{conflict.candidates.map((candidate, index) => <button
type="button"
className="btn btn-sm"
key={`${candidate.source}-${candidate.projectId ?? "global"}-${index}`}
onClick={() => chooseCandidate(conflict, candidate)}
>
{candidate.source === "global"
? t("settings.backups.existingGlobalValue", "Existing global value")
: `${t("settings.backups.projectValue", "Project value")}: ${candidate.projectId}`}
</button>)}
</div>)}
</div>}
<h4 className="settings-section-heading">{t("settings.backups.databaseBackups", "Database Backups")}</h4>
<details className="settings-advanced-disclosure">
<summary>{t("settings.database.advanced", "Advanced database settings")}</summary>
<SettingsNumberRow
descriptor={{
key: "embeddedPostgresMaxConnections",
label: t("settings.database.embeddedConnectionCap", "Embedded PostgreSQL connection cap"),
help: t("settings.database.embeddedConnectionCapHelp", "Maximum server connections for Fusion's embedded PostgreSQL. Applies after restarting Fusion. Range: 32–2,000. Unset by default — Fusion picks 500, or 150 on Windows where each connection is a separate process and higher caps can crash backends. External PostgreSQL uses its provider's connection limit."),
scope: "global",
min: 32,
max: 2000,
placeholder: t("settings.database.embeddedConnectionCapPlaceholder", "auto"),
}}
value={form.embeddedPostgresMaxConnections ?? null}
onChange={(v) => setForm((f) => ({ ...f, embeddedPostgresMaxConnections: v ?? undefined }))}
error={form.embeddedPostgresMaxConnections !== undefined && (form.embeddedPostgresMaxConnections < 32 || form.embeddedPostgresMaxConnections > 2000)
? t("settings.database.embeddedConnectionCapError", "Enter a value between 32 and 2,000.")
: undefined}
/>
</details>
<SettingsToggleRow
descriptor={{
key: "autoBackupEnabled",
label: t("settings.backups.enableAutomaticDatabaseBackups", " Enable automatic database backups "),
help: t("settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically", "When enabled, the database is backed up automatically on a schedule. Default: disabled."),
scope: "global",
}}
value={form.autoBackupEnabled || false}
onChange={(v) => setForm((f) => ({ ...f, autoBackupEnabled: v === true }))}
/>
<SettingsTextRow
descriptor={{
key: "autoBackupSchedule",
label: t("settings.backups.backupScheduleCron", "Backup Schedule (Cron)"),
help: t("settings.backups.cronExpressionForBackupTimingDefault02", " Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) "),
scope: "global",
placeholder: t("settings.backups.02", "0 2 * * *"),
disabled: !form.autoBackupEnabled,
}}
value={form.autoBackupSchedule || "0 2 * * *"}
onChange={(v) => setForm((f) => ({ ...f, autoBackupSchedule: v ?? "" }))}
error={form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule)
? t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")
: undefined}
/>
<SettingsNumberRow
descriptor={{
key: "autoBackupRetention",
label: t("settings.backups.retentionCount", "Retention Count"),
help: t("settings.backups.numberOfBackupFilesToKeepOldestAre", "Number of backup files to keep (oldest are deleted first). Range: 1-100. Default: 7."),
scope: "global",
min: 1,
max: 100,
disabled: !form.autoBackupEnabled,
}}
value={form.autoBackupRetention ?? null}
onChange={(v) => setForm((f) => ({ ...f, autoBackupRetention: v ?? undefined }))}
error={form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100)
? t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")
: undefined}
/>
<SettingsTextRow
descriptor={{
key: "autoBackupDir",
label: t("settings.backups.backupDirectory", "Backup Directory"),
help: t("settings.backups.directoryForBackupFilesRelativeToProjectRoot", "Directory for backup files, relative to the global Fusion directory. Default: .fusion/backups."),
scope: "global",
placeholder: t("settings.backups.fusionBackups", ".fusion/backups"),
disabled: !form.autoBackupEnabled,
}}
value={form.autoBackupDir || ".fusion/backups"}
onChange={(v) => setForm((f) => ({ ...f, autoBackupDir: v ?? "" }))}
error={form.autoBackupDir && form.autoBackupDir.includes("..")
? t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")
: undefined}
/>
{backupLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.backups.loadingBackupInfo", "Loading backup info\u2026")} /></div>) : backupInfo ? (<div className="form-group">
<label>{t("settings.backups.currentBackups", "Current Backups")}</label>
<div className="backup-stats">
<div className="backup-stat">
<span className="backup-stat-value">{backupInfo.count}</span>
<span className="backup-stat-label">{t("settings.backups.backups", "backups")}</span>
</div>
<div className="backup-stat">
<span className="backup-stat-value">
{backupInfo.totalSize > 1024 * 1024
? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB`
: `${(backupInfo.totalSize / 1024).toFixed(1)} KB`}
</span>
<span className="backup-stat-label">{t("settings.backups.totalSize", "total size")}</span>
</div>
</div>
{backupInfo.backups.length > 0 && (<details className="backup-list">
<summary>{t("settings.backups.view", "View ")}{backupInfo.backups.length}{t("settings.backups.backupS", " backup(s)")}</summary>
<ul>
{backupInfo.backups.slice(0, 10).map((backup) => (<li key={backup.filename}>
<code>{backup.filename}</code>
<span className="backup-size">
{backup.size > 1024 * 1024
? `${(backup.size / (1024 * 1024)).toFixed(1)} MB`
: `${(backup.size / 1024).toFixed(1)} KB`}
</span>
</li>))}
{backupInfo.backups.length > 10 && (<li><em>{t("settings.backups.and", "...and ")}{backupInfo.backups.length - 10}{t("settings.backups.more", " more")}</em></li>)}
</ul>
</details>)}
</div>) : null}
<div className="form-group">
<button type="button" className="btn btn-sm" onClick={onBackupNow} disabled={backupLoading}>
{backupLoading ? t("settings.backups.creating", "Creating…") : t("settings.backups.backupNow", "Backup Now")}
</button>
</div>
</>);
}
export default DatabaseBackupsSection;