fix: harden Pi lifecycle recovery
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
closeSync,
|
||||
fsyncSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { AppConfig } from "../config.js";
|
||||
|
||||
@@ -9,6 +18,10 @@ export interface Settings {
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
export interface SettingsDurability {
|
||||
syncDirectory(directory: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings files are installation defaults only. Personal workspace/model/thinking choices
|
||||
* belong to the browser and must never be written back here by request handlers.
|
||||
@@ -27,23 +40,65 @@ export function loadSettings(cfg: AppConfig): Settings {
|
||||
}
|
||||
|
||||
/** Persist settings (pretty JSON). Creates the parent directory if needed. */
|
||||
export function saveSettings(cfg: AppConfig, s: Settings): Settings {
|
||||
export function saveSettings(
|
||||
cfg: AppConfig,
|
||||
s: Settings,
|
||||
durability: SettingsDurability = defaultDurability,
|
||||
): Settings {
|
||||
mkdirSync(dirname(cfg.settingsFile), { recursive: true });
|
||||
const directory = dirname(cfg.settingsFile);
|
||||
const temporary = `${cfg.settingsFile}.tmp-${process.pid}-${Date.now()}`;
|
||||
const fd = openSync(temporary, "wx", 0o600);
|
||||
let previous: Buffer | undefined;
|
||||
try {
|
||||
writeFileSync(fd, JSON.stringify(s, null, 2) + "\n", "utf8");
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
previous = readFileSync(cfg.settingsFile);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
renameSync(temporary, cfg.settingsFile);
|
||||
// The core image runs Linux. Keep the directory acknowledgement explicit there; Windows
|
||||
// filesystem replacement semantics are delegated to the host-side Go durable writer.
|
||||
if (process.platform !== "win32") {
|
||||
const dirFd = openSync(directory, "r");
|
||||
try { fsyncSync(dirFd); } finally { closeSync(dirFd); }
|
||||
|
||||
replaceSettingsFile(cfg.settingsFile, Buffer.from(JSON.stringify(s, null, 2) + "\n", "utf8"));
|
||||
try {
|
||||
durability.syncDirectory(directory);
|
||||
} catch (durabilityError) {
|
||||
try {
|
||||
if (previous === undefined) unlinkSync(cfg.settingsFile);
|
||||
else replaceSettingsFile(cfg.settingsFile, previous);
|
||||
durability.syncDirectory(directory);
|
||||
} catch {
|
||||
throw new Error("settings durability failed and previous settings could not be restored", {
|
||||
cause: durabilityError,
|
||||
});
|
||||
}
|
||||
throw durabilityError;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
let temporarySequence = 0;
|
||||
|
||||
function replaceSettingsFile(path: string, contents: Buffer): void {
|
||||
const temporary = `${path}.tmp-${process.pid}-${Date.now()}-${temporarySequence++}`;
|
||||
const fd = openSync(temporary, "wx", 0o600);
|
||||
try {
|
||||
writeFileSync(fd, contents);
|
||||
fsyncSync(fd);
|
||||
} catch (error) {
|
||||
try { closeSync(fd); } catch { /* preserve the write error */ }
|
||||
try { unlinkSync(temporary); } catch { /* best effort */ }
|
||||
throw error;
|
||||
}
|
||||
closeSync(fd);
|
||||
try {
|
||||
renameSync(temporary, path);
|
||||
} catch (error) {
|
||||
try { unlinkSync(temporary); } catch { /* best effort */ }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDurability: SettingsDurability = {
|
||||
syncDirectory(directory: string): void {
|
||||
// The core image runs Linux. Windows durability is owned by the host-side Go executable.
|
||||
if (process.platform === "win32") return;
|
||||
const dirFd = openSync(directory, "r");
|
||||
try { fsyncSync(dirFd); } finally { closeSync(dirFd); }
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user