Commit Graph

6 Commits

Author SHA1 Message Date
teknium1
87bb0d3827 fix(state): a closing writer keeps its WAL generation until SQLite's own close (#121429)
SessionDB.close() lifts the OFD lock guard before sqlite3_close so a true
last close can still end the generation. When a stray in-process
open()/close() has already cancelled the handle's POSIX locks (the case
the guard exists for), lifting the OFD copy leaves the still-open
connection with no lock at all. A sibling closing in that gap takes
EXCLUSIVE and unlinks -wal/-shm under it, and every opener in the
meantime refuses with DeletedWalGenerationError.

release() now re-takes SQLite's own process-owned POSIX read lock on each
range before it drops the OFD copy. That is the lock SQLite still thinks
it holds. The re-take cannot be refused, because our OFD lock already
excludes writers. SQLite's close upgrades or drops the lock itself, so a
true last close still unlinks the generation.

Repro: the torture chamber's lock_cancellation episode on CI
(job 107586407677: "lock_cancellation-tui held state.db-shm (deleted)").
Deterministic with a sleep injected between release() and the connection
close: base red 4/4, fix green 4/4. The new lifecycle test runs a sibling
sqlite3 close at that seam: red on base (-shm unlinked), green on the fix,
and it checks that the last close still removes -wal.
2026-09-26 11:32:10 -07:00
Austin Pickett
524041b9d0 fix(state): a partial fcntl must disable the WAL guard, not kill every importer (#118269)
* fix(state): a partial fcntl must disable the WAL guard, not kill every importer

hermes_state_lockguard gated only on ImportError, treating "import fcntl
succeeded" as "full POSIX fcntl present". Stock CPython for Windows ships no
fcntl at all, so CI's Windows lane always took the except branch — but an
install whose venv has some other module importable as `fcntl` (flock/LOCK_*
only) reached the unguarded `fcntl.F_RDLCK` read and raised AttributeError at
import time.

hermes_state imports this module at module level, so that killed every
importer before supported() could report the guard as unavailable: `hermes
serve` (Desktop backend "exited before port announcement (1)"), `hermes
doctor`, the gateway and cron.

Gate on os.name == "nt" first — the module already promises to be a no-op on
Windows, and a lookalike must never arm it — and treat a partial module off
Windows like a missing one (ImportError, AttributeError), falling back to the
same no-op.

Closes #118026

* fix(state): a constants-only fcntl must not report the WAL guard as supported

Checking F_OFD_SETLK/F_RDLCK/F_UNLCK alone let a module carrying the constants
but no fcntl() callable pass the capability probe: supported() returned True and
the first hold() raised AttributeError from _ofd_lock(). Probe the callable in
the same block so that shape degrades to the no-op like a missing module, and
pin it with a constants-only stub that holds an fd open so hold() actually
reaches fcntl.fcntl().

* test(state): run the Windows branch of the lockguard gate on the Windows lane

The tests-os Windows job selects files by the windows_only marker, so the
os.name == "nt" branch had no runner coverage. Add a marked test asserting a
lookalike fcntl never arms the guard on native Windows.
2026-09-21 19:56:16 -04:00
teknium1
274fd56dca fix(state): WAL lock guard follows the handle's lifecycle
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.
2026-09-14 06:54:07 -07:00
teknium1
3e43cee505 fix(state): refcount guard locks shared by several handles in one process
Two SessionDB handles on one state.db in one process see the same
descriptors; the first release must not drop the range the second still
needs. Test covers the same-process sibling and the foreign-process close.
2026-09-14 05:28:22 -07:00
teknium1
beb546b0f2 fix(state): lock guard rides SQLite's own descriptors
OFD locks on the connection's fds die with the connection: no private
descriptors to track, retire, or exclude from holder scans.
2026-09-14 05:28:22 -07:00
teknium1
75e155ab09 fix(state): a live writer's WAL generation survives lock cancellation and sibling closes
SQLite protects a WAL generation with per-PROCESS POSIX locks (SHARED on
state.db, DMS byte on -shm). Any in-process open()/close() of either file
cancels both (sqlite.org/howtocorrupt.html §2.2); the next last-connection
close in ANY process then checkpoints and unlinks -wal/-shm, and the holder
sticky-halts with DeletedWalGenerationError. #109841 removed one such
close (mode tightening) but the class is open-ended: raw header probes,
plugins, tool reads of ~/.hermes, any library that touches the files.

hermes_state_lockguard re-holds the same two ranges as OFD locks
(F_OFD_SETLK) on private descriptors for as long as a writer handle is
open. OFD locks belong to the open file description, so a stray close()
cannot cancel them, and they conflict with the EXCLUSIVE a sibling needs
for the close-time reset exactly like SQLite's own. Released before the
handle's own close so a true last close still ends the generation; the
descriptors are closed only once no connection to the path remains, so a
holder scan from another process never counts them. Works on Python 3.11
(where sqlite3 cannot arm SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE) and on macOS
(F_OFD_SETLK=90 per XNU bsd/sys/fcntl.h); no-op on Windows.

Live repro (Linux, Python 3.11.15, SQLite 3.53.1): holder = SessionDB
writer; in-process os.open/os.close of state.db and -shm; then a foreign
sqlite3.connect()+close(). Before: -wal unlinked, holder write raises
DeletedWalGenerationError. After: -wal keeps its inode, holder writes.
2026-09-14 05:28:22 -07:00