fix(update): commit dependencies for processes the update spawns early
prepare_launch returned early for any process running under the updater's own claim, so it would not re-run the completion tail. That also covered processes the updater spawns before PM commits a generation (a restarted gateway), which then booted with no environment: previously on the pre-PM venv, now refused. Under the updater's claim with nothing committed, sync the dependency generation (carrying the legacy venv's extras, as the first sync always has), skip the tail since that belongs to the updater, and relaunch on the store Python. The relaunched process sees the commit and returns early as before, so the no-recursion guard still holds.
This commit is contained in:
@@ -252,13 +252,21 @@ def prepare_launch(project_root: Path, argv: list[str]) -> Path | None:
|
||||
lock = UpdateLock()
|
||||
if not lock.acquire():
|
||||
raise RuntimeError("an update is still running; wait for it to exit, then relaunch Hermes")
|
||||
# The tail imports the application, whose entry point runs this very function:
|
||||
# under the launching process's own claim (its pid is our ancestor) we ARE that
|
||||
# tail and owe nothing — without this, a pending marker recurses forever.
|
||||
if not lock.acquired and read_live_update() is not None:
|
||||
return None
|
||||
try:
|
||||
_finish_source_update(root, current=current, pending=pending)
|
||||
# The tail imports the application, whose entry point runs this very function:
|
||||
# under the launching process's own claim (its pid is our ancestor) we ARE that
|
||||
# tail and owe nothing — without this, a pending marker recurses forever.
|
||||
if not lock.acquired and read_live_update() is not None:
|
||||
from pm.environments import committed_venv
|
||||
|
||||
if committed_venv(root) is not None:
|
||||
return None
|
||||
# A process the update spawns before PM commits (a restarted gateway) has no
|
||||
# environment to run on. Commit one — never the tail, which is the updater's —
|
||||
# then relaunch below; the relaunched process sees the commit and returns above.
|
||||
_sync_source_dependencies(root, arm=False)
|
||||
else:
|
||||
_finish_source_update(root, current=current, pending=pending)
|
||||
finally:
|
||||
lock.release()
|
||||
python = resolve_store_python(root)
|
||||
@@ -273,9 +281,8 @@ def prepare_launch(project_root: Path, argv: list[str]) -> Path | None:
|
||||
def _finish_source_update(root: Path, *, current: bool, pending: Path) -> None:
|
||||
"""Sync dependencies when they are stale, then run the tail the marker still owes."""
|
||||
import sys
|
||||
import pm
|
||||
from hermes_cli._early_recovery import _marker_owner_is_live
|
||||
from pm.environments import activation_environment, runtime_facts_path
|
||||
from pm.environments import activation_environment
|
||||
|
||||
if not current:
|
||||
# Existing markers guard liveness, never create the completion obligation.
|
||||
@@ -284,24 +291,7 @@ def _finish_source_update(root: Path, *, current: bool, pending: Path) -> None:
|
||||
if any(_marker_owner_is_live(marker) for marker in legacy_markers):
|
||||
raise RuntimeError("an update is still running; wait for it to exit, then relaunch Hermes")
|
||||
print("hermes: completing source-update dependencies...", file=sys.stderr, flush=True)
|
||||
# Owed from before the sync commits: a crash between the commit and the
|
||||
# tail must leave the tail, not a "current" install with nothing built.
|
||||
refuse_foreign_owned_venv(root)
|
||||
arm_completion(root)
|
||||
# Main-era installs have no PM ledger; carry what their venv held.
|
||||
# Established PM installs retain their recorded extras and plugin union instead.
|
||||
from pm.client import ensure_tools_for_sync
|
||||
from pm.extras import legacy_selection
|
||||
extras = legacy_selection(root) if not runtime_facts_path(root).is_file() else None
|
||||
# Same order as `hermes update`: an interrupted update or a hand-run
|
||||
# `git pull` leaves this tree's lockfile ahead of the installed tools.
|
||||
ensure_tools_for_sync()
|
||||
pm.sync_venv(extras, explicit=True, project_root=root)
|
||||
collect_superseded_generations(root)
|
||||
# These can predate the swap. Once PM commits the replacement they
|
||||
# must not make early recovery immediately rebuild it a second time.
|
||||
for name in (".update-incomplete", ".lazy-refresh-incomplete"):
|
||||
(root / name).unlink(missing_ok=True)
|
||||
_sync_source_dependencies(root, arm=True)
|
||||
else:
|
||||
print("hermes: finishing an interrupted source update...", file=sys.stderr, flush=True)
|
||||
# Sync commits the dependency generation, but a source update also owes
|
||||
@@ -330,6 +320,35 @@ def _finish_source_update(root: Path, *, current: bool, pending: Path) -> None:
|
||||
clear_completion(root)
|
||||
|
||||
|
||||
def _sync_source_dependencies(root: Path, *, arm: bool) -> None:
|
||||
"""Commit the tree's dependency generation; *arm* also owes the tail afterwards."""
|
||||
import sys
|
||||
import pm
|
||||
from pm.client import ensure_tools_for_sync
|
||||
from pm.environments import runtime_facts_path
|
||||
from pm.extras import legacy_selection
|
||||
|
||||
if not arm:
|
||||
print("hermes: preparing dependencies for this update...", file=sys.stderr, flush=True)
|
||||
refuse_foreign_owned_venv(root)
|
||||
if arm:
|
||||
# Owed from before the sync commits: a crash between the commit and the
|
||||
# tail must leave the tail, not a "current" install with nothing built.
|
||||
arm_completion(root)
|
||||
# Main-era installs have no PM ledger; carry what their venv held.
|
||||
# Established PM installs retain their recorded extras and plugin union instead.
|
||||
extras = legacy_selection(root) if not runtime_facts_path(root).is_file() else None
|
||||
# Same order as `hermes update`: an interrupted update or a hand-run
|
||||
# `git pull` leaves this tree's lockfile ahead of the installed tools.
|
||||
ensure_tools_for_sync()
|
||||
pm.sync_venv(extras, explicit=True, project_root=root)
|
||||
collect_superseded_generations(root)
|
||||
# These can predate the swap. Once PM commits the replacement they
|
||||
# must not make early recovery immediately rebuild it a second time.
|
||||
for name in (".update-incomplete", ".lazy-refresh-incomplete"):
|
||||
(root / name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def relaunch_command(
|
||||
python: Path, root: Path, argv: list[str], original: list[str], module: str | None,
|
||||
) -> list[str]:
|
||||
|
||||
@@ -268,6 +268,31 @@ def test_launch_without_marker_publishes_then_skips_and_rebuilds_on_lock_change(
|
||||
assert not (root / ".update-incomplete").exists()
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_process_spawned_by_the_update_commits_dependencies_but_not_the_tail(source_launch, tmp_path):
|
||||
"""A process an update spawns before PM commits (its restarted gateway) must not boot on the
|
||||
pre-PM venv: that tree was built for the old interpreter and loses its compiled modules."""
|
||||
import time
|
||||
from hermes_cli.update_lock import update_marker_path
|
||||
from pm.environments import committed_venv
|
||||
|
||||
root, store_python, _ = source_launch
|
||||
marker = update_marker_path()
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(f"{os.getppid()}\n{int(time.time())}\n", encoding="utf-8") # the updater is our ancestor
|
||||
assert committed_venv(root) is None
|
||||
|
||||
assert venv_sync.prepare_launch(root, []) == store_python
|
||||
assert committed_venv(root) == Path(_fact(root)["environment"])
|
||||
assert not (tmp_path / "completion-calls").exists(), "the tail is the updater's, not its child's"
|
||||
assert not venv_sync.completion_pending_path(root).exists()
|
||||
|
||||
facts_bytes, receipts = runtime_facts_path(root).read_bytes(), _receipts(tmp_path)
|
||||
venv_sync.prepare_launch(root, [])
|
||||
assert runtime_facts_path(root).read_bytes() == facts_bytes
|
||||
assert _receipts(tmp_path) == receipts, "a committed child synced again under the updater's claim"
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_failed_real_sync_preserves_previous_selection_and_retries(source_launch, tmp_path):
|
||||
root, store_python, _ = source_launch
|
||||
|
||||
Reference in New Issue
Block a user