fix(state): hoist Restart Manager ctypes metadata to module level

`_windows_restart_manager_holders` rebuilt two ctypes Structure classes and
called `ctypes.POINTER()` on the fresh class every scan. POINTER memoises in
the process-global `ctypes._pointer_type_cache`, so each call pinned another
class set forever: probe measured 3 -> 221 cache entries over 200 calls. The
scan runs from auto-VACUUM admission and the FTS-rebuild guard in a long-lived
gateway, so the growth was monotonic. Structures, constants and argtypes now
live at module level (ctypes.wintypes imports on every platform); the
`ctypes.WinDLL("rstrtmgr")` load stays per call so the unit test can still
inject a fake rstrtmgr. Same probe after: 22 -> 22.

While here: one `_sqlite_family(base)` helper replaces the fourth hand-rolled
(db, -wal, -shm) triple in this module and the three existing ones; the
Windows lane computes abspath once instead of per holder; the RmGetList
sizing call and the retry loop collapse into one bounded loop (pass 1 sizes
with a NULL buffer, later passes re-size), which keeps the existing fake's
`apps is None` contract.
This commit is contained in:
kshitijk4poor
2026-09-24 15:49:44 +05:30
committed by kshitij
parent 36bab6f98d
commit cc5cf768fd

View File

@@ -7,11 +7,13 @@ SQLite connection factory needed by the final lock probe.
from __future__ import annotations
import ctypes
import errno
import logging
import os
import sqlite3
import sys
from ctypes import wintypes
from pathlib import Path
from typing import Callable, List, Optional, Sequence, Set, Tuple
@@ -285,10 +287,7 @@ def _argv_scoped_to_other_home(argv: Sequence[str], db_path: Path) -> bool:
db_path_str = os.path.abspath(os.fspath(db_path))
this_home = os.path.dirname(db_path_str)
install_root, our_profile = _store_install_layout(this_home)
sidecars = {
os.path.normcase(candidate)
for candidate in (db_path_str, db_path_str + "-wal", db_path_str + "-shm")
}
sidecars = {os.path.normcase(candidate) for candidate in _sqlite_family(db_path_str)}
this_home_norm = os.path.normcase(this_home)
root_norm = os.path.normcase(install_root) if install_root else None
path_tokens = _argv_path_tokens(argv)
@@ -321,55 +320,65 @@ def _argv_scoped_to_other_home(argv: Sequence[str], db_path: Path) -> bool:
return other_home_seen
# Restart Manager ctypes metadata lives at module level: ``ctypes.POINTER()`` on a freshly defined Structure
# pins it in ``ctypes._pointer_type_cache`` forever, so rebuilding these per scan leaked one class set per call.
_RM_ERROR_MORE_DATA = 234
_RM_SESSION_KEY_LEN = 33 # CCH_RM_SESSION_KEY + 1
class _RmUniqueProcess(ctypes.Structure):
_fields_ = [("pid", wintypes.DWORD), ("started", wintypes.FILETIME)]
class _RmProcessInfo(ctypes.Structure):
_fields_ = [
("process", _RmUniqueProcess),
("app_name", wintypes.WCHAR * 256), # CCH_RM_MAX_APP_NAME + 1
("service_name", wintypes.WCHAR * 64), # CCH_RM_MAX_SVC_NAME + 1
("app_type", wintypes.DWORD),
("app_status", wintypes.ULONG),
("ts_session_id", wintypes.DWORD),
("restartable", wintypes.BOOL),
]
_RM_ARGTYPES = {
"RmStartSession": [ctypes.POINTER(wintypes.DWORD), wintypes.DWORD, wintypes.LPWSTR],
"RmRegisterResources": [
wintypes.DWORD, wintypes.UINT, ctypes.POINTER(wintypes.LPCWSTR),
wintypes.UINT, ctypes.c_void_p, wintypes.UINT, ctypes.c_void_p,
],
"RmGetList": [
wintypes.DWORD, ctypes.POINTER(wintypes.UINT), ctypes.POINTER(wintypes.UINT),
ctypes.POINTER(_RmProcessInfo), ctypes.POINTER(wintypes.DWORD),
],
"RmEndSession": [wintypes.DWORD],
}
def _sqlite_family(base: str) -> Tuple[str, str, str]:
"""The main database file plus the ``-wal``/``-shm`` sidecars SQLite may hold alongside it."""
return (base, base + "-wal", base + "-shm")
def _windows_restart_manager_holders(db_path: Path) -> List[Tuple[int, str]]:
"""Return foreign processes using state.db or a WAL sidecar via Windows Restart Manager."""
import ctypes
from ctypes import wintypes
error_more_data = 234
max_app_name, max_svc_name = 255, 63
class _UniqueProcess(ctypes.Structure):
_fields_ = [("pid", wintypes.DWORD), ("started", wintypes.FILETIME)]
class _ProcessInfo(ctypes.Structure):
_fields_ = [
("process", _UniqueProcess),
("app_name", wintypes.WCHAR * (max_app_name + 1)),
("service_name", wintypes.WCHAR * (max_svc_name + 1)),
("app_type", wintypes.DWORD),
("app_status", wintypes.ULONG),
("ts_session_id", wintypes.DWORD),
("restartable", wintypes.BOOL),
]
# WinDLL per call (not memoised) so the unit test can inject a fake rstrtmgr through ctypes.WinDLL.
api = ctypes.WinDLL("rstrtmgr", use_last_error=True)
start, register, get_list, end = (
api.RmStartSession, api.RmRegisterResources, api.RmGetList, api.RmEndSession
)
start.argtypes = [ctypes.POINTER(wintypes.DWORD), wintypes.DWORD, wintypes.LPWSTR]
register.argtypes = [
wintypes.DWORD, wintypes.UINT, ctypes.POINTER(wintypes.LPCWSTR),
wintypes.UINT, ctypes.c_void_p, wintypes.UINT, ctypes.c_void_p,
]
get_list.argtypes = [
wintypes.DWORD, ctypes.POINTER(wintypes.UINT), ctypes.POINTER(wintypes.UINT),
ctypes.POINTER(_ProcessInfo), ctypes.POINTER(wintypes.DWORD),
]
end.argtypes = [wintypes.DWORD]
for fn in (start, register, get_list, end):
for name, fn in zip(_RM_ARGTYPES, (start, register, get_list, end)):
fn.argtypes = _RM_ARGTYPES[name]
fn.restype = wintypes.DWORD
resources = [
os.path.abspath(path)
for path in (os.fspath(db_path), os.fspath(db_path) + "-wal", os.fspath(db_path) + "-shm")
if os.path.exists(path)
]
db_abspath = os.path.abspath(os.fspath(db_path))
resources = [path for path in _sqlite_family(db_abspath) if os.path.exists(path)]
if not resources:
return []
session = wintypes.DWORD()
key = ctypes.create_unicode_buffer(33)
key = ctypes.create_unicode_buffer(_RM_SESSION_KEY_LEN)
rc = start(ctypes.byref(session), 0, key)
if rc:
raise OSError(rc, "RmStartSession failed")
@@ -379,28 +388,24 @@ def _windows_restart_manager_holders(db_path: Path) -> List[Tuple[int, str]]:
if rc:
raise OSError(rc, "RmRegisterResources failed")
needed, count, reasons = wintypes.UINT(), wintypes.UINT(), wintypes.DWORD()
rc = get_list(session, ctypes.byref(needed), ctypes.byref(count), None, ctypes.byref(reasons))
if rc == 0:
return []
if rc != error_more_data:
raise OSError(rc, "RmGetList failed")
# The process set can change between the sizing and data calls. Retry the bounded race.
for _ in range(3):
apps = (_ProcessInfo * needed.value)()
# Pass 1 sizes (ERROR_MORE_DATA); the process set can change before the data pass, so retry the
# bounded race with a re-sized buffer.
needed, reasons = wintypes.UINT(), wintypes.DWORD()
for _ in range(4):
apps = (_RmProcessInfo * needed.value)()
count = wintypes.UINT(needed.value)
rc = get_list(
session, ctypes.byref(needed), ctypes.byref(count), apps, ctypes.byref(reasons)
session, ctypes.byref(needed), ctypes.byref(count),
apps if needed.value else None, ctypes.byref(reasons),
)
if rc == 0:
own_pid = os.getpid()
return [
(int(apps[index].process.pid), os.path.abspath(os.fspath(db_path)))
(int(apps[index].process.pid), db_abspath)
for index in range(count.value)
if int(apps[index].process.pid) != own_pid
]
if rc != error_more_data:
if rc != _RM_ERROR_MORE_DATA:
raise OSError(rc, "RmGetList failed")
raise RuntimeError("Restart Manager holder set kept changing")
finally:
@@ -427,15 +432,11 @@ def foreign_state_db_holders(db_path: Path) -> List[Tuple[int, str]]:
# realpath, not abspath: psutil/libproc report the kernel-resolved pathname, so a symlinked
# HERMES_HOME would otherwise make every holder invisible and let maintenance proceed.
db_path_str = os.path.realpath(os.fspath(db_path))
watched = {
canonical_sqlite_path(db_path_str),
canonical_sqlite_path(db_path_str + "-wal"),
canonical_sqlite_path(db_path_str + "-shm"),
}
watched = {canonical_sqlite_path(candidate) for candidate in _sqlite_family(db_path_str)}
holders: List[Tuple[int, str]] = []
watched_ids: Set[Tuple[int, int]] = set()
db_dev: Optional[int] = None
for candidate in (db_path_str, db_path_str + "-wal", db_path_str + "-shm"):
for candidate in _sqlite_family(db_path_str):
try:
stat_result = os.stat(candidate)
except OSError as exc: