Skip to content

Read-only serve opens SQLite with immutable=1 — long-lived MCP connections go stale after external writes and report "database disk image is malformed" on a healthy DB #319

Description

@jakobtfaber

Summary

icm serve --read-only opens the SQLite store with mode=ro&immutable=1 (crates/icm-store/src/store.rs:72, open_readonly_immutable). immutable=1 tells SQLite the file will never change for the lifetime of the connection. But the documented deployment model for --read-only serve is exactly a DB that does keep changing underneath it: hooks and CLI invocations write to the same memories.db while a long-lived read-only MCP server holds its connection.

The result: after the first external write, the server's connection is permanently stale —

  1. recalls silently return a stale snapshot (memories stored after server start are invisible), and
  2. once the file has grown/restructured enough, every query on that connection — reads included (icm_memory_recall, icm_memory_health) — fails with SQLITE_CORRUPT: database error: database disk image is malformed.

The database itself is healthy the whole time (PRAGMA integrity_check, PRAGMA quick_check, and FTS5 integrity-check all return ok; fresh connections work fine). The error message sends operators chasing nonexistent corruption.

  • Version: icm 0.10.57 (release binary; main has the same open path)
  • OS: macOS (Darwin 27.0)
  • Setup: icm --db <path> --read-only serve --no-embeddings as an MCP server for Claude Code/Codex, with Claude Code hooks writing to the same DB via the CLI.

Repro

Deterministic, single machine:

# 1. seed
icm --db /tmp/t.sqlite --no-embeddings store -t t1 -c "seed memory alpha" -i low

# 2. start a read-only MCP server and keep it running
icm --db /tmp/t.sqlite --read-only serve --no-embeddings
#    (drive it over stdio: initialize, then tools/call icm_memory_recall query="alpha" → returns the seed. OK)

# 3. from another shell, write to the same DB
icm --db /tmp/t.sqlite --no-embeddings store -t t1 -c "second memory alpha beta" -i low

# 4. recall again on the SAME server process
#    → still returns only "seed memory alpha"; the new memory is invisible (stale immutable snapshot)

# 5. keep writing (e.g. ~40 stores of a few KB, or anything that grows/restructures the file)
#    → queries on the server connection begin failing:
#    "database error: database disk image is malformed"

Observed in production as: MCP icm_memory_store/icm_memory_health/recall all returning database disk image is malformed from long-lived Claude Code sessions, while sqlite3 <db> "PRAGMA integrity_check;" says ok and fresh CLI invocations work.

Root cause

open_readonly_immutable's doc comment explains immutable=1 was chosen so a read-only open works even on a chmod -w parent directory (SQLite otherwise wants to create/refresh -shm/-wal). That's the right recipe for a static snapshot, but it also disables all locking and change detection — SQLite caches the page count/schema and serves stale pages, and once the file layout shifts, cached assumptions contradict the file and every statement returns SQLITE_CORRUPT.

Proposed fix

Open read-only connections with plain mode=ro by default — WAL-aware, lock-respecting, sees committed writes; busy_timeout=30000 is already set on this path. Fall back to immutable=1 only when the plain read-only open fails (unwritable directory, true read-only sandbox), or gate it behind an explicit flag. I.e.:

// try: file:{path}?mode=ro           (normal case: live DB, writable dir)
// fallback: file:{path}?mode=ro&immutable=1   (read-only sandboxes)

A local build of the icm-v0.10.57 tag with immutable=1 dropped resolves both symptoms (fresh reads after external writes; no corrupt-image errors; store on the read-only server now fails with the honest attempt to write a readonly database).

As a smaller companion fix: surfacing SQLITE_CORRUPT from a read-only connection as "connection stale — reopen" (or auto-reopening) would prevent the misleading corruption diagnosis even in genuinely immutable setups.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions