fix: finalize durable Pi lifecycle
This commit is contained in:
+18
-4
@@ -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();
|
||||
|
||||
@@ -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); }
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { MaintenanceBarrier } from "../src/runtime/maintenance-gate.js";
|
||||
@@ -45,3 +45,58 @@ test("durable activation survives recreation and deactivation removes the marker
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("activation reconciles active state when directory fsync fails after marker rename", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "tht-maintenance-activate-fsync-"));
|
||||
const marker = join(directory, "maintenance.json");
|
||||
try {
|
||||
const gate = new MaintenanceBarrier(marker, {
|
||||
syncDirectory() { throw new Error("injected post-rename fsync failure"); },
|
||||
});
|
||||
await expect(gate.activate()).rejects.toThrow(/post-rename fsync failure/);
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
expect(gate.status()).toEqual({ active: true, admissions: 0 });
|
||||
expect(gate.acquire()).toBeUndefined();
|
||||
|
||||
const recovered = new MaintenanceBarrier(marker);
|
||||
expect(recovered.status()).toEqual({ active: true, admissions: 0 });
|
||||
expect(recovered.acquire()).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("deactivation reconciles inactive state when directory fsync fails after marker removal", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "tht-maintenance-deactivate-fsync-"));
|
||||
const marker = join(directory, "maintenance.json");
|
||||
let failSync = false;
|
||||
try {
|
||||
const gate = new MaintenanceBarrier(marker, {
|
||||
syncDirectory() {
|
||||
if (failSync) throw new Error("injected post-remove fsync failure");
|
||||
},
|
||||
});
|
||||
await gate.activate();
|
||||
failSync = true;
|
||||
expect(() => gate.deactivate()).toThrow(/post-remove fsync failure/);
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
expect(gate.status()).toEqual({ active: false, admissions: 0 });
|
||||
expect(gate.acquire()).toBeTypeOf("function");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status and admission recover from a persistent marker even when memory started inactive", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "tht-maintenance-reconcile-"));
|
||||
const marker = join(directory, "maintenance.json");
|
||||
try {
|
||||
const gate = new MaintenanceBarrier(marker);
|
||||
expect(gate.status().active).toBe(false);
|
||||
writeFileSync(marker, '{"version":1,"active":true}\n', { mode: 0o600 });
|
||||
expect(gate.status()).toEqual({ active: true, admissions: 0 });
|
||||
expect(gate.acquire()).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -152,6 +152,38 @@ test.each(["none", "upstream"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
test("maintenance endpoints report marker-derived state after post-rename and post-remove fsync failures", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-endpoint-fsync-"));
|
||||
const marker = path.join(dir, "maintenance.json");
|
||||
let failSync = true;
|
||||
try {
|
||||
const maintenanceBarrier = new MaintenanceBarrier(marker, {
|
||||
syncDirectory() {
|
||||
if (failSync) throw new Error("injected maintenance fsync failure");
|
||||
},
|
||||
});
|
||||
const app = buildApp(loadConfig({
|
||||
AUTH_MODE: "none",
|
||||
THT_HARNESS_DIR: "../harness",
|
||||
THT_MAINTENANCE_FILE: marker,
|
||||
}), { thtRunner: {} as any, maintenanceBarrier });
|
||||
|
||||
const activated = await app.inject({ method: "POST", url: "/internal/maintenance/activate" });
|
||||
expect(activated.statusCode).toBe(500);
|
||||
expect(activated.json()).toMatchObject({ active: true, admissions: 0 });
|
||||
|
||||
failSync = false;
|
||||
expect((await app.inject({ method: "GET", url: "/internal/maintenance/status" })).json())
|
||||
.toEqual({ active: true, admissions: 0 });
|
||||
failSync = true;
|
||||
const deactivated = await app.inject({ method: "POST", url: "/internal/maintenance/deactivate" });
|
||||
expect(deactivated.statusCode).toBe(500);
|
||||
expect(deactivated.json()).toMatchObject({ active: false, admissions: 0 });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("admin all-sessions response matches the authenticated lifecycle wire fixture", async () => {
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
path.join(import.meta.dirname, "fixtures", "sessions-scope-all.json"),
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { closeSync, fsyncSync, mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { closeSync, existsSync, fsyncSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadSettings, saveSettings } from "../src/settings/settings-store.js";
|
||||
import {
|
||||
captureSettingsSnapshot,
|
||||
loadSettings,
|
||||
restoreSettingsSnapshot,
|
||||
saveSettings,
|
||||
} from "../src/settings/settings-store.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
function cfgWith(file: string) {
|
||||
@@ -70,3 +75,25 @@ test("saveSettings restores the previous file when post-rename directory durabil
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("settings snapshots restore exact absent and empty-file states", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tht-settings-snapshot-"));
|
||||
try {
|
||||
const file = join(dir, "settings.json");
|
||||
const cfg = cfgWith(file);
|
||||
const absent = captureSettingsSnapshot(cfg);
|
||||
expect(absent).toEqual({ exists: false, rawBase64: "" });
|
||||
saveSettings(cfg, { provider: "new", model: "model", thinking: "high" });
|
||||
restoreSettingsSnapshot(cfg, absent);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
|
||||
writeFileSync(file, Buffer.alloc(0), { mode: 0o600 });
|
||||
const empty = captureSettingsSnapshot(cfg);
|
||||
expect(empty.exists).toBe(true);
|
||||
saveSettings(cfg, { provider: "new", model: "model", thinking: "high" });
|
||||
restoreSettingsSnapshot(cfg, empty);
|
||||
expect(readFileSync(file)).toEqual(Buffer.alloc(0));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,6 +66,8 @@ test("CLI accepts an explicit valid ID when a legacy filename contains dots", as
|
||||
test("declares a durable isolated registry volume and only read-only Git credential mounts", () => {
|
||||
const compose = readFileSync(new URL("../../compose.yaml", import.meta.url), "utf8");
|
||||
const development = readFileSync(new URL("../../docker-compose.dev.yml", import.meta.url), "utf8");
|
||||
const gitHttps = readFileSync(new URL("../../deploy/compose.git-https.yaml", import.meta.url), "utf8");
|
||||
const gitSsh = readFileSync(new URL("../../deploy/compose.git-ssh.yaml", import.meta.url), "utf8");
|
||||
const dockerfile = readFileSync(new URL("../../docker/core.Dockerfile", import.meta.url), "utf8");
|
||||
const smoke = readFileSync(new URL("../../scripts/workspace-registry-smoke.sh", import.meta.url), "utf8");
|
||||
|
||||
@@ -73,12 +75,14 @@ test("declares a durable isolated registry volume and only read-only Git credent
|
||||
expect(source).toContain("THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry");
|
||||
expect(source).toContain("THT_WORKSPACE_GIT_REMOTE: ${THT_WORKSPACE_GIT_REMOTE:?set THT_WORKSPACE_GIT_REMOTE}");
|
||||
expect(source).toContain("workspace-registry:/data/workspace-registry");
|
||||
expect(source).toMatch(/workspace-registry-git-credentials:ro/);
|
||||
expect(source).toMatch(/workspace-registry-git-ca:ro/);
|
||||
expect(source).toMatch(/workspace-registry-git-ssh-key:ro/);
|
||||
expect(source).toMatch(/workspace-registry-git-known-hosts:ro/);
|
||||
}
|
||||
expect(dockerfile).toMatch(/mkdir -p \/data\/workspace-registry && chown -R thoth:thoth \/data\/workspace-registry/);
|
||||
expect(compose).not.toMatch(/workspace-registry-git-(?:credentials|ca|ssh-key|known-hosts):ro/);
|
||||
expect(gitHttps).toMatch(/workspace-registry-git-credentials:ro/);
|
||||
expect(gitHttps).toMatch(/workspace-registry-git-ca:ro/);
|
||||
expect(gitSsh).toMatch(/workspace-registry-git-ssh-key:ro/);
|
||||
expect(gitSsh).toMatch(/workspace-registry-git-known-hosts:ro/);
|
||||
expect(dockerfile).toMatch(/mkdir -p[^\n]*\/data\/workspace-registry/);
|
||||
expect(dockerfile).toMatch(/chown -R thoth:thoth \/home\/thoth\/\.pi \/data/);
|
||||
expect(smoke).toContain('core_remote="/fixtures/offline.git"');
|
||||
expect(smoke).toContain('"degraded":true');
|
||||
expect(smoke).toContain('core_remote="/fixtures/remote.git"');
|
||||
|
||||
Reference in New Issue
Block a user