Files
hermes-agent/tests/pm/test_generation_durability.py
ethernet 97b0ab42e8 fix(pm): flush a generation tree before its selection record names it
The selection record (the install's facts.json, a side environment's
active.json) is already written last, atomically and fsynced, so it is the
generation's completion marker. But nothing flushed the tree it vouches for
first: a power loss mid-publish could persist the record while the venv's
files were still in the page cache, and the next boot would select a
half-written environment whose pyvenv.cfg happened to exist.

Both generation publishers (Venv via sync_venv, and _ensure_generation for
side environments) now call pm.environments.flush_before_selecting() after
the tree is complete and before the record is written: one os.sync() rather
than an fsync per file, which would cost minutes for a venv. Windows has no
os.sync; there the atomic record is the only guarantee, documented in place.

Reusing the existing record instead of adding a new marker file means no
migration: generations published by earlier builds stay selectable, and no
reader needs a legacy rule. Every reader of the selection (selected_venv,
environment_python, and their callers) already requires the record.
2026-09-24 11:50:30 -04:00

75 lines
3.0 KiB
Python

"""A generation's selection record is its completion marker, so it may only be
written after the finished tree has been flushed to disk. Otherwise a power loss
mid-publish can persist the (fsynced) record while the venv's files are still in
the page cache, and the next boot selects a half-written environment.
"""
from __future__ import annotations
import os
def _flush_probe(monkeypatch, observe):
"""Replace the filesystem-wide flush with one that records what was on disk
when it ran; ``raising=False`` so the contract is exercised on Windows too."""
seen: list = []
monkeypatch.setattr(os, "sync", lambda: seen.append(observe()), raising=False)
return seen
def test_install_selection_is_committed_only_after_its_tree_is_flushed(tmp_path, monkeypatch):
import pm.install as install
from pm import paths
from pm.environments import install_state_dir, runtime_facts_path, selected_venv
store = tmp_path / "tools"
store.mkdir()
monkeypatch.setattr("pm.paths.store_root", lambda: store)
repo = tmp_path / "repo"
repo.mkdir()
monkeypatch.setattr(paths, "repo_root", lambda: repo)
venv = install.get_package("venv")
monkeypatch.setattr(venv, "expected_stamp", lambda extras, **kwargs: "stamp")
candidate = install_state_dir(repo) / "environments" / "fresh" / "venv"
def apply(*args, **kwargs):
candidate.mkdir(parents=True)
(candidate / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8")
return {"environment": candidate}
monkeypatch.setattr(venv, "apply", apply)
def committed() -> bool:
return runtime_facts_path(repo).is_file() and selected_venv(repo) == candidate.resolve()
flushes = _flush_probe(monkeypatch, lambda: (candidate / "pyvenv.cfg").is_file() and not committed())
install.sync_venv([], explicit=True)
assert flushes == [True], "the tree must be flushed after it is complete and before it is selected"
assert selected_venv(repo) == candidate.resolve()
def test_side_environment_selection_is_committed_only_after_its_tree_is_flushed(tmp_path, monkeypatch):
from pm import _uv, operations
base = tmp_path / "base" / "python"
base.parent.mkdir()
base.touch()
monkeypatch.setattr(_uv, "_toolchain", lambda **kwargs: (None, base))
root = tmp_path / "side"
built: list = []
def build(generation, base_python):
environment = generation / "venv"
python = operations._python(environment)
python.parent.mkdir(parents=True)
python.touch()
(environment / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8")
built.append(environment)
return python
flushes = _flush_probe(monkeypatch, lambda: bool(built) and not (root / "active.json").exists())
python = operations._ensure_generation("proof", root, {}, build, record={}, explicit=True)
assert flushes == [True], "the tree must be flushed after it is complete and before it is selected"
assert operations.environment_python("proof", root=root) == python