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.
This commit is contained in:
teknium1
2026-09-14 06:17:39 -07:00
committed by Teknium
parent 743140cd82
commit 274fd56dca
4 changed files with 139 additions and 48 deletions
+4 -2
View File
@@ -295,10 +295,12 @@ def _iter_darwin_sidecar_holders(db_path) -> List[Tuple[int, str]]:
path for the vnode, while ``os.path.abspath`` does not resolve symlinks -- a textual compare
of the two silently misses every sidecar under a symlinked prefix (on macOS ``/var`` itself)."""
base = os.path.realpath(os.path.abspath(os.fspath(db_path)))
watched = {os.path.normcase(path): path for path in (base + "-wal", base + "-shm")}
# APFS/HFS+ are case-insensitive by default and libproc reports the pathname as the opener
# spelled it; ``os.path.normcase`` is the identity on darwin, so fold case here.
watched = {path.casefold(): path for path in (base + "-wal", base + "-shm")}
holders: List[Tuple[int, str]] = []
for pid, _fd, target, identity in _iter_darwin_fd_targets():
literal = watched.get(os.path.normcase(target))
literal = watched.get(target.casefold())
if literal is not None and _identity_is_truly_unlinked(identity, literal):
holders.append((pid, target))
return holders