Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prevent Windows cache identity probes from creating locked temporary files inside scanned directories.
- Preserve locked Windows cache probes reached through directory aliases while clearing stale scan results.
- Place cross-volume Windows cache identity probes near the volume root instead of the nearest ancestor of the scanned path, so a probe can no longer appear inside a directory tree that a concurrent scan is walking.
- Preserve macOS scan-result cache entries during unrelated temporary-file churn while rejecting replaced source files and directories.

## [0.2.52](https://github.com/promptfoo/modelaudit/compare/v0.2.51...v0.2.52) (2026-07-22)

Expand Down
98 changes: 93 additions & 5 deletions modelaudit/cache/scan_results_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import json
import logging
import os
import select
import stat
import struct
import sys
import tempfile
import threading
import time
from collections.abc import Callable, Iterable
from contextlib import suppress
from contextlib import ExitStack, suppress
from contextvars import ContextVar
from dataclasses import asdict, dataclass
from importlib.machinery import (
Expand Down Expand Up @@ -217,6 +218,87 @@ def __del__(self) -> None:
self.close()


class _DarwinPathMonitor:
"""Observe vnode replacement without rejecting unrelated directory writes."""

def __init__(self, file_path: str, ancestor_identity: tuple[AncestorEntry, ...]) -> None:
select_module: Any = select
self._queue: Any = None
self._descriptors: list[int] = []
self._descriptor_stack: ExitStack | None = None

try:
self._queue = select_module.kqueue()
vnode_filter = select_module.KQ_FILTER_VNODE
event_flags = select_module.KQ_EV_ADD | select_module.KQ_EV_CLEAR
change_flags = (
select_module.KQ_NOTE_DELETE
| select_module.KQ_NOTE_RENAME
| select_module.KQ_NOTE_REVOKE
| select_module.KQ_NOTE_ATTRIB
)
descriptor_flags = getattr(os, "O_EVTONLY", os.O_RDONLY) | getattr(os, "O_CLOEXEC", 0)
descriptor_flags |= getattr(os, "O_NOFOLLOW", 0)
paths = [os.path.abspath(file_path), *(entry[0] for entry in ancestor_identity)]
with ExitStack() as descriptor_stack:
events = []
opened_descriptors = []
for path in dict.fromkeys(paths):
watched_path = _DARWIN_STABLE_SYMLINK_ALIASES.get(path, path)
descriptor = os.open(watched_path, descriptor_flags)
try:
descriptor_stack.callback(os.close, descriptor)
except BaseException:
with suppress(OSError):
os.close(descriptor)
raise
opened_descriptors.append(descriptor)
events.append(
select_module.kevent(
descriptor,
filter=vnode_filter,
flags=event_flags,
fflags=change_flags,
)
)
self._queue.control(events, 0, 0)
transferred_stack = descriptor_stack.pop_all()
try:
self._descriptor_stack = transferred_stack
self._descriptors = opened_descriptors
except BaseException:
with suppress(OSError):
transferred_stack.close()
raise
except BaseException:
self.close()
raise

def changed(self) -> bool:
if self._queue is None:
return True
try:
return bool(self._queue.control(None, max(len(self._descriptors), 1), 0))
except (OSError, ValueError):
return True

def close(self) -> None:
queue = getattr(self, "_queue", None)
self._queue = None
descriptor_stack = getattr(self, "_descriptor_stack", None)
self._descriptor_stack = None
if queue is not None:
with suppress(OSError):
queue.close()
if descriptor_stack is not None:
with suppress(OSError):
descriptor_stack.close()
self._descriptors = []

def __del__(self) -> None:
self.close()


class _WindowsPathLockMonitor:
"""Prevent file and ancestor replacement while a Windows scan is in flight."""

Expand Down Expand Up @@ -280,12 +362,12 @@ def __del__(self) -> None:


class AncestorIdentity(tuple[AncestorEntry, ...]):
monitor: _AncestorPathMonitor | _WindowsPathLockMonitor | None
monitor: _AncestorPathMonitor | _DarwinPathMonitor | _WindowsPathLockMonitor | None

def __new__(
cls,
entries: tuple[AncestorEntry, ...] | list[AncestorEntry],
monitor: _AncestorPathMonitor | _WindowsPathLockMonitor | None = None,
monitor: _AncestorPathMonitor | _DarwinPathMonitor | _WindowsPathLockMonitor | None = None,
) -> "AncestorIdentity":
identity = super().__new__(cls, entries)
identity.monitor = monitor
Expand Down Expand Up @@ -610,6 +692,10 @@ def _get_cached_result_with_identity(
logger.debug(f"Cache lookup failed for {file_path}: {e}")
self._record_cache_miss("error")
return None, file_identity
except BaseException:
if file_identity is not None:
self.release_ancestor_identity(file_identity[-1])
raise

def get_cached_result_by_key(
self,
Expand Down Expand Up @@ -1076,7 +1162,7 @@ def _capture_file_identity_leased(
time.sleep(0.01)
continue
raise
except Exception:
except BaseException:
self.release_ancestor_identity(monitored_ancestor_identity)
raise

Expand Down Expand Up @@ -1329,13 +1415,15 @@ def _monitor_ancestor_identity(file_path: str, identity: AncestorIdentity) -> An
if not identity:
return identity
try:
monitor: _AncestorPathMonitor | _WindowsPathLockMonitor | None = None
monitor: _AncestorPathMonitor | _DarwinPathMonitor | _WindowsPathLockMonitor | None = None
platform_name = getattr(sys, "platform", "")
if platform_name.startswith("linux"):
monitor = _AncestorPathMonitor(
file_path,
tuple(identity),
)
elif platform_name == "darwin":
monitor = _DarwinPathMonitor(file_path, tuple(identity))
elif platform_name == "win32":
monitor = _WindowsPathLockMonitor(file_path, tuple(identity))
if monitor is None:
Expand Down
Loading
Loading