fix: harden Pi lifecycle recovery

This commit is contained in:
2026-08-04 20:28:09 +02:00
parent 5b3ce93e31
commit 5ba2821a1b
24 changed files with 1764 additions and 384 deletions
+2
View File
@@ -17,6 +17,7 @@ test("loadConfig accepts container listening and runtime paths", () => {
thtBin: "/opt/venv/bin/tht",
piBin: "/usr/local/bin/pi",
settingsFile: "/data/settings/settings.json",
maintenanceFile: "/data/settings/maintenance.json",
dataRoot: "/data",
});
});
@@ -29,6 +30,7 @@ test("loadConfig keeps local development defaults", () => {
thtBin: "tht",
piBin: "pi",
settingsFile: "data/settings.json",
maintenanceFile: "data/maintenance.json",
workspaceRegistry: {
root: "/data/workspace-registry",
branch: "main",
+14
View File
@@ -0,0 +1,14 @@
[
{
"id": "open-session",
"status": "open",
"archived": false,
"active": false
},
{
"id": "finished-session",
"status": "finalized",
"archived": false,
"active": false
}
]
+28
View File
@@ -1,4 +1,7 @@
import { test, expect } from "vitest";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MaintenanceBarrier } from "../src/runtime/maintenance-gate.js";
test("activation waits for an in-flight admission lease and rejects later admissions", async () => {
@@ -17,3 +20,28 @@ test("activation waits for an in-flight admission lease and rejects later admiss
gate.deactivate();
expect(gate.acquire()).toBeTypeOf("function");
});
test("durable activation survives recreation and deactivation removes the marker first", async () => {
const directory = mkdtempSync(join(tmpdir(), "tht-maintenance-gate-"));
const marker = join(directory, "maintenance.json");
try {
const first = new MaintenanceBarrier(marker);
const release = first.acquire();
expect(release).toBeTypeOf("function");
const activation = first.activate();
expect(existsSync(marker)).toBe(true);
expect(first.acquire()).toBeUndefined();
expect(first.status()).toEqual({ active: true, admissions: 1 });
release?.();
await activation;
expect(first.status()).toEqual({ active: true, admissions: 0 });
const recreated = new MaintenanceBarrier(marker);
expect(recreated.status()).toEqual({ active: true, admissions: 0 });
recreated.deactivate();
expect(existsSync(marker)).toBe(false);
expect(recreated.status()).toEqual({ active: false, admissions: 0 });
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+62 -3
View File
@@ -2,7 +2,7 @@ import { test, expect, vi } from "vitest";
import { spawn as nodeSpawn } from "node:child_process";
import path from "node:path";
import os from "node:os";
import { chmodSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { chmodSync, readFileSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { buildApp as buildRealApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
@@ -97,7 +97,7 @@ test("maintenance rejects new and resumed session admission without interrupting
expect(resume.json()).toEqual(create.json());
});
test("a server-profile marker does not weaken the in-process maintenance gate", async () => {
test("a durable maintenance marker initializes admission closed after backend recreation", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-"));
try {
const marker = path.join(dir, "maintenance.json");
@@ -108,12 +108,71 @@ test("a server-profile marker does not weaken the in-process maintenance gate",
const response = await app.inject({
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { question: "q" },
});
expect(response.statusCode).not.toBe(503);
expect(response.statusCode).toBe(503);
expect(response.json()).toMatchObject({ code: "maintenance" });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test.each(["none", "upstream"] as const)(
"maintenance control is loopback-only and independent of %s authentication",
async (authMode) => {
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-control-"));
const marker = path.join(dir, "maintenance.json");
try {
const app = buildApp(loadConfig({
AUTH_MODE: authMode,
THT_HARNESS_DIR: "../harness",
THT_MAINTENANCE_FILE: marker,
}), { thtRunner: {} as any });
const activated = await app.inject({ method: "POST", url: "/internal/maintenance/activate" });
expect(activated.statusCode).toBe(200);
expect(activated.json()).toEqual({ active: true, admissions: 0 });
const spoofedProxy = await app.inject({
method: "POST",
url: "/internal/maintenance/deactivate",
remoteAddress: "172.30.0.9",
headers: {
"x-thoth-principal-subject": "thothctl-maintenance",
"x-thoth-is-admin": "1",
},
});
expect(spoofedProxy.statusCode).toBe(403);
const status = await app.inject({ method: "GET", url: "/internal/maintenance/status" });
expect(status.json()).toEqual({ active: true, admissions: 0 });
const deactivated = await app.inject({ method: "POST", url: "/internal/maintenance/deactivate" });
expect(deactivated.json()).toEqual({ 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"),
"utf8",
));
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
withPrincipal: () => ({ sessionList: async () => fixture.map(({ active: _active, ...row }: any) => row) }),
} as any,
mgr: { get: () => undefined } as any,
workspaceRegistry: defaultWorkspaceRegistry as any,
});
const response = await app.inject({
method: "GET",
url: "/sessions?scope=all",
headers: { ...aliceHeaders, "x-thoth-is-admin": "1" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(fixture);
});
test("session routes conceal foreign or missing sessions and deny SSE before it subscribes", async () => {
let subscribed = false;
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
+26 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { closeSync, fsyncSync, mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadSettings, saveSettings } from "../src/settings/settings-store.js";
@@ -45,3 +45,28 @@ test("loadConfig sets settingsFile from SETTINGS_FILE, default data/settings.jso
expect(loadConfig({}).settingsFile).toBe("data/settings.json");
expect(loadConfig({ SETTINGS_FILE: "/x/y.json" }).settingsFile).toBe("/x/y.json");
});
test("saveSettings restores the previous file when post-rename directory durability fails", () => {
const dir = mkdtempSync(join(tmpdir(), "tht-set-transaction-"));
try {
const cfg = cfgWith(join(dir, "settings.json"));
saveSettings(cfg, { provider: "old", model: "old-model", thinking: "low" });
let syncs = 0;
expect(() => saveSettings(
cfg,
{ provider: "new", model: "new-model", thinking: "high" },
{
syncDirectory(directory: string) {
syncs += 1;
if (syncs === 1) throw new Error("injected directory fsync failure");
const fd = openSync(directory, "r");
try { fsyncSync(fd); } finally { closeSync(fd); }
},
},
)).toThrow(/directory fsync failure/);
expect(loadSettings(cfg)).toEqual({ provider: "old", model: "old-model", thinking: "low" });
expect(syncs).toBeGreaterThanOrEqual(2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});