Files
hermes-agent/pm/filesystem.py
teknium1 eb7e862032 fix(pm): confine the read-only retry to Windows unlink/rmdir failures
Review of #131847: the retry ran for every rmtree failure on every OS.
On POSIX an unreadable directory hands the callback os.open, and
func(path) raised TypeError (missing flags), which escaped the OSError
set-aside path and failed an install that had already published. And
os.chmod followed a symlink inside an unwritable directory, rewriting
the mode of a file outside the tree to 0200.

The retry now only fires for a PermissionError from os.unlink/os.rmdir
on Windows, and clears the link's own attribute where chmod can follow
links; everything else raises as plain rmtree does.
2026-10-03 01:11:10 -07:00

106 lines
3.9 KiB
Python

"""Stdlib-only filesystem primitives PM needs before it can replace the caller's interpreter.
Boot-time dependency selection (``hermes_cli.runtime_state``) imports these, so nothing here
may import a dependency or another PM module.
"""
from __future__ import annotations
import errno
import hashlib
import os
from pathlib import Path
import shutil
import stat
import sys
import tempfile
import time
_LOCK_POLL_SECONDS = 0.05
def is_junction(path: Path) -> bool:
"""Keep junctions opaque even before Python 3.12's Path.is_junction exists."""
return os.name == "nt" and path.lstat().st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT
def lock_fd(fd: int, *, wait: bool, timeout: float | None = None) -> bool:
"""Take the byte lock; ``timeout`` bounds the retry loop (None waits forever, 0 tries once)."""
deadline = None if timeout is None else time.monotonic() + timeout
if os.name == "nt":
import msvcrt
while True:
try:
os.lseek(fd, 0, os.SEEK_SET)
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
return True
except OSError as exc:
if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
raise
if not wait or (deadline is not None and time.monotonic() >= deadline):
return False
time.sleep(_LOCK_POLL_SECONDS)
else:
import fcntl
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except BlockingIOError:
pass
if not wait or (deadline is not None and time.monotonic() >= deadline):
return False
time.sleep(_LOCK_POLL_SECONDS)
def read_bytes_or_none(path: Path) -> bytes | None:
try:
return path.read_bytes()
except FileNotFoundError:
return None
def file_digest(path: Path) -> str | None:
data = read_bytes_or_none(path)
return hashlib.sha256(data).hexdigest() if data is not None else None
def durable_write_bytes(path: Path, data: bytes) -> None:
"""Replace ``path`` atomically and fsync file and directory so a crash keeps old or new bytes."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".publish-")
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
if os.name != "nt":
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
Path(temporary).unlink(missing_ok=True)
def remove_tree(path: Path) -> None:
"""``shutil.rmtree`` that clears a Windows read-only attribute and retries that one unlink.
Windows refuses to unlink read-only files (PortableGit ships ``etc/hosts`` read-only), and no
retry clears that bit. Every other failure, and every POSIX failure, raises as plain rmtree
would: a POSIX permission error is about the parent directory, and chmodding the path there
would follow a symlink out of the tree. Lives here, not in ``hermes_cli``: PM installs run
before the rest of the tree exists (the Docker toolchain stage copies only ``pm/``).
"""
def _retry_writable(func, failed, error):
error = error[1] if isinstance(error, tuple) else error
if os.name != "nt" or func not in (os.unlink, os.rmdir) or not isinstance(error, PermissionError):
raise error
# Windows >= 3.13 chmod follows links; the attribute to clear is the link's own.
os.chmod(failed, stat.S_IWRITE, **({"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {}))
func(failed)
handler = {"onexc": _retry_writable} if sys.version_info >= (3, 12) else {"onerror": _retry_writable}
shutil.rmtree(path, **handler)