deer-flow/backend/tests/test_config_atomic_cleanup.py
llb555 95ffb29e50
fix(config): keep atomic-write cleanup from replacing the caller's outcome (#7279)
* fix(config): keep atomic-write cleanup from replacing the caller's outcome

* docs(config): state the cleanup invariant for every exit path
2026-10-25 19:23:44 +08:00

93 lines
3.8 KiB
Python

"""Cleanup failures in the secret-file and managed-model writers.
Both writers stage a temporary file and then discard it. That removal runs on
every exit path, not only after a successful publication: it also runs when the
write, the close, or the replacement already failed. On Windows a scanner, a peer
holding the handle, or a read-only mount can make the removal itself fail, so it
has to stay best-effort — a cleanup error must never replace the outcome the
caller needed, whether that outcome is a published value or an earlier error.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from deerflow.config.managed_models import ManagedModelStore
from deerflow.config.secret_file import read_or_create_secret_file
def _fail_unlink_where(predicate: Callable[[Path], bool], monkeypatch: pytest.MonkeyPatch) -> list[Path]:
"""Make ``Path.unlink`` fail for matching paths and record the attempts."""
real_unlink = Path.unlink
attempts: list[Path] = []
def unlink(self: Path, missing_ok: bool = False) -> None:
if predicate(self):
attempts.append(self)
raise PermissionError(13, "The process cannot access the file", str(self))
real_unlink(self, missing_ok=missing_ok)
monkeypatch.setattr(Path, "unlink", unlink)
return attempts
def test_secret_creation_survives_a_failing_temporary_removal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
path = tmp_path / ".secret"
attempts = _fail_unlink_where(lambda candidate: candidate.suffix == ".tmp", monkeypatch)
value = read_or_create_secret_file(path, lambda: "generated-value")
assert value == "generated-value"
assert path.read_text(encoding="utf-8") == "generated-value"
assert attempts, "the temporary file should have been handed to the cleanup helper"
def test_secret_creation_keeps_the_replace_error_when_cleanup_also_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
path = tmp_path / ".secret"
path.write_text("", encoding="utf-8") # an abandoned empty file takes the claim path
def no_hard_links(*_args, **_kwargs):
raise OSError(95, "Operation not supported")
def failing_replace(*_args, **_kwargs):
raise OSError(5, "publish failed")
monkeypatch.setattr("deerflow.config.secret_file.os.link", no_hard_links)
monkeypatch.setattr("deerflow.config.secret_file.os.replace", failing_replace)
attempts = _fail_unlink_where(lambda candidate: candidate.suffix == ".replacing", monkeypatch)
with pytest.raises(OSError) as raised:
read_or_create_secret_file(path, lambda: "generated-value")
assert "publish failed" in str(raised.value)
assert attempts, "the unpublished claim should still have been cleaned up"
def test_managed_model_write_survives_a_failing_temporary_removal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
target = tmp_path / "managed.json"
attempts = _fail_unlink_where(lambda candidate: candidate.parent == tmp_path and candidate != target, monkeypatch)
ManagedModelStore._write(target, b"payload")
assert target.read_bytes() == b"payload"
assert attempts
def test_managed_model_write_keeps_the_replace_error_when_cleanup_also_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
target = tmp_path / "managed.json"
def failing_replace(*_args, **_kwargs):
raise OSError(5, "publish failed")
monkeypatch.setattr("deerflow.config.managed_models.os.replace", failing_replace)
attempts = _fail_unlink_where(lambda candidate: candidate.parent == tmp_path and candidate != target, monkeypatch)
with pytest.raises(OSError) as raised:
ManagedModelStore._write(target, b"payload")
assert "publish failed" in str(raised.value)
assert attempts