From bcefb277b2180fcea3c986cff2569272f5dc67df Mon Sep 17 00:00:00 2001 From: Anders Cedronius Date: Tue, 24 Feb 2026 23:57:17 +0100 Subject: [PATCH 1/2] fix(core): ensure sessions persist on exit by closing database and checkpointing WAL --- packages/opencode/src/cli/cmd/tui/worker.ts | 2 ++ packages/opencode/src/index.ts | 16 ++++++++++++---- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/storage/db.ts | 3 +++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e63f10ba80c9..53ed782b9691 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -9,6 +9,7 @@ import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import type { BunWebSocketData } from "hono/bun" +import { Database } from "@/storage/db" import { Flag } from "@/flag/flag" await Log.init({ @@ -139,6 +140,7 @@ export const rpc = { if (eventStream.abort) eventStream.abort.abort() await Instance.disposeAll() if (server) server.stop(true) + Database.close() }, } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 4fd5f0e67b3d..610467dd2b14 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -46,10 +46,15 @@ process.on("uncaughtException", (e) => { }) }) -// Ensure the process exits on terminal hangup (eg. closing the terminal tab). -// Without this, long-running commands like `serve` block on a never-resolving -// promise and survive as orphaned processes. -process.on("SIGHUP", () => process.exit()) +// Ensure database is properly closed on signal-based termination. +// Also covers terminal hangup (SIGHUP) to prevent orphaned processes +// from long-running commands like `serve`. +for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { + process.on(signal, () => { + Database.close() + process.exit(128 + (signal === "SIGTERM" ? 15 : signal === "SIGINT" ? 2 : 1)) + }) +} let cli = yargs(hideBin(process.argv)) .parserConfiguration({ "populate--": true }) @@ -208,6 +213,9 @@ try { } process.exitCode = 1 } finally { + // Ensure all pending WAL writes are flushed to the main database file + // before exiting. Without this, sessions can be lost on exit. + Database.close() // Some subprocesses don't react properly to SIGTERM and similar signals. // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4f77920cc987..3ed1aa8edb00 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -713,7 +713,7 @@ export namespace SessionPrompt { } continue } - SessionCompaction.prune({ sessionID }) + await SessionCompaction.prune({ sessionID }) for await (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user") continue const queued = state()[sessionID]?.callbacks ?? [] diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index f29aac18d163..a593581fccbe 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -103,6 +103,9 @@ export namespace Database { export function close() { const sqlite = state.sqlite if (!sqlite) return + try { + sqlite.run("PRAGMA wal_checkpoint(TRUNCATE)") + } catch {} sqlite.close() state.sqlite = undefined Client.reset() From c1beeea2c6029d807048aa25528c6847f05aeddc Mon Sep 17 00:00:00 2001 From: Anders Cedronius Date: Wed, 25 Feb 2026 08:23:44 +0100 Subject: [PATCH 2/2] test: add WAL checkpoint and Database.close tests --- .../opencode/test/storage/db-close.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 packages/opencode/test/storage/db-close.test.ts diff --git a/packages/opencode/test/storage/db-close.test.ts b/packages/opencode/test/storage/db-close.test.ts new file mode 100644 index 000000000000..4d480c4409ce --- /dev/null +++ b/packages/opencode/test/storage/db-close.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test" +import { Database as BunDatabase } from "bun:sqlite" +import path from "path" +import os from "os" +import fs from "fs/promises" + +describe("database close checkpoint", () => { + test("wal_checkpoint(TRUNCATE) flushes WAL to main db file", async () => { + const dir = path.join(os.tmpdir(), "opencode-wal-test-" + Math.random().toString(36).slice(2)) + await fs.mkdir(dir, { recursive: true }) + const dbPath = path.join(dir, "test.db") + + // Create a database with WAL mode matching production settings + const sqlite = new BunDatabase(dbPath, { create: true }) + sqlite.run("PRAGMA journal_mode = WAL") + sqlite.run("PRAGMA synchronous = NORMAL") + sqlite.run("PRAGMA foreign_keys = ON") + sqlite.run("CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT)") + + // Insert data + sqlite.run("INSERT INTO sessions (id, title) VALUES ('s1', 'test session')") + sqlite.run("INSERT INTO sessions (id, title) VALUES ('s2', 'another session')") + + // WAL file should exist after writes + const walExists = await fs + .stat(dbPath + "-wal") + .then(() => true) + .catch(() => false) + expect(walExists).toBe(true) + + // Checkpoint and close (matching our fix in db.ts) + sqlite.run("PRAGMA wal_checkpoint(TRUNCATE)") + sqlite.close() + + // After TRUNCATE checkpoint, WAL file should be empty or gone + const walSize = await fs + .stat(dbPath + "-wal") + .then((s) => s.size) + .catch(() => 0) + expect(walSize).toBe(0) + + // Reopen and verify data survived + const sqlite2 = new BunDatabase(dbPath) + const rows = sqlite2.query("SELECT id, title FROM sessions ORDER BY id").all() as { + id: string + title: string + }[] + expect(rows).toHaveLength(2) + expect(rows[0].id).toBe("s1") + expect(rows[0].title).toBe("test session") + expect(rows[1].id).toBe("s2") + expect(rows[1].title).toBe("another session") + sqlite2.close() + + await fs.rm(dir, { recursive: true, force: true }) + }) + + test("data survives close without checkpoint when WAL is intact", async () => { + const dir = path.join(os.tmpdir(), "opencode-wal-test-" + Math.random().toString(36).slice(2)) + await fs.mkdir(dir, { recursive: true }) + const dbPath = path.join(dir, "test.db") + + const sqlite = new BunDatabase(dbPath, { create: true }) + sqlite.run("PRAGMA journal_mode = WAL") + sqlite.run("PRAGMA synchronous = NORMAL") + sqlite.run("CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT)") + sqlite.run("INSERT INTO sessions (id, title) VALUES ('s1', 'test')") + + // Close without explicit checkpoint — WAL recovery should still work + sqlite.close() + + const sqlite2 = new BunDatabase(dbPath) + const rows = sqlite2.query("SELECT * FROM sessions").all() as { id: string }[] + expect(rows).toHaveLength(1) + sqlite2.close() + + await fs.rm(dir, { recursive: true, force: true }) + }) + + test("Database.close checkpoints WAL before closing", async () => { + // This tests the actual Database.close() function from our codebase + // The preload.ts sets up isolated XDG dirs so this is safe + const { Database } = await import("../../src/storage/db") + + // Access the client to ensure DB is initialized + const db = Database.Client() + + // Insert a project row first (session.project_id has FK to project.id) + const now = Date.now() + db.$client.run( + `INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes) + VALUES ('test-proj', '/tmp', ${now}, ${now}, '[]')`, + ) + + // Insert a session row to verify checkpoint works + db.$client.run( + `INSERT OR IGNORE INTO session (id, project_id, directory, title, version, slug, time_created, time_updated) + VALUES ('test-wal-check', 'test-proj', '/tmp', 'WAL Test', '0.0.0', 'wal-test', ${now}, ${now})`, + ) + + // Verify it's readable before close + const before = db.$client.query("SELECT id FROM session WHERE id = 'test-wal-check'").get() as { id: string } | null + expect(before).not.toBeNull() + expect(before!.id).toBe("test-wal-check") + + // Close triggers checkpoint + Database.close() + + // Reopen via Client() — the lazy initializer should create a fresh connection + const db2 = Database.Client() + const after = db2.$client.query("SELECT id FROM session WHERE id = 'test-wal-check'").get() as { id: string } | null + expect(after).not.toBeNull() + expect(after!.id).toBe("test-wal-check") + + // Clean up (session cascades from project FK) + db2.$client.run("DELETE FROM session WHERE id = 'test-wal-check'") + db2.$client.run("DELETE FROM project WHERE id = 'test-proj'") + }) +})