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 @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
- Keep published scan-result cache entries readable when concurrent or interrupted hits update access metadata.
- Preserve macOS scan-result cache entries during unrelated temporary-file churn while rejecting replaced source files and directories.
- Keep macOS scan-result caching enabled when hashing updates a model file's access time.

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

Expand Down
96 changes: 90 additions & 6 deletions modelaudit/cache/scan_results_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ def __init__(self, file_path: str, ancestor_identity: tuple[AncestorEntry, ...])
self._queue: Any = None
self._descriptors: list[int] = []
self._descriptor_stack: ExitStack | None = None
self._file_descriptor: int | None = None
self._attribute_flag = 0
self._event_error_flag = 0
self._unsafe_event_seen = False

try:
self._queue = select_module.kqueue()
Expand All @@ -237,6 +241,8 @@ def __init__(self, file_path: str, ancestor_identity: tuple[AncestorEntry, ...])
| select_module.KQ_NOTE_REVOKE
| select_module.KQ_NOTE_ATTRIB
)
self._attribute_flag = select_module.KQ_NOTE_ATTRIB
self._event_error_flag = getattr(select_module, "KQ_EV_ERROR", 0)
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)]
Expand Down Expand Up @@ -266,6 +272,7 @@ def __init__(self, file_path: str, ancestor_identity: tuple[AncestorEntry, ...])
try:
self._descriptor_stack = transferred_stack
self._descriptors = opened_descriptors
self._file_descriptor = opened_descriptors[0]
except BaseException:
with suppress(OSError):
transferred_stack.close()
Expand All @@ -274,8 +281,29 @@ def __init__(self, file_path: str, ancestor_identity: tuple[AncestorEntry, ...])
self.close()
raise

def changed(self) -> bool:
def discard_validated_file_attribute_event(self) -> None:
"""Discard only pending pure file attribute events after identity validation."""
if self._queue is None:
self._unsafe_event_seen = True
return

try:
events = self._queue.control(None, max(len(self._descriptors), 1), 0)
except (OSError, ValueError):
self._unsafe_event_seen = True
return

for event in events:
if (
getattr(event, "ident", None) != self._file_descriptor
or getattr(event, "fflags", None) != self._attribute_flag
or (self._event_error_flag and getattr(event, "flags", 0) & self._event_error_flag)
):
self._unsafe_event_seen = True
return

def changed(self) -> bool:
if self._unsafe_event_seen or self._queue is None:
return True
try:
return bool(self._queue.control(None, max(len(self._descriptors), 1), 0))
Expand All @@ -285,6 +313,7 @@ def changed(self) -> bool:
def close(self) -> None:
queue = getattr(self, "_queue", None)
self._queue = None
self._file_descriptor = None
descriptor_stack = getattr(self, "_descriptor_stack", None)
self._descriptor_stack = None
if queue is not None:
Expand Down Expand Up @@ -929,14 +958,29 @@ def store_result(
if self._get_file_change_token(file_path, file_stat) != expected_change_token:
logger.debug("Skipping cache store for %s: file change token changed during scan", file_path)
return False
if not self._ancestor_identity_matches_for_store(
expected_ancestor_identity,
self._capture_ancestor_identity(file_path),
):
logger.debug("Skipping cache store for %s: ancestor path changed during scan", file_path)
return False
self._discard_validated_file_attribute_event(expected_ancestor_identity)
settled_pre_hash_stat = os.stat(file_path)
if not self._stat_matches(settled_pre_hash_stat, expected_file_stat):
logger.debug("Skipping cache store for %s: file metadata changed while starting store", file_path)
return False
if self._get_file_change_token(file_path, settled_pre_hash_stat) != expected_change_token:
logger.debug("Skipping cache store for %s: file changed while starting store", file_path)
return False
if self._ancestor_monitor_changed(
expected_ancestor_identity
) or not self._ancestor_identity_matches_for_store(
expected_ancestor_identity,
self._capture_ancestor_identity(file_path),
):
logger.debug("Skipping cache store for %s: ancestor path changed during scan", file_path)
logger.debug("Skipping cache store for %s: ancestor path changed while starting store", file_path)
return False
file_stat = settled_pre_hash_stat

verified_current_hash = self.hasher.hash_file_with_stat(file_path, file_stat)
if verified_current_hash != expected_file_hash:
Expand All @@ -949,15 +993,29 @@ def store_result(
if self._get_file_change_token(file_path, post_hash_stat) != expected_change_token:
logger.debug("Skipping cache store for %s: file changed during verification", file_path)
return False
if not self._ancestor_identity_matches_for_store(
expected_ancestor_identity,
self._capture_ancestor_identity(file_path),
):
logger.debug("Skipping cache store for %s: ancestor path changed during verification", file_path)
return False
self._discard_validated_file_attribute_event(expected_ancestor_identity)
settled_post_hash_stat = os.stat(file_path)
if not self._stat_matches(settled_post_hash_stat, expected_file_stat):
logger.debug("Skipping cache store for %s: file metadata changed while settling monitor", file_path)
return False
if self._get_file_change_token(file_path, settled_post_hash_stat) != expected_change_token:
logger.debug("Skipping cache store for %s: file changed while settling monitor", file_path)
return False
if self._ancestor_monitor_changed(
expected_ancestor_identity
) or not self._ancestor_identity_matches_for_store(
expected_ancestor_identity,
self._capture_ancestor_identity(file_path),
):
logger.debug("Skipping cache store for %s: ancestor path changed during verification", file_path)
logger.debug("Skipping cache store for %s: ancestor path changed while settling monitor", file_path)
return False
file_stat = post_hash_stat
file_stat = settled_post_hash_stat

version_info = self._get_version_info(version_context)
if version_info is None:
Expand Down Expand Up @@ -1162,15 +1220,35 @@ def _capture_file_identity_leased(
if (
not self._stat_matches(initial_stat, verified_stat)
or initial_change_token != verified_change_token
or self._ancestor_monitor_changed(monitored_ancestor_identity)
or not self._ancestor_identity_matches_for_store(
monitored_ancestor_identity,
verified_ancestor_identity,
)
):
raise ValueError(f"File changed while capturing cache identity: {file_path}")

return verified_stat, content_hash, verified_change_token, monitored_ancestor_identity
self._discard_validated_file_attribute_event(monitored_ancestor_identity)
settled_stat = os.stat(file_path)
settled_change_token = self._get_file_change_token(file_path, settled_stat)
settled_ancestor_identity = self._capture_ancestor_identity(file_path)

if (
not self._stat_matches(initial_stat, settled_stat)
or initial_change_token != settled_change_token
or self._ancestor_monitor_changed(monitored_ancestor_identity)
or not self._ancestor_identity_matches_for_store(
monitored_ancestor_identity,
settled_ancestor_identity,
)
):
raise ValueError(f"File changed while settling cache identity monitor: {file_path}")

return (
settled_stat,
content_hash,
settled_change_token,
monitored_ancestor_identity,
)
except ValueError as exc:
self.release_ancestor_identity(monitored_ancestor_identity)
if str(exc).startswith("File changed while"):
Expand Down Expand Up @@ -1405,6 +1483,12 @@ def _ancestor_identity_matches_for_store(expected: AncestorIdentity, current: An
def _ancestor_monitor_changed(identity: AncestorIdentity) -> bool:
return identity.monitor is not None and identity.monitor.changed()

@staticmethod
def _discard_validated_file_attribute_event(identity: AncestorIdentity) -> None:
discard_event = getattr(identity.monitor, "discard_validated_file_attribute_event", None)
if callable(discard_event):
discard_event()

def _file_identity_matches(self, file_path: str, expected: ScannedFileIdentity) -> bool:
expected_stat, _expected_hash, expected_change_token, expected_ancestor_identity = expected
try:
Expand Down
Loading
Loading