fix: finalize durable Pi lifecycle

This commit is contained in:
2026-08-04 21:34:08 +02:00
parent 5ba2821a1b
commit a368889838
20 changed files with 1016 additions and 147 deletions
+18 -4
View File
@@ -111,12 +111,26 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
maintenanceBarrier,
});
app.post("/internal/maintenance/activate", async (req, reply) => {
await maintenanceBarrier.activate();
return maintenanceBarrier.status();
try {
await maintenanceBarrier.activate();
return maintenanceBarrier.status();
} catch {
return reply.code(500).send({
...maintenanceBarrier.status(),
error: "maintenance activation durability was not acknowledged",
});
}
});
app.post("/internal/maintenance/deactivate", async (req, reply) => {
maintenanceBarrier.deactivate();
return maintenanceBarrier.status();
try {
maintenanceBarrier.deactivate();
return maintenanceBarrier.status();
} catch {
return reply.code(500).send({
...maintenanceBarrier.status(),
error: "maintenance deactivation durability was not acknowledged",
});
}
});
app.get("/internal/maintenance/status", async (req, reply) => {
return maintenanceBarrier.status();
+52 -15
View File
@@ -10,17 +10,25 @@ import {
} from "node:fs";
import { dirname } from "node:path";
export interface MaintenanceDurability {
syncDirectory(directory: string): void;
}
/** A durable admission barrier. A lease spans the complete create/resume decision. */
export class MaintenanceBarrier {
private active: boolean;
private admissions = 0;
private waiters: (() => void)[] = [];
constructor(private readonly markerFile?: string) {
constructor(
private readonly markerFile?: string,
private readonly durability: MaintenanceDurability = defaultDurability,
) {
this.active = markerFile === undefined ? false : existsSync(markerFile);
}
acquire(): (() => void) | undefined {
this.reconcileActive();
if (this.active) return undefined;
this.admissions += 1;
let released = false;
@@ -33,17 +41,44 @@ export class MaintenanceBarrier {
}
async activate(): Promise<void> {
this.persistMarker();
this.active = true;
if (this.admissions === 0) return;
await new Promise<void>((resolve) => this.waiters.push(resolve));
let persistError: unknown;
if (this.markerFile === undefined) {
this.active = true;
} else {
try {
this.persistMarker();
} catch (error) {
persistError = error;
} finally {
this.reconcileActive();
}
}
if (!this.active) throw persistError;
if (this.admissions > 0) {
await new Promise<void>((resolve) => this.waiters.push(resolve));
}
if (persistError !== undefined) throw persistError;
}
deactivate(): void {
this.removeMarker();
this.active = false;
if (this.markerFile === undefined) {
this.active = false;
return;
}
try {
this.removeMarker();
} finally {
this.reconcileActive();
}
}
status(): { active: boolean; admissions: number } {
this.reconcileActive();
return { active: this.active, admissions: this.admissions };
}
private reconcileActive(): void {
if (this.markerFile !== undefined) this.active = existsSync(this.markerFile);
}
status(): { active: boolean; admissions: number } { return { active: this.active, admissions: this.admissions }; }
private persistMarker(): void {
if (!this.markerFile) return;
@@ -59,7 +94,7 @@ export class MaintenanceBarrier {
}
try {
renameSync(temporary, this.markerFile);
syncDirectory(directory);
this.durability.syncDirectory(directory);
} catch (error) {
try { unlinkSync(temporary); } catch { /* already renamed or best-effort cleanup */ }
throw error;
@@ -69,12 +104,14 @@ export class MaintenanceBarrier {
private removeMarker(): void {
if (!this.markerFile || !existsSync(this.markerFile)) return;
unlinkSync(this.markerFile);
syncDirectory(dirname(this.markerFile));
this.durability.syncDirectory(dirname(this.markerFile));
}
}
function syncDirectory(directory: string): void {
if (process.platform === "win32") return;
const fd = openSync(directory, "r");
try { fsyncSync(fd); } finally { closeSync(fd); }
}
const defaultDurability: MaintenanceDurability = {
syncDirectory(directory: string): void {
if (process.platform === "win32") return;
const fd = openSync(directory, "r");
try { fsyncSync(fd); } finally { closeSync(fd); }
},
};
+27 -9
View File
@@ -1,7 +1,15 @@
/* Core-side, non-interactive installation-default writer used only through compose exec.
* It accepts no credentials and writes the same SETTINGS_FILE consumed by session creation. */
import { readFileSync } from "node:fs";
import { loadConfig } from "../config.js";
import { loadSettings, saveSettings, type Settings } from "./settings-store.js";
import {
captureSettingsSnapshot,
loadSettings,
restoreSettingsSnapshot,
saveSettings,
type Settings,
type SettingsSnapshot,
} from "./settings-store.js";
const choice = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
@@ -15,15 +23,25 @@ function value(args: string[], flag: string): string {
try {
const args = process.argv.slice(2);
if (args.length !== 6) throw new Error("only provider, model, and thinking may be configured");
const provider = value(args, "--provider");
const model = value(args, "--model");
const thinking = value(args, "--thinking");
if (!choice.test(provider) || !choice.test(model)) throw new Error("invalid provider or model");
if (!["low", "medium", "high"].includes(thinking)) throw new Error("invalid thinking level");
const cfg = loadConfig(process.env);
const next: Settings = { ...loadSettings(cfg), provider, model, thinking };
saveSettings(cfg, next);
if (args.length === 1 && args[0] === "--snapshot") {
process.stdout.write(`${JSON.stringify(captureSettingsSnapshot(cfg))}\n`);
} else if (args.length === 1 && args[0] === "--restore") {
const parsed = JSON.parse(readFileSync(0, "utf8")) as Partial<SettingsSnapshot>;
if (Object.keys(parsed).some((key) => key !== "exists" && key !== "rawBase64")) {
throw new Error("invalid settings snapshot");
}
restoreSettingsSnapshot(cfg, parsed as SettingsSnapshot);
} else {
if (args.length !== 6) throw new Error("only provider, model, and thinking may be configured");
const provider = value(args, "--provider");
const model = value(args, "--model");
const thinking = value(args, "--thinking");
if (!choice.test(provider) || !choice.test(model)) throw new Error("invalid provider or model");
if (!["low", "medium", "high"].includes(thinking)) throw new Error("invalid thinking level");
const next: Settings = { ...loadSettings(cfg), provider, model, thinking };
saveSettings(cfg, next);
}
} catch (error) {
process.stderr.write(`settings-cli: ${error instanceof Error ? error.message : "invalid configuration"}\n`);
process.exitCode = 2;
+44
View File
@@ -22,6 +22,11 @@ export interface SettingsDurability {
syncDirectory(directory: string): void;
}
export interface SettingsSnapshot {
exists: boolean;
rawBase64: string;
}
/**
* 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.
@@ -39,6 +44,45 @@ export function loadSettings(cfg: AppConfig): Settings {
}
}
/** Capture exact file existence and bytes so host-side configuration can compensate losslessly. */
export function captureSettingsSnapshot(cfg: AppConfig): SettingsSnapshot {
try {
return { exists: true, rawBase64: readFileSync(cfg.settingsFile).toString("base64") };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return { exists: false, rawBase64: "" };
}
throw error;
}
}
/** Restore a previously captured settings file exactly, including the clean absent state. */
export function restoreSettingsSnapshot(
cfg: AppConfig,
snapshot: SettingsSnapshot,
durability: SettingsDurability = defaultDurability,
): void {
if (typeof snapshot.exists !== "boolean" || typeof snapshot.rawBase64 !== "string") {
throw new Error("invalid settings snapshot");
}
const raw = Buffer.from(snapshot.rawBase64, "base64");
if (raw.toString("base64") !== snapshot.rawBase64 || (!snapshot.exists && raw.length !== 0)) {
throw new Error("invalid settings snapshot");
}
const directory = dirname(cfg.settingsFile);
mkdirSync(directory, { recursive: true });
if (snapshot.exists) {
replaceSettingsFile(cfg.settingsFile, raw);
} else {
try {
unlinkSync(cfg.settingsFile);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
durability.syncDirectory(directory);
}
/** Persist settings (pretty JSON). Creates the parent directory if needed. */
export function saveSettings(
cfg: AppConfig,