mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Mechanism: since 1672cf0b03 delete_profile waits for the profile to be unowned through
wait_profile_unowned -> exclusive_maintenance -> ProfileOwnership.reserve, which opens
gateway.lock O_RDWR|O_CREAT. In a NixOS-style read-only copy (0555 dirs, 0444 files) that
raises FileNotFoundError/EACCES (no lock: the 0555 home refuses the create) or
PermissionError (0444 lock), the OSError escapes the OwnershipConflict handler, and the
delete stops with "Could not remove" before _rmtree_make_writable ever runs. Main's
delete had no reservation step and removed such copies.
Fix: when the reservation cannot even be attempted (OSError), probe the lock read-only: a
missing lock, or one our own flock through an O_RDONLY descriptor wins, has no holder; a
held one still blocks the delete exactly like an OwnershipConflict (fail closed; Windows
refuses since a sharing violation is a live holder there).
Test: tests/hermes_cli/test_profile_delete_read_only_copy.py::
test_read_only_copy_is_unowned_unless_its_lock_is_held[False/True] (real 0555/0444 tree,
real flock holder subprocess); on r13-base2 both cases raise.
Review: dokterdok N106
90 lines
3.5 KiB
Python
90 lines
3.5 KiB
Python
"""Profile directory removal: wait out the live owner, then rmtree with a short retry."""
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import stat
|
|
import time
|
|
|
|
# A multiplexer answers a delete's rescan ``pending`` after 5 s and keeps tearing the profile down;
|
|
# its gateway.lock reservation is released last, once the profile's writers are gone.
|
|
OWNER_RELEASE_WAIT_SECONDS = 60.0
|
|
|
|
|
|
def wait_profile_unowned(profile_dir: Path, *, timeout: float | None = None) -> bool:
|
|
"""True once no gateway runtime holds ``profile_dir``'s ``gateway.lock`` (taken and released
|
|
through ``exclusive_maintenance``); False when it is still held after ``timeout`` seconds."""
|
|
from gateway.runtime_ownership import OwnershipConflict, exclusive_maintenance
|
|
deadline = time.monotonic() + (OWNER_RELEASE_WAIT_SECONDS if timeout is None else timeout)
|
|
while True:
|
|
try:
|
|
with exclusive_maintenance([profile_dir]):
|
|
return True
|
|
except OwnershipConflict:
|
|
pass
|
|
except OSError:
|
|
# A read-only copy (0555 home, 0444 lock) cannot host a reservation: probe it instead.
|
|
if _lock_unheld_read_only(profile_dir / 'gateway.lock'):
|
|
return True
|
|
if time.monotonic() >= deadline:
|
|
return False
|
|
time.sleep(0.25)
|
|
|
|
|
|
def _lock_unheld_read_only(path: Path) -> bool:
|
|
"""A missing lock, or one our own flock through a read-only descriptor wins, has no holder."""
|
|
if os.name == 'nt':
|
|
return False # a sharing violation there is a live holder
|
|
import fcntl
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | os.O_NONBLOCK)
|
|
except FileNotFoundError:
|
|
return True
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _rmtree_make_writable(func, path, exc):
|
|
"""onexc/onerror handler: add +w on PermissionError so rmtree can proceed. Covers NixOS-
|
|
style read-only copies where the path itself (0444) or its parent (0555) isn't writable."""
|
|
# onexc(func, path, exc_instance) on 3.12+; onerror(func, path, exc_info_tuple) on 3.11.
|
|
if isinstance(exc, tuple):
|
|
exc = exc[1]
|
|
if not isinstance(exc, PermissionError):
|
|
raise
|
|
for target in (path, os.path.dirname(path)): # parent needed for unlink/rmdir
|
|
if target:
|
|
with contextlib.suppress(OSError):
|
|
os.chmod(target, os.stat(target).st_mode | stat.S_IWUSR)
|
|
func(path)
|
|
|
|
|
|
def _rmtree_with_retry(profile_dir: Path, onexc_handler) -> None:
|
|
"""``shutil.rmtree`` with a short retry loop: a just-terminated process can leave in-flight
|
|
writes (SQLite -wal/-shm checkpoints, sandbox temp files) landing after rmtree walked
|
|
past a directory — ENOTEMPTY on POSIX, transient PermissionError on Windows."""
|
|
attempts = 3
|
|
last_exc: OSError | None = None
|
|
for attempt in range(attempts):
|
|
try:
|
|
try:
|
|
shutil.rmtree(profile_dir, onexc=onexc_handler)
|
|
except TypeError: # ``onexc`` is 3.12+; 3.11 has ``onerror``
|
|
shutil.rmtree(profile_dir, onerror=onexc_handler)
|
|
return
|
|
except OSError as e:
|
|
last_exc = e
|
|
if not profile_dir.exists():
|
|
return
|
|
if attempt < attempts - 1:
|
|
time.sleep(0.3 * (attempt + 1))
|
|
if last_exc is not None:
|
|
raise last_exc
|