mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
fix: recover from read-only database files at startup (#12508)
This commit is contained in:
committed by
GitHub
parent
1a3c719175
commit
0fe46ecb8d
@@ -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.
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user