Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
7064cca334 fix(db): degrade getSettings() to defaults instead of crashing Home on corrupted SQLite (#14060)
Root cause: getSettings() (src/lib/db/settings.ts) read the key_value
table without try/catch, and src/app/(dashboard)/home/page.tsx awaited
it unguarded during server-side rendering. When the key_value table's
page is corrupted (SQLITE_CORRUPT / 'database disk image is
malformed'), that unguarded read threw synchronously and crashed the
Home Server Component render, producing the generic Next.js
Internal Server Error the reporter saw.

Fix: getSettings() now catches the read error, warns via console.warn
(mirroring the pattern already used by optimizationSettings.ts,
proxyLogger.ts and memory/index.ts), and falls through to the existing
in-memory defaults. The Home page also wraps getSettings() in a
.catch() as defense-in-depth against a future unguarded read anywhere
in its dependency chain.

Regression test: tests/unit/settings-14060-getsettings-corrupt-db.test.ts
corrupts only the on-disk page backing key_value on an otherwise-valid
storage.sqlite, then asserts getSettings() degrades to defaults
instead of throwing.
2026-09-21 21:44:21 -03:00
4 changed files with 83 additions and 2 deletions

View File

@@ -0,0 +1 @@
- fix(db): degrade getSettings() to defaults instead of crashing the Home dashboard when the key_value table is corrupted (#14060)

View File

@@ -13,7 +13,17 @@ export const dynamic = "force-dynamic";
export default async function HomePage() {
// Even if getSettings() rejects, getMachineId() runs concurrently, which is acceptable
// as both paths fail-fast on error and avoids the waterfall penalty.
const [settings, machineId] = await Promise.all([getSettings(), getMachineId()]);
// Defense-in-depth (#14060): getSettings() already degrades to defaults on a corrupted
// key_value table, but a future unguarded read anywhere in its dependency chain should
// not be able to crash this Server Component render again.
const [settings, machineId] = await Promise.all([
getSettings().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[Home] Failed to load settings; using defaults: ${message}`);
return { setupComplete: false };
}),
getMachineId(),
]);
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
return (
<>

View File

@@ -151,7 +151,13 @@ function applySessionAffinityLegacyFallback(settings: Record<string, unknown>):
export async function getSettings() {
const db = getDbInstance();
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all();
let rows: unknown[] = [];
try {
rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[DB] Failed to read settings; using defaults: ${message}`);
}
const settings: Record<string, unknown> = {
cloudEnabled: true,
tailscaleEnabled: false,

View File

@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-issue-14060-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("getSettings() degrades to defaults (does not throw) when only the key_value table's page is corrupted", async () => {
const db = core.getDbInstance();
const dbPath = (db as { name?: string }).name;
assert.ok(typeof dbPath === "string" && dbPath.length > 0, "expected a file-backed db path");
db.prepare(
"INSERT INTO key_value (namespace, key, value) VALUES ('settings', 'requireLogin', 'true')"
).run();
const pageSize = (db.pragma("page_size") as Array<{ page_size: number }>)[0].page_size;
const rootPageRow = db
.prepare("SELECT rootpage FROM sqlite_master WHERE type = 'table' AND name = 'key_value'")
.get() as { rootpage: number } | undefined;
assert.ok(rootPageRow?.rootpage, "expected to find key_value's rootpage in sqlite_master");
const rootPage = rootPageRow!.rootpage;
core.resetDbInstance();
const buf = fs.readFileSync(dbPath as string);
const pageStart = (rootPage - 1) * pageSize;
for (let i = pageStart; i < Math.min(buf.length, pageStart + pageSize); i++) {
buf[i] = 0xff;
}
fs.writeFileSync(dbPath as string, buf);
const reopened = core.getDbInstance();
const bootHealthy = !!reopened
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'provider_connections'"
)
.get();
assert.equal(bootHealthy, true, "expected the boot probe's own tables to remain readable");
core.resetDbInstance();
const settings = await settingsDb.getSettings();
assert.equal(
typeof settings,
"object",
"expected getSettings() to degrade to a defaults object instead of throwing"
);
assert.equal(
settings.requireLogin,
true,
"expected the built-in default for requireLogin (row could not be read from the corrupted table)"
);
});