From 7feaf03883d15e4be7f6c7285d14684256a6013e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:17:42 +0530 Subject: [PATCH] fix(state): treat ESRCH like ENOENT in deleted-WAL fd identity check `_fd_is_truly_unlinked` stats `/proc//fd/` 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. --- hermes_state_dbfile.py | 12 +++++++----- .../test_deleted_wal_generation_guard.py | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/hermes_state_dbfile.py b/hermes_state_dbfile.py index b0acf2141f..1aaa326005 100644 --- a/hermes_state_dbfile.py +++ b/hermes_state_dbfile.py @@ -10,6 +10,7 @@ call time, so tests that monkeypatch ``hermes_state.`` 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) diff --git a/tests/hermes_state/test_deleted_wal_generation_guard.py b/tests/hermes_state/test_deleted_wal_generation_guard.py index bfa16643ee..ae4cfc1710 100644 --- a/tests/hermes_state/test_deleted_wal_generation_guard.py +++ b/tests/hermes_state/test_deleted_wal_generation_guard.py @@ -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) == []