fix(state): treat ESRCH like ENOENT in deleted-WAL fd identity check

`_fd_is_truly_unlinked` stats `/proc/<pid>/fd/<n>` after the scan has
already read the readlink target. Between those two steps the descriptor
can be closed (ENOENT, handled by the previous commit) or the whole
process can exit (ESRCH). Both mean the descriptor can no longer keep a
retired WAL/SHM generation alive, so neither is evidence of a live holder
and neither should make the guard refuse to open the database.

Match the sibling scan in hermes_state_holders, which already skips both
errnos, by branching on `exc.errno in (ENOENT, ESRCH)`; any other OSError
still fails closed. The existing closed-descriptor test is parametrized
over both errnos, injecting the failure at `os.stat` so the ESRCH path is
exercised on every platform.
This commit is contained in:
kshitijk4poor
2026-09-15 01:17:42 +05:30
committed by kshitij
parent 106bf99a0e
commit 7feaf03883
2 changed files with 20 additions and 7 deletions
+7 -5
View File
@@ -10,6 +10,7 @@ call time, so tests that monkeypatch ``hermes_state.<name>`` keep intercepting.
from __future__ import annotations
import contextlib
import errno
import hashlib
import json
import logging
@@ -165,11 +166,12 @@ def _fd_is_truly_unlinked(fd_path: str, watched_path: str) -> bool:
names — the guard keeps failing closed."""
try:
fd_stat = os.stat(fd_path)
except FileNotFoundError:
# The descriptor was closed after /proc was read. It cannot keep a
# retired generation alive, so do not turn this scan race into a halt.
return False
except OSError:
except OSError as exc:
# ENOENT: the descriptor was closed after /proc was read. ESRCH: the whole
# process exited mid-scan. Neither can keep a retired generation alive, so
# do not turn this scan race into a refusal (mirrors hermes_state_holders).
if exc.errno in (errno.ENOENT, errno.ESRCH):
return False
return True
return _identity_is_truly_unlinked((fd_stat.st_dev, fd_stat.st_ino), watched_path)
@@ -7,6 +7,7 @@ fail closed on both the open and write paths instead of creating the second
generation.
"""
import errno
import gc
import os
import sqlite3
@@ -160,8 +161,10 @@ def test_iter_holders_ignores_live_unhashed_dentry(tmp_path, force_wal, monkeypa
db.close()
def test_iter_holders_ignores_descriptor_closed_during_scan(tmp_path, monkeypatch):
"""A descriptor gone after ``readlink`` cannot hold a retired generation."""
@pytest.mark.parametrize("vanish_errno", [errno.ENOENT, errno.ESRCH])
def test_iter_holders_ignores_descriptor_closed_during_scan(tmp_path, monkeypatch, vanish_errno):
"""A descriptor (ENOENT) or its whole process (ESRCH) gone after ``readlink``
cannot hold a retired generation."""
path = tmp_path / "state.db"
wal = Path(str(path) + "-wal")
wal.write_bytes(b"current generation")
@@ -172,6 +175,14 @@ def test_iter_holders_ignores_descriptor_closed_during_scan(tmp_path, monkeypatc
"_iter_proc_fd_targets",
lambda: iter([(os.getpid(), str(wal) + " (deleted)", str(vanished_fd))]),
)
real_stat = os.stat
def stat_vanished(target, *args, **kwargs):
if str(target) == str(vanished_fd):
raise OSError(vanish_errno, os.strerror(vanish_errno), str(target))
return real_stat(target, *args, **kwargs)
monkeypatch.setattr(hermes_state_dbfile.os, "stat", stat_vanished)
assert iter_deleted_sqlite_sidecar_holders(path) == []