fix(state): tighten existing db files by chmod(2), not an open/fchmod/close cycle

POSIX fcntl locks are owned per (process, inode): closing any descriptor
for state.db releases every lock the process holds on that inode,
including the locks of an already-open SQLite connection. The
owner-only hardening cycle opened the live database and its -wal/-shm
read-only, fchmod'ed, and closed, so any process that already held a
connection (gateway, desktop hermes serve, dashboard share one) dropped
its live locks on every SessionDB init. A sibling process then took the
shared-memory DMS exclusively at its own close, checkpointed, and
unlinked the sidecars while long-lived holders kept the deleted inodes
open, tripping the deleted-WAL generation guard.

chmod(2) on the path never opens the file, so it cannot disturb locks.
The descriptor path remains only for first-time main-db creation, where
no locks can exist yet.
This commit is contained in:
Konstantin Khlopkov
2026-09-13 11:17:17 +03:00
committed by Teknium
parent b6b53c69a6
commit ccd360e94f
2 changed files with 72 additions and 18 deletions

View File

@@ -15,6 +15,7 @@ import queue
import random
import re
import sqlite3
import stat
import sys
import threading
import time
@@ -222,41 +223,58 @@ def _secure_state_db_files(db_path: Path, *, create_main: bool = False) -> None:
"""Create/tighten a writable state database and its sidecars to 0600.
SQLite otherwise creates ``state.db``, ``-wal``, and ``-shm`` according to
the process umask (commonly 0644 under 0022). Use file descriptors so a
missing main database is private from its first byte and O_NOFOLLOW can
refuse a planted symlink. Read-only SessionDB attachments never call this
helper and remain observational.
the process umask (commonly 0644 under 0022). Read-only SessionDB
attachments never call this helper and remain observational.
Existing files are tightened with ``chmod(2)`` on the path: opening the
file and closing that descriptor would drop every POSIX ``fcntl`` lock the
process holds on its inode — including the locks of an already-open SQLite
connection to the same database. A lock-losing close in one process lets a
sibling's connection take the shared-memory DMS exclusively at its own
close, checkpoint, and unlink the sidecars while long-lived holders
(gateway, desktop ``hermes serve``) keep using the deleted inodes.
"""
if os.name == "nt":
return
for index, path in enumerate(
(
db_path,
db_path.with_name(db_path.name + "-wal"),
db_path.with_name(db_path.name + "-shm"),
)
):
flags = os.O_RDONLY
if index == 0 and create_main:
flags = os.O_WRONLY | os.O_CREAT
main_path = db_path
if create_main:
flags = os.O_WRONLY | os.O_CREAT
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
if hasattr(os, "O_CLOEXEC"):
flags |= os.O_CLOEXEC
try:
fd = os.open(path, flags, 0o600)
except FileNotFoundError:
continue
fd = os.open(main_path, flags, 0o600)
except IsADirectoryError:
# Not a database file at all; sqlite3.connect() raises the
# canonical error for this, and a directory leaks no row data.
continue
return
try:
os.fchmod(fd, 0o600)
finally:
os.close(fd)
for path in (
main_path,
db_path.with_name(db_path.name + "-wal"),
db_path.with_name(db_path.name + "-shm"),
):
# fchmod on an fd of a pre-existing file cannot be used here: close(fd)
# would release this process's POSIX locks on that inode, stripping the
# locks of any live SQLite connection to the same database. chmod(2)
# never opens the file, so it leaves the lock state untouched.
try:
st = os.lstat(path)
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
# Refuse a planted symlink exactly like O_NOFOLLOW would.
continue
if not stat.S_ISREG(st.st_mode):
continue
os.chmod(path, 0o600)
# Openings of the background-review harness prompts (agent/background_review.py).
_REVIEW_HARNESS_PREFIXES = (