fix(windows): preserve active source launchers during backend startup

This commit is contained in:
ethernet
2026-09-24 06:02:17 -04:00
parent 1a495c4033
commit 06e9c6a0b8
2 changed files with 111 additions and 11 deletions

View File

@@ -196,19 +196,65 @@ def mint_launcher(
def _get_script_text(self, entry):
return script
maker = _PathedScriptMaker(None, str(out_dir), add_launchers=True)
maker.executable = str(python_exe)
maker.variants = {""}
maker.clobber = True
try:
written = maker.make(f"{name} = {module}:{func}", {"interpreter_args": ["-I"]})
except Exception:
written = []
for path in written:
if Path(path).suffix.lower() == ".exe":
return Path(path)
import tempfile
from zipfile import BadZipFile, ZipFile
def make(directory: Path) -> list[str]:
maker = _PathedScriptMaker(None, str(directory), add_launchers=True)
maker.executable = str(python_exe)
maker.variants = {""}
maker.clobber = True
return maker.make(f"{name} = {module}:{func}", {"interpreter_args": ["-I"]})
# distlib's exe ZIP records the current time, so byte equality cannot
# detect an unchanged launcher. Compare its loader + shebang and script;
# leave an active executable alone when only the ZIP timestamp changed.
with tempfile.TemporaryDirectory(prefix=f".{name}-", dir=out_dir) as staging:
try:
candidate = next((Path(p) for p in make(Path(staging))
if Path(p).suffix.lower() == ".exe"), None)
except Exception:
candidate = None
if candidate is not None:
target = out_dir / candidate.name
try:
with ZipFile(target) as old, ZipFile(candidate) as new:
if (old.namelist() == new.namelist() == ["__main__.py"]
and target.read_bytes()[:old.infolist()[0].header_offset]
== candidate.read_bytes()[:new.infolist()[0].header_offset]
and old.read("__main__.py") == new.read("__main__.py")):
return target
except (OSError, BadZipFile, KeyError):
pass
# For changed launchers, distlib's .deleteme replacement can
# move an executable Windows still has mapped in memory.
try:
written = make(out_dir)
except Exception:
written = []
for path in written:
if Path(path).suffix.lower() == ".exe":
return Path(path)
# distlib ran but produced no exe (unexpected) — fall through to cmd.
# A prepared app environment need not include distlib, even when the
# installer used it to publish a native exe. Keep that executable if its
# embedded interpreter and script still match; replacing it with a cmd
# would require unlinking the currently running exe on Windows.
existing = out_dir / f"{name}.exe"
from zipfile import BadZipFile, ZipFile
try:
with ZipFile(existing) as archive:
if archive.namelist() == ["__main__.py"]:
prefix = existing.read_bytes()[:archive.infolist()[0].header_offset]
shebangs = (f"#!{python_exe} -I\n".encode("utf-8"),
f'#!"{python_exe}" -I\n'.encode("utf-8"))
if (any(prefix.endswith(shebang) for shebang in shebangs)
and archive.read("__main__.py") == script.encode("utf-8")):
return existing
except (OSError, BadZipFile, KeyError):
pass
# The script is data to Python, not interpolated shell source.
import base64
encoded = base64.b64encode(script.encode("utf-8")).decode("ascii")

View File

@@ -333,6 +333,60 @@ def test_windows_commands_survive_generation_collection(tmp_path, monkeypatch, s
_command_survives_generation_collection(tmp_path, monkeypatch, surface)
@pytest.mark.platforms("windows")
@pytest.mark.parametrize("launcher_form", ["native", "cmd", "native-with-maker"])
def test_running_source_launcher_can_republish_itself(tmp_path, monkeypatch, launcher_form):
repo, _home, interpreter = fixture_tree(tmp_path, monkeypatch)
selected = select_generation(repo, "selected", "ready")
if launcher_form == "native-with-maker":
import distlib
shutil.copytree(Path(distlib.__file__).parent, site_packages(selected) / "distlib")
entry = repo / "hermes_cli/main.py"
entry.write_text(
"from pathlib import Path\n"
"from hermes_cli._launchers import ensure_install_launchers, ENTRY_POINTS\n"
"def main():\n"
" root = Path(__file__).resolve().parents[1]\n"
" written = ensure_install_launchers(root, root / '.hermes/bin')\n"
" print('published', len(written), len(ENTRY_POINTS), flush=True)\n"
" return 0 if len(written) == len(ENTRY_POINTS) else 1\n",
encoding="utf-8",
)
out = repo / ".hermes/bin"
if launcher_form == "cmd":
monkeypatch.setattr(_launchers, "_load_script_maker", lambda: None)
launchers = _launchers.ensure_install_launchers(repo, out)
assert len(launchers) == len(_launchers.ENTRY_POINTS)
command = next(Path(p) for p in launchers if Path(p).stem == "hermes")
assert command.suffix == (".cmd" if launcher_form == "cmd" else ".exe")
result = subprocess.run([str(command)], cwd=tmp_path, capture_output=True,
text=True, encoding="utf-8", timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
assert "published 2 2" in result.stdout
assert _launchers.ensure_install_launchers(repo, out)
selected_python = _launchers.resolve_store_python(repo)
assert selected_python is not None and selected_python.samefile(interpreter)
@pytest.mark.platforms("windows")
def test_repin_without_distlib_retires_stale_native_launcher(tmp_path, monkeypatch):
repo, home, _interpreter = fixture_tree(tmp_path, monkeypatch)
local = repo / ".hermes/bin"
original = Path(_launchers.ensure_install_launchers(repo, local)[0])
assert original.suffix == ".exe"
new_python = home / "tools" / "repinned" / "python.exe"
new_python.parent.mkdir()
new_python.touch()
(home / "tools/facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "repinned"}}}), encoding="utf-8")
monkeypatch.setattr(_launchers, "_load_script_maker", lambda: None)
result = _launchers.stage_launcher("hermes", repo, local)
assert result == local / "hermes.cmd"
assert not original.exists() # cmd.exe must not run the old exe first
assert result is not None and str(new_python) in result.read_text(encoding="utf-8-sig")
@pytest.mark.platforms("windows")
def test_windows_repair_upgrades_healthy_old_pm_external_launchers(tmp_path, monkeypatch):
from hermes_cli._install_repair import ensure_windows_bin_launchers