fix(pm): re-activate from the shebang only when an input's mtime differs

The _hermes-python prologue compared uv.lock / pyproject.toml / pm/lock.json
against facts.json with `-nt`. facts.json is only rewritten on a real sync,
so a checkout that rewrites an input without changing it left the input
newer forever: every run from an activated shell re-sourced activate
(~370ms instead of ~13ms).

pm now records, after every successful full-closure install (no-op syncs
included), a stamp per input under installs/<key>/inputs carrying the exact
mtime the install was verified against, snapshotted before installing so
an input edited mid-install still reads as stale. The prologue re-activates
when any input's mtime differs from its stamp in either direction (branch
switches can move it backwards), when an input is missing, or when there
are no stamps. It globs the stamp dir, so pm owns the only input list.

Also read pyvenv.cfg as utf-8-sig (footgun lane, same file).
This commit is contained in:
ethernet
2026-09-23 13:38:54 -04:00
parent 95b005fde3
commit b16f953f7c
5 changed files with 128 additions and 60 deletions

View File

@@ -177,6 +177,12 @@ def cmd_install(args) -> int:
print("✗ --extra syncs this install's venv and cannot combine with --target") print("✗ --extra syncs this install's venv and cannot combine with --target")
return 1 return 1
names = args.names if args.names or extras else source_install_packages(_lockfile().names()) names = args.names if args.names or extras else source_install_packages(_lockfile().names())
# Only the whole default closure verifies everything an activated shell
# composes from, so only it advances the prologue's input stamps.
full_closure = not (args.names or tools_only or cross_target)
from pm.environments import activation_input_mtimes
input_mtimes = activation_input_mtimes(repo_root()) if full_closure else {}
# Tools before the venv. A bare `pm install` used to install tools and # Tools before the venv. A bare `pm install` used to install tools and
# sync the venv in one breath, so a native build (Windows ARM64 source # sync the venv in one breath, so a native build (Windows ARM64 source
# wheels) resolved compilers and git from the host PATH. Publish every # wheels) resolved compilers and git from the host PATH. Publish every
@@ -208,6 +214,10 @@ def cmd_install(args) -> int:
except InstallError as e: except InstallError as e:
print(f"✗ {e}") print(f"✗ {e}")
failed += 1 failed += 1
if full_closure and not failed:
from pm.environments import activation_inputs_dir, record_activation_inputs
record_activation_inputs(activation_inputs_dir(repo_root()), input_mtimes)
return 1 if failed else 0 return 1 if failed else 0

View File

@@ -39,6 +39,41 @@ def runtime_facts_path(project_root: Path) -> Path:
return install_state_dir(project_root) / "facts.json" return install_state_dir(project_root) / "facts.json"
# The files that decide the dependency set. `scripts/_hermes-python` re-activates
# when any of them differs in mtime from its stamp under activation_inputs_dir.
ACTIVATION_INPUTS = ("uv.lock", "pyproject.toml", "pm/lock.json")
def activation_inputs_dir(project_root: Path) -> Path:
"""Beside facts.json, so the prologue finds it from ``$__HERMES_ACTIVATED``."""
return install_state_dir(project_root) / "inputs"
def activation_input_mtimes(project_root: Path) -> dict[str, int]:
"""Snapshot before installing, so an input edited mid-install records its
pre-install mtime and the next run re-activates."""
root = Path(project_root)
return {name: (root / name).stat().st_mtime_ns for name in ACTIVATION_INPUTS if (root / name).is_file()}
def record_activation_inputs(stamps: Path, mtimes: dict[str, int]) -> None:
"""Give each stamp the exact mtime of the input the install was verified against.
Recorded on every successful install, including no-op syncs: a checkout that
rewrites an input without changing it moves the mtime, and only this record
brings the stamp back to equal. The prologue compares for equality, not order,
because switching branches can move an input's mtime in either direction.
"""
import shutil
shutil.rmtree(stamps, ignore_errors=True)
for name, mtime in mtimes.items():
stamp = stamps / name
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.touch()
os.utime(stamp, ns=(mtime, mtime))
def base_venv(project_root: Path) -> Path: def base_venv(project_root: Path) -> Path:
root = Path(project_root).resolve() root = Path(project_root).resolve()
manifest_path = root.parent / "manifest.json" manifest_path = root.parent / "manifest.json"
@@ -138,7 +173,7 @@ def venv_python_version(venv: Path) -> tuple[int, int] | None:
tree, and failed *after* a successful update. tree, and failed *after* a successful update.
""" """
try: try:
for line in (venv / "pyvenv.cfg").read_text(encoding="utf-8").splitlines(): for line in (venv / "pyvenv.cfg").read_text(encoding="utf-8-sig").splitlines():
key, _, value = line.partition("=") key, _, value = line.partition("=")
if key.strip() != "version": if key.strip() != "version":
continue continue
@@ -253,12 +288,10 @@ def activation_environment(project_root: Path) -> dict[str, str]:
env.pop("VIRTUAL_ENV", None) env.pop("VIRTUAL_ENV", None)
env["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)]) env["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)])
# The child-process sentinel. Its VALUE is the installed-state file this # The child-process sentinel. Its VALUE is the installed-state file this
# environment was composed against, so a consumer gets three things for # environment was composed against, so a consumer learns that it inherited
# free: that it inherited an activated shell, which checkout/profile that # an activated shell and which checkout/profile that shell came from. Its
# shell came from, and a staleness stamp — uv.lock / pyproject.toml / # directory also holds activation_inputs_dir, the input-mtime stamps
# pm/lock.json newer than this file means the shell's environment predates # `scripts/_hermes-python` compares against to decide staleness.
# its inputs. pm rewrites it on every real sync and no-ops otherwise, so a
# `-nt` comparison settles back to "current" after one re-activation.
env["__HERMES_ACTIVATED"] = str(runtime_facts_path(project_root)) env["__HERMES_ACTIVATED"] = str(runtime_facts_path(project_root))
return env return env

View File

@@ -33,20 +33,22 @@ binds it to ``$0`` — so the prologue finds the target relative to itself and
the shebang stays independent of the cwd. ``$BASH`` for the second hop keeps it the shebang stays independent of the cwd. ``$BASH`` for the second hop keeps it
free of both the exec bit and a ``PATH`` lookup. free of both the exec bit and a ``PATH`` lookup.
Activation is not re-run when the inherited environment is still current: the Activation is not re-run when the inherited environment is still current. pm
sentinel's value is the installed-state file the environment was composed records one stamp per dependency input (``uv.lock``, ``pyproject.toml``,
against (see ``pm.environments.activation_environment``), so the ``pm/lock.json``) beside the installed-state file that ``__HERMES_ACTIVATED``
prologue compares it against the inputs that decide the dependency set. Any of names, each carrying the exact mtime that input had when the install was last
``uv.lock``, ``pyproject.toml`` or ``pm/lock.json`` being newer than that file verified against it (``pm.environments.record_activation_inputs``). The
means the inherited environment predates its inputs, and the prologue prologue re-activates when any input's mtime *differs* from its stamp: newer
re-activates once. ``[ -nt ]`` is a bash builtin, so the check costs no process or older, since switching branches can move it either way. ``[ -nt ]`` and
spawn; pm rewrites that file on every real sync and no-ops otherwise, so it ``[ -ot ]`` are bash builtins, so the check costs no process spawn. Every
settles back to current rather than re-syncing on every run. successful activation records again, including no-op syncs, so a checkout that
touches an input without changing it costs one re-activation and then settles.
Two constraints worth knowing: the line is 83 bytes (a shebang must stay under Two constraints worth knowing: the line is 83 bytes (a shebang must stay under
127), and ``/usr/bin/env -S`` is GNU and newer-BSD — verify it if an older 127), and ``/usr/bin/env -S`` is GNU and newer-BSD — verify it if an older
macOS or BSD is a target. Windows keeps using ``python scripts\\foo.py`` with macOS or BSD is a target. macOS's stock bash 3.2 compares whole seconds, so an
the guard. input rewritten within the same second as its recorded mtime goes unnoticed
there. Windows keeps using ``python scripts\\foo.py`` with the guard.
""" """
from __future__ import annotations from __future__ import annotations

View File

@@ -24,22 +24,30 @@ if [ ! -f "$_here/activate" ]; then
exit 1 exit 1
fi fi
# __HERMES_ACTIVATED holds the installed-state file the environment was built # pm records, beside the installed-state file named by __HERMES_ACTIVATED, one
# against, so `-nt` against the inputs that decide the dependency set answers # stamp per dependency input carrying the exact mtime that input had when the
# "is the inherited environment stale". `[ -nt ]` is a bash builtin — no # install was last verified against it (pm.environments.record_activation_inputs).
# process spawn — and pm rewrites that file on every real sync while no-oping # Any input whose mtime DIFFERS from its stamp — newer or older, since a branch
# otherwise, so one re-activation settles this back to current rather than # switch can move it either way — means the inherited environment may not match
# re-syncing on every run. A path that no longer exists (or a legacy literal # its inputs. `-nt`/`-ot` are bash builtins, so this costs no process spawn, and
# "1") fails `-e` and activates. # every successful activation re-records, so one re-activation settles it. No
# stamps (an install from before stamps existed), a missing input, a dangling
# sentinel or a legacy literal "1" all activate.
_needs_activation=1 _needs_activation=1
if [ -n "${__HERMES_ACTIVATED:-}" ] && [ -e "${__HERMES_ACTIVATED}" ]; then if [ -n "${__HERMES_ACTIVATED:-}" ] && [ -e "${__HERMES_ACTIVATED}" ]; then
_stamps="${__HERMES_ACTIVATED%/*}/inputs"
_needs_activation=0 _needs_activation=0
for _input in uv.lock pyproject.toml pm/lock.json; do _stamped=0
if [ "$_here/$_input" -nt "$__HERMES_ACTIVATED" ]; then for _stamp in "$_stamps"/* "$_stamps"/*/*; do
[ -f "$_stamp" ] || continue
_stamped=1
_input="$_here/${_stamp#"$_stamps"/}"
if [ ! -e "$_input" ] || [ "$_input" -nt "$_stamp" ] || [ "$_input" -ot "$_stamp" ]; then
_needs_activation=1 _needs_activation=1
break break
fi fi
done done
[ "$_stamped" = 1 ] || _needs_activation=1
fi fi
if [ "$_needs_activation" = 1 ]; then if [ "$_needs_activation" = 1 ]; then

View File

@@ -1,11 +1,14 @@
"""The self-activating shebang prologue: when does it re-activate? """The self-activating shebang prologue: when does it re-activate?
``scripts/_hermes-python`` is the POSIX shebang target. Its one decision worth ``scripts/_hermes-python`` is the POSIX shebang target. Its one decision worth
pinning is staleness: ``__HERMES_ACTIVATED`` holds the installed-state file the pinning is staleness: pm stamps each dependency input's mtime beside the
environment was built against, so any of ``uv.lock`` / ``pyproject.toml`` / installed-state file ``__HERMES_ACTIVATED`` names, and any input whose mtime
``pm/lock.json`` being newer than that file means the inherited environment differs from its stamp means the inherited environment may not match its
predates its inputs. Invert that either way and the cost is invisible — a inputs. Invert that either way and the cost is invisible — a re-sync on every
re-sync on every run, or a stale environment that looks fine. run, or a stale environment that looks fine.
The stamps come from the real ``pm.environments`` writer, so these tests pin
the contract between what pm records and what the prologue reads.
A ``python3`` shim goes on PATH because the prologue execs ``python3`` by name, A ``python3`` shim goes on PATH because the prologue execs ``python3`` by name,
which does not exist on stock Windows; the subject here is the staleness which does not exist on stock Windows; the subject here is the staleness
@@ -23,13 +26,15 @@ from pathlib import Path
import pytest import pytest
from pm.environments import ACTIVATION_INPUTS, activation_input_mtimes, record_activation_inputs
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
PROLOGUE = REPO_ROOT / "scripts" / "_hermes-python" PROLOGUE = REPO_ROOT / "scripts" / "_hermes-python"
EARLIER = "2018-01-01 00:00:00"
LONG_AGO = "2019-01-01 00:00:00" LONG_AGO = "2019-01-01 00:00:00"
STAMP_TIME = "2020-06-01 00:00:00" STAMP_TIME = "2020-06-01 00:00:00"
JUST_AFTER = "2021-01-01 00:00:00" JUST_AFTER = "2021-01-01 00:00:00"
INPUTS = ("uv.lock", "pyproject.toml", "pm/lock.json")
def _posix(path: Path) -> str: def _posix(path: Path) -> str:
@@ -55,16 +60,19 @@ def _set_mtime(path: Path, stamp: str) -> None:
@pytest.fixture @pytest.fixture
def checkout(tmp_path: Path) -> Path: def checkout(tmp_path: Path) -> Path:
"""A repo-shaped tree whose stub activate announces each sourcing.""" """A repo-shaped tree whose stub activate announces each sourcing; its
installed state lives in ``state/`` (facts.json beside the input stamps)."""
root = tmp_path / "checkout" root = tmp_path / "checkout"
(root / "scripts").mkdir(parents=True) (root / "scripts").mkdir(parents=True)
(root / "pm").mkdir()
(root / "shim").mkdir() (root / "shim").mkdir()
(root / "state").mkdir()
shutil.copy2(PROLOGUE, root / "scripts" / PROLOGUE.name) shutil.copy2(PROLOGUE, root / "scripts" / PROLOGUE.name)
for name in ("uv.lock", "pyproject.toml"): for name in ACTIVATION_INPUTS:
(root / name).parent.mkdir(parents=True, exist_ok=True)
(root / name).touch() (root / name).touch()
(root / "pm" / "lock.json").touch() _set_mtime(root / name, LONG_AGO)
(root / "stamp").touch() (root / "state" / "facts.json").touch()
_set_mtime(root / "state" / "facts.json", STAMP_TIME)
# Quote in posix form: a /bin/sh script treats backslashes in an unquoted # Quote in posix form: a /bin/sh script treats backslashes in an unquoted
# word as escapes (same pattern as tests/pm/test_activate_scripts.py). # word as escapes (same pattern as tests/pm/test_activate_scripts.py).
@@ -79,14 +87,19 @@ def checkout(tmp_path: Path) -> Path:
) )
(root / "activate").write_text( (root / "activate").write_text(
"echo 'ACTIVATED' >&2\n" "echo 'ACTIVATED' >&2\n"
"export __HERMES_ACTIVATED=\"%s/stamp\"\n" "export __HERMES_ACTIVATED=\"%s/state/facts.json\"\n"
"export PATH=\"%s/shim:$PATH\"\n" % (_posix(root), _posix(root)), "export PATH=\"%s/shim:$PATH\"\n" % (_posix(root), _posix(root)),
encoding="utf-8", encoding="utf-8",
) )
return root return root
def _run(root: Path, sentinel: str | None) -> str: def _record(root: Path) -> None:
"""What a successful ``pm install`` leaves behind."""
record_activation_inputs(root / "state" / "inputs", activation_input_mtimes(root))
def _run(root: Path, sentinel: str | None = "{root}/state/facts.json") -> str:
"""Drive the prologue as the kernel would; return stderr, assert it ran.""" """Drive the prologue as the kernel would; return stderr, assert it ran."""
env = {**os.environ, "PATH": f"{_posix(root / 'shim')}{os.pathsep}{os.environ.get('PATH', '')}"} env = {**os.environ, "PATH": f"{_posix(root / 'shim')}{os.pathsep}{os.environ.get('PATH', '')}"}
env.pop("__HERMES_ACTIVATED", None) env.pop("__HERMES_ACTIVATED", None)
@@ -101,28 +114,30 @@ def _run(root: Path, sentinel: str | None) -> str:
return result.stderr return result.stderr
def test_current_environment_is_left_alone(checkout: Path): def test_recorded_inputs_are_left_alone(checkout: Path):
"""Inputs older than the stamp: re-syncing on every run is the cost of """A checkout rewrote every input after facts.json was last written, then a
getting this wrong, so the prologue must stay out of the way.""" no-op sync recorded them: the environment is current. Re-syncing on every
_set_mtime(checkout / "stamp", STAMP_TIME) run is the cost of getting this wrong."""
for name in INPUTS: for name in ACTIVATION_INPUTS:
_set_mtime(checkout / name, LONG_AGO) _set_mtime(checkout / name, JUST_AFTER)
assert "ACTIVATED" not in _run(checkout, "{root}/stamp") _record(checkout)
assert "ACTIVATED" not in _run(checkout)
@pytest.mark.parametrize("input_name", INPUTS) @pytest.mark.parametrize("moved_to", [JUST_AFTER, EARLIER], ids=["newer", "older"])
def test_input_newer_than_stamp_reactivates(checkout: Path, input_name: str): @pytest.mark.parametrize("input_name", ACTIVATION_INPUTS)
_set_mtime(checkout / "stamp", STAMP_TIME) def test_input_mtime_differing_from_its_stamp_reactivates(checkout: Path, input_name: str, moved_to: str):
for name in INPUTS: """A branch switch can move an input's mtime either way; both mean the
_set_mtime(checkout / name, LONG_AGO) recorded install was not verified against what is on disk now."""
_set_mtime(checkout / input_name, JUST_AFTER) _record(checkout)
assert "ACTIVATED" in _run(checkout, "{root}/stamp") _set_mtime(checkout / input_name, moved_to)
assert "ACTIVATED" in _run(checkout)
@pytest.mark.parametrize("sentinel", [None, "{root}/gone", "1"]) @pytest.mark.parametrize("sentinel", [None, "{root}/gone", "1", "{root}/state/facts.json"],
ids=["cold", "dangling", "legacy-1", "no-stamps"])
def test_unusable_sentinel_activates(checkout: Path, sentinel: str | None): def test_unusable_sentinel_activates(checkout: Path, sentinel: str | None):
"""Cold, dangling stamp, or the bare ``1`` an older activate exported — """Cold, dangling stamp, the bare ``1`` an older activate exported, or an
each must activate rather than read as current.""" install from before stamps existed — each must activate rather than read
for name in INPUTS: as current."""
_set_mtime(checkout / name, LONG_AGO) assert "ACTIVATED" in _run(checkout, sentinel)
assert "ACTIVATED" in _run(checkout, sentinel)