Skip to content

state.db WAL lock guard follows the handle lifecycle: reopen, checkpoint-vs-close, fd reuse (review of #110544) - #110872

Merged
teknium1 merged 1 commit into
mainfrom
fix/state-lockguard-lifecycle
Sep 14, 2026
Merged

teknium1 merged 1 commit into
mainfrom
fix/state-lockguard-lifecycle

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

The WAL lock guard from #110544 now follows the lifecycle of the handle it protects: a reopened writer is guarded again, a checkpoint refresh can no longer pin a lock after close(), and a recycled descriptor number is never mistaken for a surviving lock.

All three gaps were raised in the #110544 review by @andrexibiza and @ehz0ah before it merged and were not addressed there; this PR fixes them forward, with each one reproduced first.

Changes

  • hermes_state_lockguard.py: ownership is counted per inode, not per (fd, inode). Every hold() re-locks every matching descriptor (OFD re-lock on an already-locked description is idempotent), so a new descriptor that reused an old number gets its own lock; release() drops this handle's claim and unlocks only when it was the last handle on that inode. Held is now {inode: range}.
  • hermes_state.py::_reopen_after_close_locked: the reopened writer takes the guard like a first open.
  • hermes_state.py::_try_wal_checkpoint: the guard refresh runs under self._lock and is skipped when _conn is None, so a refresh landing after close() no longer leaves an OFD lock with no connection behind it.
  • hermes_state_dbfile.py::_iter_darwin_sidecar_holders: sidecar paths are compared case-folded; os.path.normcase is the identity on darwin while APFS/HFS+ are case-insensitive, so a case-only alias of the path was missed.

Root cause

The first cut modeled the guard as a property of a descriptor number and refreshed it without the connection lock; the guard is a property of the handle's lifetime.

Validation

Witness (Linux, Python 3.11, SQLite 3.53.1) origin/main (3e43cee) this PR
close() → append_message() reopen: guard present, foreign EXCLUSIVE refused guard {}, EXCLUSIVE acquired guard held, refused
A+B live → close A → C reuses A's fd → close B: C still guarded; after C closes nothing locked, _HANDLES empty EXCLUSIVE acquired with C live refused with C live; free after
tests/hermes_state/test_wal_lock_guard_lifecycle.py (2 invariants) 2 failed 2 passed
tests/hermes_state/, tests/test_hermes_state.py, ledger/delegation fd-leak suites 1291 passed, 1 failed (TestFTS5Search::test_search_projection_skips_context_enrichment_queries, fails identically on unpatched main)

The checkpoint-vs-close race is covered by construction (refresh under self._lock, _conn is None check) rather than a timing test; the macOS case-fold change needs a darwin host to exercise and is not covered by a new test here.

Refs #100896 #103339. Follow-up to #110544.

Infographic

WAL lock guard lifecycle fixes

Three gaps in the #110544 guard, all reported in its review and reproduced:

- A writer reopened by _reopen_after_close_locked (teardown/worker race,
  #94736) came back with no guard: the next stray close + foreign close
  deleted its WAL again.
- _try_wal_checkpoint refreshed the guard outside self._lock; landing after
  close() it pinned an OFD lock with no connection behind it, so a foreign
  `PRAGMA journal_mode=DELETE` saw `database is locked` forever.
- Refcounts keyed on (fd, inode) treated a recycled fd number as a surviving
  lock: A+B live, close A, C reuses A's fd, close B left C recorded as guarded
  while a foreign EXCLUSIVE succeeded.

The guard now counts handles per inode, re-locks every matching descriptor on
each hold (OFD re-lock is idempotent), and unlocks on the last handle only;
the reopen path holds it; the checkpoint refresh runs under self._lock and
skips a closed handle. The macOS holder scan folds case so a case-only alias
of the sidecar path on APFS still matches.
@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 Sep 14, 2026
@teknium1
teknium1 merged commit 274fd56 into main Sep 14, 2026
40 checks passed
@teknium1
teknium1 deleted the fix/state-lockguard-lifecycle branch September 14, 2026 13:54
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: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.

2 participants