fix(plugins): recover interrupted Python environment rebuilds

This commit is contained in:
yellowcooln committed 2026-09-05 13:15:26 -04:00
1 parent 1cafdd0383
commit 4aa67a354f
3 files changed
+79

No files matched your search

+5
View File
@@ -153,6 +153,11 @@ that deliberately detach into new sessions or write other files.
Failed Python-version rebuilds restore the previous venv and remain retryable; Failed Python-version rebuilds restore the previous venv and remain retryable;
the new marker is accepted only after the new environment and entrypoint exist. the new marker is accepted only after the new environment and entrypoint exist.
A rebuild-intent record is reconciled before version checks after a manager
restart, so interruption during replacement cannot silently accept a partial
venv. This provides process-interruption recovery, not guaranteed filesystem
durability across power loss; interruption during cleanup can leave an unused
backup.
Archive extraction limits expanded member size and total size and rejects Archive extraction limits expanded member size and total size and rejects
symlink-backed destinations. Catalogue JSON and wheel response bodies have size symlink-backed destinations. Catalogue JSON and wheel response bodies have size
+25
View File
@@ -153,6 +153,24 @@ class PluginRuntime:
version = str(state.get("version") or manifest.version) version = str(state.get("version") or manifest.version)
paths = self.storage.paths_for(plugin_id) paths = self.storage.paths_for(plugin_id)
venv_path = paths.venv_dir(version) venv_path = paths.venv_dir(version)
# Reconcile before either missing-config or matching-version early return:
# a newly created pyvenv.cfg does not mean pip/entrypoint validation finished.
rebuild_marker = venv_path.with_name(".venv-rebuild-in-progress")
if rebuild_marker.exists():
backup_name = rebuild_marker.read_text(encoding="ascii")
suffix = backup_name.removeprefix(".venv-backup-")
if backup_name != f".venv-backup-{uuid.UUID(hex=suffix).hex}":
raise ValueError("invalid venv rebuild backup marker")
interrupted_backup = venv_path.with_name(backup_name)
if interrupted_backup.exists():
if venv_path.exists():
shutil.rmtree(venv_path)
interrupted_backup.rename(venv_path)
# No backup means interruption before rename or after rollback.
if not venv_path.exists():
raise RuntimeError("interrupted venv rebuild has no recoverable environment")
rebuild_marker.unlink()
config_path = venv_path / "pyvenv.cfg" config_path = venv_path / "pyvenv.cfg"
if not config_path.is_file(): if not config_path.is_file():
# Legacy/test venvs have no reliable version marker. Let normal # Legacy/test venvs have no reliable version marker. Let normal
@@ -194,6 +212,9 @@ class PluginRuntime:
# Build at the final absolute path: moving a freshly built venv would # Build at the final absolute path: moving a freshly built venv would
# invalidate pip-generated shebangs. Keep the old environment for rollback. # invalidate pip-generated shebangs. Keep the old environment for rollback.
backup = venv_path.with_name(f".venv-backup-{uuid.uuid4().hex}") backup = venv_path.with_name(f".venv-backup-{uuid.uuid4().hex}")
# Publish intent atomically before moving the old environment. This is
# process-interruption recovery, not a filesystem power-loss guarantee.
write_release_asset(venv_path.parent, rebuild_marker.name, backup.name.encode("ascii"))
venv_path.rename(backup) venv_path.rename(backup)
try: try:
self._create_venv(venv_path) self._create_venv(venv_path)
@@ -203,8 +224,12 @@ class PluginRuntime:
if venv_path.exists(): if venv_path.exists():
shutil.rmtree(venv_path) shutil.rmtree(venv_path)
backup.rename(venv_path) backup.rename(venv_path)
rebuild_marker.unlink()
raise raise
else: else:
# Commit only after validation; an interrupted cleanup may leave an
# unused backup, but must never restore a partly deleted backup.
rebuild_marker.unlink()
shutil.rmtree(backup) shutil.rmtree(backup)
def _run_install_command(self, cmd, *, timeout, **kwargs): def _run_install_command(self, cmd, *, timeout, **kwargs):
+49
View File
@@ -2,6 +2,7 @@
import json import json
import os import os
import shutil
import signal import signal
import subprocess import subprocess
import sys import sys
@@ -169,6 +170,54 @@ def test_failed_rebuild_restores_old_environment_and_retries(tmp_path):
assert len([cmd for cmd in calls if "pip" in cmd]) == 2 assert len([cmd for cmd in calls if "pip" in cmd]) == 2
@pytest.mark.parametrize("stage", ["old_renamed", "new_config"])
def test_fresh_runtime_recovers_interrupted_rebuild_snapshot(tmp_path, stage):
snapshot = tmp_path / "snapshot"
calls = []
capturing = True
def run(cmd, **kwargs):
calls.append(cmd)
if "venv" in cmd:
target = Path(cmd[-1])
if capturing and stage == "old_renamed":
shutil.copytree(tmp_path / "plugins", snapshot, symlinks=True)
raise RuntimeError("snapshot captured")
(target / "bin").mkdir(parents=True)
(target / "bin/python").symlink_to(sys.executable)
(target / "pyvenv.cfg").write_text(
f"version = {sys.version_info.major}.{sys.version_info.minor}.0\n"
)
if capturing:
shutil.copytree(tmp_path / "plugins", snapshot, symlinks=True)
raise RuntimeError("snapshot captured")
else:
entry = Path(cmd[0]).parent / "demo-cli"
entry.write_text(f"#!{cmd[0]}\nprint('recovered')\n")
entry.chmod(0o755)
return subprocess.CompletedProcess(cmd, 0, "", "")
runtime, paths = prepared_runtime(tmp_path, run_factory=run)
venv = paths.venv_dir("1.0.0")
(venv / "bin").mkdir(parents=True)
(venv / "bin/python").write_text("old")
(venv / "pyvenv.cfg").write_text("version = 2.7.0\n")
(paths.release_dir("1.0.0") / "demo-1.0.0-py3-none-any.whl").touch()
with pytest.raises(RuntimeError, match="snapshot captured"):
runtime.ensure_venv_compatible("openhop.demo")
capturing = False
calls.clear()
fresh = PluginRuntime(PluginStorage(snapshot), run_factory=run)
fresh.ensure_venv_compatible("openhop.demo")
assert len([cmd for cmd in calls if "pip" in cmd]) == 1
entry = fresh.resolve_entrypoint(fresh.storage.paths_for("openhop.demo"), "1.0.0", "demo-cli")
assert subprocess.check_output([str(entry)], text=True, timeout=5).strip() == "recovered"
calls.clear()
fresh.ensure_venv_compatible("openhop.demo")
assert calls == []
def test_install_subprocesses_have_deadlines(tmp_path): def test_install_subprocesses_have_deadlines(tmp_path):
calls = [] calls = []