Repository navigation
Conversation
A process killed mid-WAL (SIGKILL after a shutdown timeout, OOM kill, power-off) can leave a `-wal` that disagrees with turso's `-tshm` index; every later open then fails with "I/O error: short read on WAL frame", which takes the whole memory system down because ensureTursoReady() gates tools, capture and the web server behind it (see tickernelz#435). Run `PRAGMA wal_checkpoint(TRUNCATE)` before database.close(), bounded at 1500 ms so a contended checkpoint cannot consume a host shutdown budget, never throwing, and logging the pragma result row so a partial checkpoint is visible. Close pooled databases in parallel so N * the bound still fits inside the window. Verified: format/lint/typecheck clean; 13 pass across the four turso and plugin-shutdown test files; against a patched build loaded by a running server, stopping the service leaves metadata.db 74192 -> 0 and user-prompts.db 45352 -> 0 bytes of WAL with no process alive to write.
There was a problem hiding this comment.
🟡 Changes recommended
The checkpoint await breaks synchronous exit cleanup, and logging failures can prevent database handles from closing.
2 open issues
What changed in this PR
This PR adds best-effort WAL checkpointing before Turso database closure to reduce exposure to the stale-sidecar failures described in #435. It mitigates shutdown risk rather than repairing existing corruption.
Changes:
- Adds a 1500 ms checkpoint timeout and result logging before database closure.
- Closes pooled databases in parallel to avoid accumulating checkpoint delays.
- Adds tests for checkpoint ordering, failure handling, and late rejection after timeout.
| File | Description |
|---|---|
tests/turso-close-checkpoint.test.ts |
Tests checkpoint ordering and failure paths. |
src/services/turso/turso-db.ts |
Adds bounded, best-effort checkpointing before close. |
src/services/turso/connection-manager.ts |
Changes pooled database closure to run in parallel. |
Review effort: Balanced
| checkpointed: result?.checkpointed, | ||
| }); | ||
| } catch (error) { | ||
| log("Turso WAL checkpoint before close failed", { error: String(error) }); |
There was a problem hiding this comment.
Fixed in 924a6c1: both diagnostic writes in checkpointBeforeClose() now go through a local safeLog() that swallows logger failures, so nothing in the helper can throw and database.close() is always reached. Regression test points OPENCODE_MEM_LOG_FILE under a regular file to force ENOTDIR from the logger and asserts the sequence is still ["PRAGMA wal_checkpoint(TRUNCATE)", "close"].
| } | ||
|
|
||
| async close(): Promise<void> { | ||
| await this.checkpointBeforeClose(); |
There was a problem hiding this comment.
Fixed in 924a6c1: added TursoDb.closeSync() and closeAllSync() now uses it. turso's async close() calls the native close in its first statement with nothing awaited before it, so an un-awaited call does release the handle — there is no public sync API on the async class, and the compat build is a different class that would require reopening. A test asserts Database.open === false synchronously after closeAllSync(), which also fails if turso ever puts an await ahead of the native close. The residual limitation is documented in the JSDoc: the exit path still cannot checkpoint, because there is no event loop.
|
Measurement and the read-only/Class-B evidence are in #435: #435 (comment) — includes the before/while-stopped WAL table and why sidecar presence alone isn't a valid corruption signal. |
… path Review findings on tickernelz#440: - checkpointBeforeClose: swallow logger failures through a local safeLog, so an ENOSPC/EACCES diagnostic write cannot skip database.close() - add TursoDb.closeSync() and use it from closeAllSync(): the async close() starts with the awaited checkpoint, so the synchronous exit handler never reached the native close. closeSync() calls the native close on the same tick (turso's async close() runs db.close() before any await) and skips the checkpoint, which cannot run without an event loop - tests: log failure still closes; closeSync skips the checkpoint and closeAllSync flips Database.open on the same tick
|
Both review findings are addressed in
Verified: format/lint/typecheck clean, 16 pass across the four turso + shutdown test files, full suite down to the 2 pre-existing |
Follow-up to the review on tickernelz#440: the checkpoint path already swallowed logger failures locally, but the close-error handlers in TursoConnectionManager still called log() directly. A logger that throws on ENOSPC/EACCES there would escape into the process `exit` handler. Move the helper into src/services/logger.ts as an exported safeLog() and use it for both the checkpoint diagnostics and every close-error log in the connection manager, so the rule is stated once instead of being re-implemented per file. No behaviour change on the success path.
|
One asymmetry left over from the same review. The checkpoint diagnostics were already guarded, but the close-error handlers in
Re-verified after the change: |

Summary
Checkpoints the WAL into the main database file before closing a pooled connection, so a process that dies ungracefully cannot leave a
-walthat disagrees with turso's-tshmindex. Relates to #435; complements #441 (prevention on the way out, recovery on the way in).Test plan
metadata.db74,192 -> 0,user-prompts.db45,352 -> 0)Checklist
main(do not open PRs from commits already onmain)bun testpasses (or N/A) — 16 pass across the four turso/shutdown files; full suite down to 2 pre-existingconfigfailuresbun run typecheckpasses (or N/A)bun run checkpasses (format + lint + typecheck; or N/A)Checkpoints the WAL into the main database file before closing a pooled connection, so a process
that dies ungracefully cannot leave a
-walwhose state disagrees with turso's-tshmindex.Why
Turso keeps its multiprocess WAL index in
<db>-tshm. When a host kills the process mid-WAL —SIGKILL after a shutdown timeout, an OOM kill, a power-off — the
-walcan end up truncated orpartially written while the index still claims frames exist. Every later open then fails with:
and because
ensureTursoReady()gates memory tools, chat capture and the web server behind it, onetorn sidecar pair takes the whole memory system down (issue #435, which documents the same classes
of failure). The main
.dbfiles stay intact, so no data is lost — but nothing in the plugincloses databases in a way that flushes the WAL first.
OpenCode makes this reachable in normal use: its
service restartsends SIGTERM and, after a fixed5 s poll, SIGKILLs the shared server. That server is where plugin databases live, so a restart can
land on hot WALs across every database at once. Checkpointing before close shrinks that window to
the duration of the close itself.
What changed
src/services/turso/turso-db.ts—TursoDb.close()runsPRAGMA wal_checkpoint(TRUNCATE)beforedatabase.close().CHECKPOINT_TIMEOUT_MS, overridable via an optional constructor argumentused only by tests) so a contended checkpoint cannot eat the whole shutdown budget.
Turso WAL checkpoint before close failed) and the close proceeds.busy/log/checkpointed) on success, so a partiallycheckpointed WAL is visible instead of silent.
src/services/turso/connection-manager.ts—closeAll()closes pooled databases in parallelinstead of sequentially, so N × the per-DB bound cannot exceed the host's shutdown window.
tests/turso-close-checkpoint.test.ts— checkpoint-then-close ordering; close still happens whenthe pragma rejects; late rejection after the timeout is observed (no unhandled rejection).
Verification
bun run format:check,bun run lint,bun run typecheck— clean.bun test tests/turso-close-checkpoint.test.ts— 3 pass.bun test tests/turso-close-checkpoint.test.ts tests/turso-connection-manager.test.ts tests/turso-connection-lifecycle.test.ts tests/plugin-shutdown.test.ts— 13 pass, 0 fail.and inspecting sidecars with no server process alive so nothing could write in between:
Full
bun test: 860 pass / 4 skip / 6 fail; the 6 are pre-existing (2config > CONFIG defaults,4
plugin-v2-loader-contractbecausedist/v2is not built in this checkout) and reproduce onmainwith the patch stashed.Risks / limits
disk. It reduces the window rather than closing it. A plugin shutdown hook plus a SIGTERM grace
period in the host is the real fix.
wal_checkpoint(TRUNCATE)needs no other readers; it can returnbusyon a store observed byanother process. That is logged, not retried, and does not block shutdown.
dispose→ close → reopen) this adds a small per-database latency.Relates to #435.