Repository navigation
Stale -tshm after power-off (or mixed sqlite3 access) bricks the whole memory store — recovery recipe + self-heal tool #435
Description
Summary
opencode-mem's Turso storage can become permanently unreadable, taking the entire memory system with it — every memory tool fails, auto-capture stops, the web UI won't start — even though the main SQLite files are perfectly intact.
There are two distinct failure classes, both rooted in the same thing: Turso's multiprocess WAL index (-tshm) getting out of sync with the -wal file. Neither loses data, and neither currently has any recovery path in the plugin.
| Trigger | Error | |
|---|---|---|
| A. Stale sidecar | OS crash / power-off | I/O error: short read on WAL frame at offset N: expected 4096 bytes, got 0 |
| B. Mixed runtime | stock sqlite3 CLI opened a live DB |
Corrupt database: Invalid page type: 0 |
Both are upstream issues in tursodatabase/turso (A: #8536; B is the same -tshm vs -shm mismatch with a different symptom), but the plugin is what turns them into a total outage — ensureTursoReady() gates memory tools, chat capture, and web-server startup behind it.
Environment
- opencode-mem 2.33.0
@tursodatabase/database0.8.3- OpenCode 1.19.3
- macOS 14 arm64, Node 26.7.0
Symptom
Class A, on every session start:
[2026-10-24T01:57:04.815+08:00] Turso ready gate failed: {"error":"Error: failed to open database .../project_94ddb9b4d4051219_shard_0.db: I/O error: short read on WAL frame at offset 366712: expected 4096 bytes, got 0"}
[2026-10-24T01:57:04.816+08:00] Plugin memory warmup failed: {"error":"..."}
Auto-capture is completely dead (chat.message: ERROR on every message), memory tools fail, the web UI doesn't start. The main DB files are fine — SELECT COUNT(*) per shard shows all records intact.
In our case a single power-off on 2026-10-18 20:25 left 9 databases in class A at once.
Class B looks like this, and is arguably worse because it arrives with no crash at all:
[2026-10-25T11:28:51] chat.message: ERROR: {"error":"Error: failed to open database .../user-prompts.db: Corrupt database: Invalid page type: 0"}
with the memory tools failing outright:
Memory system failed to initialize: failed to open database .../project_98cb27f472f8b28d_shard_0.db: Corrupt database: Invalid page type: 0
Root cause
Turso keeps its multiprocess WAL index in a -tshm sidecar (magic TSHMWAL, 86016 bytes); stock sqlite3 uses -shm. The two indexes can't see each other.
- Class A: after a crash,
-walis left at 0 bytes while-tshmstill claims frames exist → Turso tries to recover frames that aren't there →short read on WAL frame. - Class B: the stock
sqlite3CLI opens a live DB and recreates-shm, which breaks Turso's-tshmcorrespondence → Turso reads frames through the stale index and gets garbage →Invalid page type: 0. Anyone diagnosing withsqlite3or a DB browser can trigger this on a healthy store — and it is silent until the next session start.
Upstream: tursodatabase/turso#8536 (same error class as A; also #6454, #8593).
Reproduction
Class A (matches upstream #8536's synthetic recipe):
import { connect } from "@tursodatabase/database";
import { copyFileSync, truncateSync } from "node:fs";
const path = "./repro.db";
const OPTS = { experimental: ["encryption", "multiprocess_wal"] };
const db = await connect(path, OPTS);
await db.run("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v BLOB)");
for (let i = 0; i < 900; i++) {
await db.run("INSERT INTO t (v) VALUES (?)", [Buffer.alloc(4096, i % 256)]);
}
db.close();
// save the -tshm that claims ~900 frames
copyFileSync(path + "-tshm", path + ".saved-tshm");
// checkpoint everything into the main file, then restore the stale index
const db2 = await connect(path, OPTS);
await db2.run("PRAGMA wal_checkpoint(TRUNCATE)");
db2.close();
copyFileSync(path + ".saved-tshm", path + "-tshm");
truncateSync(path + "-wal", 0);
// now fails:
// await connect(path, OPTS);
// → I/O error: short read on WAL frame at offset 3773952: expected 4096 bytes, got 0Note: the naive recipe (truncate a small WAL to 0, keep -tshm) can open successfully — SQLite's checksum recovery silently drops the tail. You need ~900 × 4 KiB rows (≈3.7 MiB WAL) for a deterministic failure.
Class B needs a live sqlite3 open, so we can't reproduce it deterministically in a script without one. What we can state precisely: after a stock sqlite3 open, the store contains main + non-empty -wal + a -shm that Turso never wrote + the original -tshm, and Turso then fails with Invalid page type: 0. Moving -shm and -tshm aside (keeping -wal) restores it.
Workaround (manual)
The repair depends on which class you're in — this distinction matters, because getting it wrong loses data.
Class A — move all three sidecars, then reopen:
mv shard.db-wal shard.db-wal.quarantine
mv shard.db-shm shard.db-shm.quarantine # if present
mv shard.db-tshm shard.db-tshm.quarantineClass B — move only the two index sidecars and keep -wal:
mv shard.db-shm shard.db-shm.quarantine # if present
mv shard.db-tshm shard.db-tshm.quarantineTurso rebuilds a fresh -tshm and opens the DB. Zero data loss — we verified row counts identical before/after (158 / 168 / 791 across three shards, and 552 KB / 2.3 MB of uncheckpointed -wal preserved byte-for-byte).
Cautions:
- Never use the stock
sqlite3CLI to open a live Turso DB. It recreates-shmand triggers class B. - In class A, only do this when
-walis 0 bytes or absent. A non-empty-walthere means uncheckpointed data may exist — don't move it. - In class B, keep
-wal. It's still valid SQLite WAL; only the index sidecars are inconsistent.
Proposed fix
The plugin could self-heal instead of requiring manual intervention:
- In
TursoConnectionManager: whenconnect()fails withshort read on WAL frameorCorrupt database, detect the stale-sidecar condition and retry once after moving the sidecars into a quarantine directory. The two classes need different handling — class A moves-waltoo (only when it's empty), class B must not. If the retry still fails, move them back. - Make
multiprocess_walopt-out-able. It's the only reason these-tshmsidecars exist. A user who doesn't need multiple OpenCode sessions sharing onestoragePathcould fall back to single-process mode and remove the failure mode entirely. - Surface it instead of failing silently. Right now the symptom is "memory tools suddenly don't work" with no actionable message. A startup check that detects stale sidecars and repairs or reports them would turn a dead memory store into a one-line fix.
- Warn in the docs that the store must not be opened with the stock
sqlite3CLI. This is a footgun that silently breaks the store for anyone debugging it.
We put together a self-healing tool for this and have been running it on a schedule (launchd / systemd) since — it classifies every DB in the store through the real driver, repairs only provably-stale DBs, re-verifies, and rolls back on failure.
Source: https://github.com/v5vsvkmwpv-debug/opencode-config/blob/main/bin/turso-wal-check.sh
Happy to contribute a PR for the connection-manager recovery path if that's useful.
Related
- Upstream: multiprocess_wal: empty WAL + leftover -tshm fails open with short read on WAL frame tursodatabase/turso#8536
- macOS: 2.27.x Turso engine exclusive file lock breaks memory in all but one concurrent OpenCode session #357 / Windows: restore multi-session memory once Turso JS SDK supports multiprocess_wal #359 — other
multiprocess_walissues on this plugin
Activity
jkemp814 commentedon Oct 26, 2026
Independent confirmation of Class A, plus two measured findings that change the picture on Class B and on recovery. Different environment from the report (Linux/Fedora Silverblue, opencode-mem 2.34.0, OpenCode 2.1.4), same signature.
Class A: two incidents, both detected on the next open
Findings that may matter for a fix:
.dbfiles were intact in every incident.PRAGMA integrity_checkreturnedokonuser-profiles.db(72.8 MB, 1 profile row),ai-sessions.db(88 MB),metadata.db,skills.db. This was a 0-byte-walbeside a stale 86,016-byte-tshm, nothing more. Earlier reports of btree/rowid corruption are plausibly a worse instance of the same truncation rather than a separate defect — but in our case "unreadable store, zero data loss" was literally true.-waland the-tshm, reopen. Data all present, no.recover, no per-row salvage. Doing it under a live server also worked twice here, but I would not recommend it — see below.-walnext to a-tshmis also the normal shape of an idle store; turso recreates both on demand. The only reliable detector is attempting an open and matching the error string/class.service restartsends SIGTERM, polls liveness every 50 ms for 100 attempts (5 s), then SIGKILLs unconditionally. Plugin databases live in that process. I cannot prove either incident was a restart — both were found by the next process, and concurrent short-lived servers opening the same DBs are equally plausible — so treat this as strongly correlated, not witnessed.Prevention that measurably works
PRAGMA wal_checkpoint(TRUNCATE)immediately beforedatabase.close(), bounded by a timeout and never throwing, plus closing pooled DBs in parallel so N × bound still fits a shutdown window: #440.Measured by stopping the service and inspecting sidecars with no process alive so nothing could write in between:
So in a 5 s kill window, five databases closed in parallel with a 1.5 s per-DB bound all get checkpointed. That does not defend against a genuine SIGKILL mid-write — only the host giving plugins a real shutdown hook would — but it removes the common-case torn WAL.
Class B: read-only access is enough to plant the sidecars, but maybe not to poison them
This is the part I would want the maintainer to see before documenting "don't point sqlite3 at a live store" as the mitigation, because the behaviour is sneakier than that and my measurement contradicts the strong form of the claim.
Plain sqlite (python stdlib, same SQLite that the CLI uses), on a WAL-mode database:
So a read-only open creates
-shmand a 0-byte-wal, and leaves both behind after close. No write, no checkpoint, no lock released incorrectly — the sidecars simply appear. That is the exact sidecar shape Class A produces, created silently by a diagnostic read.But: in our case those
-shmfiles sat next to live turso stores (I foundai-sessions.db-shmandmetadata.db-shmfrom my own read-only integrity checks) and turso kept working normally for hours afterwards — memory tools, capture and the web server all fine, noInvalid page typeerror. One run, so not conclusive, but it suggests:-shmis not by itself the poison, and-shmcoordination) rather than a pure read.Worth pinning down in the plugin's own logic before recommending user-facing advice, because "read-only is safe" and "read-only is dangerous" lead to different docs, and any integrity check a user runs is read-only.
Practical rules I now encode in a local repair script
mode=ro. Copy the.dbto a temp dir and check the copy.-wal; a non-empty WAL can hold committed frames. Only the 0-byte-plus-stale--tshmcombination is safe to clear.busyfromwal_checkpointis worth surfacing: a partial checkpoint means the truncate was blocked by a reader, which is exactly the state that survives a restart.Offer
If a startup self-heal is wanted (the direction of the original report), I can follow up with a PR that: catches the class-A error from
ensureTursoReady(), verifies the main DB on a copy, quarantines the sidecars into a timestamped directory, and retries the open once — behind a config flag, leaving the store untouched if the main file looks wrong. The checkpoint PR above is complementary: prevention on the way out, recovery on the way in. Say the word and I'll open it rather than speculate further here.jkemp814 commentedon Oct 26, 2026
Reproduced from scratch, and the result reframes both classes. Short version: Class A's exact sidecar state is reachable without any crash — a stock sqlite write that closes while turso holds the store produces it deterministically — while a stock sqlite read is harmless.
Reproducing correctly requires the plugin's open flags
connection-manager.tsopens every database as{ experimental: ["encryption", "multiprocess_wal"] }on Unix. My first attempt omitted those flags and stock sqlite could not open the store at all (sqlite3.OperationalError: database is locked, no-tshmcreated).multiprocess_walis what creates the-tshmsidecar and what lets a foreign process in. Any repro needs it:Results (turso holds the store open throughout)
-tshm=86016 -wal=20632turso-1, turso-2turso-1, turso-2, turso-after✅-shm=32768 -tshm=86016 -wal=20632turso-1, turso-2turso-1, turso-2, turso-after✅-tshm=86016 -wal=0(-shmgone)turso-1, turso-2— stale, missedstock-rowturso-1, turso-2, stock-row—turso-aftersilently lost ❌Three things fall out:
-shmnext to-tshmand turso carries on correctly — no divergence, no loss, verified by value-level assertion across close+reopen. The advice "don't pointsqlite3at a live store" overstates the read case; the hazard is writes.-wal = 0 bytes,-shmdeleted,-tshmstill 86,016 bytes. That is the byte-for-byte sidecar state the original report attributes to power-off, produced bysqlite3 db.db "insert ..."followed by process exit.stock-row) and its next write went into a WAL that got discarded by the close — 4 expected rows, 3 present after reopen. No exception at any point.Unified mechanism
Stock sqlite's last-connection close runs a checkpoint and resets/deletes the WAL, using
-shmcoordination it owns. Turso keeps its index in-tshm, which still claims the old frames. Same root condition for both reported classes — a-tshmthat outlives the WAL it indexes:short read on WAL frame ... expected 4096 bytes, got 0(Class A);Invalid page type: 0(Class B);So a crash is one way to desynchronise the pair, not the only one, and the "diagnose it with sqlite3" path is only dangerous when someone writes.
Implications for a fix, cheapest first
<db>-shmpresence as a foreign-writer footprint: verify the main DB on a copy, quarantine-shm/-tshm/-walinto a timestamped dir, reopen once. That converts today's silent loss and hard failures into a recoverable event, and it is the same code path a startup self-heal needs.multiprocess_wal. Turning it off makes the store hostile to intruders (database is locked— loud, recoverable) instead of silently divergent. For a personal memory store, single-process may simply be the right default; concurrency is what the plugin gains from it.-tshm, validate that the WAL it indexes is still the same WAL (inode/size/header change-counter). Reset the index instead of trusting it. That fixes both classes at the source, and #8536 is presumably the place.Happy to send (1) as a PR behind a flag, and (2) as a config default proposal, if either direction is wanted. The harness is ~60 lines of bun plus two python one-liners — say the word and I'll attach it rather than paste it into the thread.