fix: recover from read-only database files at startup (#12508)

This commit is contained in:
Johnny Eric Amancio
2026-07-24 12:51:05 +02:00
committed by GitHub
parent 1a3c719175
commit 0fe46ecb8d
5 changed files with 200 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix a fatal startup crash ("attempt to write a readonly database") when the local database or its WAL sidecar files lost write permission. Kilo now repairs the permissions automatically when it safely can, and otherwise reports the exact file to fix instead of an opaque error.
+2
View File
@@ -7,6 +7,7 @@ import { Global } from "../global"
import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { existsSync } from "fs" // kilocode_change
import { DbPreflight } from "../kilocode/db-preflight" // kilocode_change
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { LayerNode } from "../effect/layer-node"
@@ -38,6 +39,7 @@ export const layer = Layer.effect(
)
export function layerFromPath(filename: string) {
DbPreflight.assertWritable(filename) // kilocode_change - actionable error (and self-heal for kilo-owned files) instead of an opaque wal_checkpoint crash on read-only db files
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
}
@@ -0,0 +1,68 @@
export * as DbPreflight from "./db-preflight"
import { accessSync, chmodSync, constants, statSync } from "fs"
import path from "path"
import { Global } from "../global"
import { Log } from "../util/log"
const log = Log.create({ service: "db-preflight" })
function writable(target: string) {
try {
accessSync(target, constants.W_OK)
return true
} catch {
return false
}
}
function exists(target: string) {
try {
statSync(target)
return true
} catch {
return false
}
}
// Startup runs `PRAGMA wal_checkpoint(PASSIVE)`, which must write the database and its
// WAL sidecars. A stray read-only file otherwise kills the process deep inside Effect
// with an opaque "attempt to write a readonly database".
export function assertWritable(filename: string, trusted: string = Global.Path.data) {
if (!filename || filename === ":memory:" || filename.startsWith("file:")) return
const dir = path.dirname(filename)
const owned = path.resolve(dir) === path.resolve(trusted)
let missing = false
for (const file of [filename, `${filename}-wal`, `${filename}-shm`]) {
if (!exists(file)) {
missing = true
continue
}
if (writable(file)) continue
let cause: unknown
if (owned) {
// chmod only succeeds for files the current user owns, which is exactly the safe repair scope
try {
chmodSync(file, statSync(file).mode | 0o600)
} catch (err) {
cause = err
}
if (writable(file)) {
// visible trail: if files keep losing their write bit, something outside kilo is doing it
log.warn("repaired read-only database file", { file })
continue
}
}
throw new Error(
`Database file is not writable: ${file}. Fix its permissions (chmod u+w "${file}") or point KILO_DB at a writable location.`,
cause === undefined ? undefined : { cause },
)
}
if (missing && !writable(dir)) {
if (!exists(dir))
throw new Error(`Database directory does not exist: ${dir}. Create it or point KILO_DB at an existing location.`)
throw new Error(
`Database directory is not writable: ${dir}. SQLite must create WAL files next to the database. Fix its permissions or point KILO_DB at a writable location.`,
)
}
}
@@ -0,0 +1,123 @@
import { describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { accessSync, chmodSync, constants } from "fs"
import path from "path"
import { DbPreflight } from "@opencode-ai/core/kilocode/db-preflight"
import { Database as KiloDatabase } from "@opencode-ai/core/database/database"
import { tmpdir } from "../fixture/tmpdir"
const writable = (file: string) => {
try {
accessSync(file, constants.W_OK)
return true
} catch {
return false
}
}
// Windows: chmod is a no-op, so non-writable files cannot be staged; root ignores permission bits
const skip = process.platform === "win32" || process.getuid?.() === 0
function createWalDb(file: string) {
const db = new Database(file)
db.run("PRAGMA journal_mode = WAL")
db.run("CREATE TABLE t (x)")
db.run("INSERT INTO t VALUES (1)")
db.close()
}
// leaves committed-but-uncheckpointed frames in the WAL by SIGKILLing the writer,
// reproducing the state a crashed kilo process leaves behind
async function createWalDbWithPendingFrames(file: string) {
const script = [
`const { Database } = require("bun:sqlite")`,
`const db = new Database(${JSON.stringify(file)})`,
`db.run("PRAGMA journal_mode = WAL")`,
`db.run("PRAGMA wal_autocheckpoint = 0")`,
`db.run("CREATE TABLE t (x)")`,
`db.run("INSERT INTO t VALUES (1)")`,
`console.log("ready")`,
`setInterval(() => {}, 1000)`,
].join("\n")
const child = Bun.spawn([process.execPath, "-e", script], { stdout: "pipe" })
const reader = child.stdout.getReader()
await reader.read()
child.kill("SIGKILL")
await child.exited
}
describe("DbPreflight", () => {
test("skips in-memory databases", () => {
expect(() => DbPreflight.assertWritable(":memory:")).not.toThrow()
})
test("accepts a writable database", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
createWalDb(file)
expect(() => DbPreflight.assertWritable(file)).not.toThrow()
})
test("names the offending file for a read-only sidecar outside the kilo data dir", async () => {
if (skip) return
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
// a clean close deletes the sidecars on some platforms; a killed writer always leaves them
await createWalDbWithPendingFrames(file)
chmodSync(`${file}-wal`, 0o444)
expect(() => DbPreflight.assertWritable(file)).toThrow(`Database file is not writable: ${file}-wal`)
chmodSync(`${file}-wal`, 0o644)
})
test("repairs read-only files inside the trusted dir", async () => {
if (skip) return
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
await createWalDbWithPendingFrames(file)
chmodSync(file, 0o444)
chmodSync(`${file}-wal`, 0o444)
expect(() => DbPreflight.assertWritable(file, tmp.path)).not.toThrow()
expect(writable(file)).toBe(true)
expect(writable(`${file}-wal`)).toBe(true)
})
test("reports a missing directory as missing, not as read-only", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "absent")
const file = path.join(dir, "kilo.db")
expect(() => DbPreflight.assertWritable(file)).toThrow(`Database directory does not exist: ${dir}`)
})
test("rejects a read-only directory when WAL files must be created", async () => {
if (skip) return
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locked")
const file = path.join(dir, "kilo.db")
await Bun.write(path.join(dir, ".keep"), "")
chmodSync(dir, 0o555)
try {
expect(() => DbPreflight.assertWritable(file)).toThrow(`Database directory is not writable: ${dir}`)
} finally {
chmodSync(dir, 0o755)
}
})
test("pending WAL frames with a read-only sidecar fail with the actionable error, and repair recovers the data", async () => {
if (skip) return
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
await createWalDbWithPendingFrames(file)
chmodSync(`${file}-wal`, 0o444)
// without repair (untrusted dir) the wiring in layerFromPath surfaces the clear error
expect(() => KiloDatabase.layerFromPath(file)).toThrow(`Database file is not writable: ${file}-wal`)
// with repair the startup pragma sequence succeeds and the committed row survives
DbPreflight.assertWritable(file, tmp.path)
const db = new Database(file, { readwrite: true, create: true })
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA wal_checkpoint(PASSIVE)")
expect(db.query("SELECT x FROM t").all()).toEqual([{ x: 1 }])
db.close()
})
})
+2
View File
@@ -5,6 +5,7 @@ export * from "drizzle-orm"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { LocalContext } from "@/util/local-context"
import { Global } from "@opencode-ai/core/global"
import { DbPreflight } from "@opencode-ai/core/kilocode/db-preflight" // kilocode_change
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import path from "path"
@@ -102,6 +103,7 @@ export const Client = Object.assign(
const dbPath = getPath(flags)
log.info("opening database", { path: dbPath })
DbPreflight.assertWritable(dbPath) // kilocode_change - actionable error (and self-heal for kilo-owned files) instead of an opaque wal_checkpoint crash on read-only db files
const db = init(dbPath)
db.run("PRAGMA journal_mode = WAL")