Skip to content

fix(state): serialize state.db schema surgery across processes - #69609

Open
ernst-bablick wants to merge 1 commit into
NousResearch:mainfrom
ernst-bablick:fix/state-cross-process-repair-lock
Open

fix(state): serialize state.db schema surgery across processes#69609
ernst-bablick wants to merge 1 commit into
NousResearch:mainfrom
ernst-bablick:fix/state-cross-process-repair-lock

Conversation

@ernst-bablick

Copy link
Copy Markdown
Contributor

Fixes #69603.

Problem

repair_state_db_schema() runs PRAGMA writable_schema=ON + sqlite_master surgery + VACUUM on a private connection. The only guard around it is _repair_attempt_lock, a threading.Lock whose docstring claims it "serialises concurrent web_server / gateway opens" — but a threading lock covers threads inside one interpreter, not processes.

A normal host runs four independent processes against the same state.db:

  • hermes-gateway.service
  • the Desktop app's own hermes serve backend (it spawns one per launch — it is not a thin client)
  • interactive CLI sessions
  • the TUI slash worker

When two of them hit a malformed DB, both enter the critical section and each runs the full surgery while the other is mid-rewrite. Observed on my host as a repair/re-corrupt cascade — the DB is repaired, then re-corrupts minutes later, repeatedly (18:03, 22:05, 22:17 the same evening), each cycle logging:

state.db write failed with an FTS-corruption error (database disk image is malformed)
FTS indexes rebuilt in place (2); retrying the failed write
gateway.session: state.db routing save failed: database disk image is malformed

Fix

1. Cross-process flock around the destructive path. An advisory lock on a sidecar file next to state.db, so it covers every process on the host regardless of interpreter. Under the lock, the existing _db_opens_cleanly() check becomes a double-check: a queued process finds the DB already healthy and returns already_healthy instead of re-running surgery on a freshly repaired DB.

2. Bump the schema cookie after direct sqlite_master edits. Ordinary DDL bumps schema_version for free, and every other connection compares it before running a prepared statement — that is how they learn to drop a cached schema. Editing sqlite_master under writable_schema=ON does not bump it, so live connections in other processes kept writing messages rows through triggers into messages_fts* shadow tables the surgery had just deleted. SQLite's writable_schema documentation calls out incrementing schema_version as the required companion to such an edit.

Tests

Four new cases in tests/test_state_db_malformed_repair.py, using real child processes and a real flock (no mocking of the concurrency):

  • two simultaneous repairers produce exactly one malformed-backup-* file (two on main)
  • a queued process observes the repaired DB and returns already_healthy
  • the schema cookie is bumped across sqlite_master surgery
  • a live connection in another process is forced to reload its cached schema

All four fail on main and pass with this change. Full -k state suite: 1043 passed, 13 skipped.

Relation to #43742

#43742 makes the in-process claim loser retry rather than raise; it explicitly leaves repair_state_db_schema() unchanged and does nothing cross-process. The two are independent and compose.

🤖 Generated with Claude Code

`repair_state_db_schema()` performs `PRAGMA writable_schema=ON` +
`sqlite_master` surgery + `VACUUM` on a private connection. The only guard
around it is `_repair_attempt_lock`, a `threading.Lock`, whose docstring
claims it "serialises concurrent web_server / gateway opens" — but a
threading lock covers threads inside one interpreter, not processes.

A normal host runs four independent processes against the same state.db:
the gateway service, the Desktop app's own `hermes serve` backend (it
spawns one per launch, not a thin client), interactive CLI sessions, and
the TUI slash worker. When two of them hit a malformed DB, both entered
the critical section and each ran the full surgery while the other was
mid-rewrite. Observed as a repair/re-corrupt cascade: the DB is repaired,
then re-corrupts minutes later, repeatedly.

Two fixes:

1. Wrap the surgery in a bounded `flock` on `<db>.repair.lock`. `flock` is
   the right primitive — the kernel drops it when the holder dies, so a
   crashed repairer cannot wedge future repairs the way a pidfile would.
   The acquire is bounded (NousResearch#36644's failure shape) and, unlike the kanban
   init lock, a caller that times out must NOT proceed: here "proceed
   anyway" is exactly the unsafe interleaving. It re-probes instead, and
   reports success if the holder already healed the file.

   Under the lock, the existing `_db_opens_cleanly()` check becomes a
   double-check: a queued process finds the DB healthy and returns
   `already_healthy` rather than re-running surgery on a repaired DB.

2. Bump the schema cookie after direct `sqlite_master` edits. Ordinary DDL
   bumps it for free and every other connection compares it before running
   a prepared statement — that is how they learn to drop a cached schema.
   Editing `sqlite_master` under `writable_schema=ON` does not, so live
   connections in other processes kept writing `messages` rows through
   triggers into `messages_fts*` shadow tables the surgery had just
   deleted. SQLite's writable_schema docs call out incrementing
   `schema_version` as the required companion to such an edit.

Tests: four new cases in tests/test_state_db_malformed_repair.py, all
using real child processes and a real flock. All four fail on main and
pass with this change; the concurrency case asserts exactly one
`malformed-backup-*` file is produced by two simultaneous repairers
(two on main). Full state suite: 558 passed.

Complements NousResearch#43742, which makes the *in-process* claim loser retry rather
than raise; it explicitly leaves `repair_state_db_schema()` unchanged and
does nothing cross-process. The two are independent and compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 22, 2026
@ernst-bablick

Copy link
Copy Markdown
Contributor Author

Full suite now run on this branch, not just the -k state subset — via the repo's own runner (scripts/run_tests.sh, the same entry point tests.yml uses):

=== Summary: 2184 files, 44664 tests passed, 3 failed (100% complete) in 261.0s (24 workers) ===

The 3 failures are host-environment artifacts, not regressions — all reproduce on main in a clean worktree:

Test Cause
tests/tools/test_execution_flag_detection.py::test_real_binaries_execute_leading_dash_program_payload[sort-args2-{bulk}-False] Ubuntu 26.04 ships uutils coreutils (/usr/bin/sort -> ../lib/cargo/bin/coreutils/sort, sort (uutils coreutils) 0.8.0) instead of GNU. Fails identically on main.
tests/hermes_cli/test_pip_install_detection.py::test_banner_warns_on_pip_install Banner text wraps at the runner's terminal width, so "officially" is split. Passes on both branches with COLUMNS=80.
tests/hermes_cli/test_pip_install_detection.py::test_banner_warns_on_homebrew_install Same width artifact.

Nothing in the run touches the state.db paths this PR changes; tests/hermes_state + tests/state are green (79 passed).

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing this to the repair path; the current-main premise is confirmed. hermes_state.py:923-954 provides only a process-local threading.Lock, while repair_state_db_schema() still performs direct sqlite_master surgery at hermes_state.py:1326-1360 without a schema-cookie bump.

Problems

  • hermes_state.py:597 fails open when the sidecar lock cannot be opened, then permits the destructive repair without cross-process serialization. The existing quarantine path instead fails closed on lock failure (hermes_state.py:1645-1657); this path needs the same safety property.
  • The simultaneous-repair test does not synchronize both children at the relevant race boundary (tests/test_state_db_malformed_repair.py:726-740), so a serial scheduler can make it pass without exercising concurrent repairers.
  • The schema-cookie test closes its probe before repair and opens a new connection after it (tests/test_state_db_malformed_repair.py:767-781), so it does not test the stated live-connection behavior.

Suggested changes

  • Fail closed if opening/acquiring the repair lock is impossible.
  • Make the concurrency and live-connection checks deterministic.

The related #71982 backup-copy race is separate, as noted in #69603 discussion. This is an automated hermes-sweeper review.

Comment thread hermes_state.py
"Could not open state.db repair lock %s (%s) — proceeding with "
"in-process serialisation only.", lock_path, exc,
)
yield True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This must not yield True: when the lock file cannot be opened, this permits the same uncoordinated schema surgery the lock is meant to prevent. Please fail closed (yield False / return a repair error), consistent with the quarantine lock's refusal behavior on current main.

probe.execute("PRAGMA writable_schema=ON")
before = probe.execute("PRAGMA schema_version").fetchone()[0]
finally:
probe.close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This closes the only probe before repair, so the test only observes a persisted schema-version change through a fresh connection. Keep a connection alive across repair and execute a previously prepared schema-dependent operation to cover the claimed cache invalidation.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
teknium1 pushed a commit that referenced this pull request Aug 13, 2026
…try transient EIO on journal-mode probe

Salvaged remainder of PR #82280 (state.db hardening rollup):

- Runtime connection corruption: a sibling process replacing/truncating
  the backing file breaks the live write connection — every subsequent
  write raises 'file is not a database' and the gateway wedges
  permanently (messages pile up in memory). Add a bounded one-shot
  reconnect on the write path: close the broken connection, reopen the
  DB file (re-running WAL activation + schema reconciliation), retry
  the failed write once.
- _on_disk_journal_mode: retry transient 'disk i/o error' (virtualized
  block devices) a few times before returning None, so a one-shot EIO
  doesn't push callers onto the fail-closed unknown-mode branch.

The rollup's write-lock machinery, checkpoint-strategy changes, and
repair serialization are intentionally NOT included — superseded by
PRs #84277 and #69609, or wrong-direction per the POSIX
lock-cancellation findings (#71724 lineage).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

state.db repair/re-corrupt cascade: schema surgery is only serialized in-process, and sqlite_master edits never bump the schema cookie

3 participants