fix: platform legs — docker arm64 bootstrap, win32-arm64 native builds, nix desktop-backend cleanup
- pm.environment owns _RESOLVER_MARKERS: the streaming uv runner imported
pm.workspace, whose tomllib import fails on the 3.10 system python that
bootstraps the Docker arm64 image (No module named 'tomllib'). Invariant test
proves runtime staging needs neither tomllib nor pm.workspace.
- run_tests.sh forwards the MSVC/SDK/Rust/OpenSSL toolchain variables through
its env -i scrub so PM tests that compile ruamel-yaml-clib on Windows arm64
find cl.exe (previously 'Visual C++ 14.0 or greater is required').
- nix desktop-backend check: the spawned backend outlives cage's process group
and kept writing under the temp HERMES_HOME during rmtree; stop every
process bound to the throwaway HOME before cleanup.
- windows: test_launcher_runtime_selection imports runtime_state from
hermes_cli (moved in bbec973514); the ' spaced ' suffix row loses its
trailing space on win32 (the filesystem strips it).
- macOS: test_sealed_worker_command copies the interpreter into the payload
(the escape guard resolves symlinks) and links the host lib tree.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
"""Launch the packaged desktop with a competing mutable install."""
|
"""Launch the packaged desktop with a competing mutable install."""
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import signal
|
import signal
|
||||||
@@ -8,6 +9,23 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def _processes_under(home: Path) -> list[int]:
|
||||||
|
"""PIDs whose environment or cwd binds them to this throwaway HOME (Linux /proc)."""
|
||||||
|
needle = str(home).encode()
|
||||||
|
found = []
|
||||||
|
for entry in os.listdir("/proc"):
|
||||||
|
if not entry.isdigit() or int(entry) == os.getpid():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
environ = Path("/proc", entry, "environ").read_bytes()
|
||||||
|
cwd = os.readlink(f"/proc/{entry}/cwd").encode()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if needle in environ or cwd.startswith(needle):
|
||||||
|
found.append(int(entry))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
desktop, expected = sys.argv[1:]
|
desktop, expected = sys.argv[1:]
|
||||||
with tempfile.TemporaryDirectory(prefix="hermes-desktop-backend-") as temporary:
|
with tempfile.TemporaryDirectory(prefix="hermes-desktop-backend-") as temporary:
|
||||||
home = Path(temporary)
|
home = Path(temporary)
|
||||||
@@ -75,3 +93,18 @@ with tempfile.TemporaryDirectory(prefix="hermes-desktop-backend-") as temporary:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
os.killpg(child.pid, signal.SIGKILL)
|
os.killpg(child.pid, signal.SIGKILL)
|
||||||
child.wait(timeout=5)
|
child.wait(timeout=5)
|
||||||
|
# The desktop spawns its backend in its own session (hermes serve outlives a
|
||||||
|
# window close on purpose), so killing cage's group leaves that gateway writing
|
||||||
|
# under HERMES_HOME while the tempdir is removed. Stop everything still rooted
|
||||||
|
# in this home before cleanup; the sandbox has no other processes to confuse.
|
||||||
|
survivors = _processes_under(home)
|
||||||
|
for pid in survivors:
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while survivors and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.2)
|
||||||
|
survivors = _processes_under(home)
|
||||||
|
for pid in survivors:
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ from typing import TextIO
|
|||||||
|
|
||||||
from pm.package import InstallError
|
from pm.package import InstallError
|
||||||
|
|
||||||
|
# Deliberately narrow: a fetch timeout or index outage must not be misread as a
|
||||||
|
# conflict — and regardless of classification, nothing here ever disables a
|
||||||
|
# plugin; the caller decides. Lives here (stdlib-only imports) because the
|
||||||
|
# bootstrap runner streams uv output from a pre-3.11 system python where
|
||||||
|
# pm.workspace's tomllib import cannot load.
|
||||||
|
_RESOLVER_MARKERS = (
|
||||||
|
"no solution found",
|
||||||
|
"conflicting requirements",
|
||||||
|
"conflicting urls",
|
||||||
|
"because only the following versions",
|
||||||
|
"and your pyproject depends on",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def prune_site_pth(venv_dir: Path) -> None:
|
def prune_site_pth(venv_dir: Path) -> None:
|
||||||
"""Drop .pth files that must never execute inside a shipped payload.
|
"""Drop .pth files that must never execute inside a shipped payload.
|
||||||
@@ -52,8 +65,6 @@ def prune_site_pth(venv_dir: Path) -> None:
|
|||||||
def _run_streaming(command: list[str], *, cwd: Path, env: dict[str, str],
|
def _run_streaming(command: list[str], *, cwd: Path, env: dict[str, str],
|
||||||
timeout: int, output: TextIO) -> subprocess.CompletedProcess:
|
timeout: int, output: TextIO) -> subprocess.CompletedProcess:
|
||||||
"""Keep CI progress live, a bounded diagnostic tail, and a wall-clock timeout."""
|
"""Keep CI progress live, a bounded diagnostic tail, and a wall-clock timeout."""
|
||||||
from pm.workspace import _RESOLVER_MARKERS
|
|
||||||
|
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
proc = subprocess.Popen(command, cwd=str(cwd), env=env, stdout=subprocess.PIPE,
|
proc = subprocess.Popen(command, cwd=str(cwd), env=env, stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=0)
|
stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=0)
|
||||||
|
|||||||
@@ -33,16 +33,7 @@ class ResolutionConflict(InstallError):
|
|||||||
|
|
||||||
# Markers uv prints ONLY when the resolver itself proves no solution
|
# Markers uv prints ONLY when the resolver itself proves no solution
|
||||||
# exists (its conflict report: "Because ...", "no solution found").
|
# exists (its conflict report: "Because ...", "no solution found").
|
||||||
# Deliberately narrow: a fetch timeout or index outage must not be
|
from pm.environment import _RESOLVER_MARKERS # noqa: E402 — defined beside the uv runner
|
||||||
# misread as a conflict — and regardless of classification, nothing
|
|
||||||
# here ever disables a plugin; the caller decides.
|
|
||||||
_RESOLVER_MARKERS = (
|
|
||||||
"no solution found",
|
|
||||||
"conflicting requirements",
|
|
||||||
"conflicting urls",
|
|
||||||
"because only the following versions",
|
|
||||||
"and your pyproject depends on",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def classify_uv_failure(stage: str, returncode: int, output: str) -> InstallError:
|
def classify_uv_failure(stage: str, returncode: int, output: str) -> InstallError:
|
||||||
|
|||||||
@@ -132,11 +132,27 @@ IFS="$_IFS_SAVE"
|
|||||||
# credentials, so forwarding them keeps the isolation intent intact. Each is
|
# credentials, so forwarding them keeps the isolation intent intact. Each is
|
||||||
# only forwarded when actually set, so POSIX runs are byte-for-byte unchanged.
|
# only forwarded when actually set, so POSIX runs are byte-for-byte unchanged.
|
||||||
WIN_ENV=()
|
WIN_ENV=()
|
||||||
for _win_var in USERPROFILE HOMEDRIVE HOMEPATH LOCALAPPDATA APPDATA SYSTEMROOT TEMP TMP; do
|
for _win_var in USERPROFILE HOMEDRIVE HOMEPATH LOCALAPPDATA APPDATA SYSTEMROOT TEMP TMP \
|
||||||
|
ComSpec PROGRAMFILES ProgramFiles PROGRAMDATA ProgramData; do
|
||||||
if [ -n "${!_win_var:-}" ]; then
|
if [ -n "${!_win_var:-}" ]; then
|
||||||
WIN_ENV+=("$_win_var=${!_win_var}")
|
WIN_ENV+=("$_win_var=${!_win_var}")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
# Native build toolchain (Windows arm64 has no wheels for every pinned C extension, so
|
||||||
|
# `uv sync` inside a PM test compiles ruamel-yaml-clib and friends). The MSVC developer
|
||||||
|
# environment is exported by scripts/build/windows-deps.ps1 into the job env; without
|
||||||
|
# INCLUDE/LIB/VSINSTALLDIR the build backend reports "Visual C++ 14.0 or greater is
|
||||||
|
# required". These describe compiler locations, not credentials.
|
||||||
|
for _tool_var in INCLUDE LIB LIBPATH VSINSTALLDIR VCINSTALLDIR VCToolsInstallDir VCToolsVersion \
|
||||||
|
VCToolsRedistDir WindowsSdkDir WindowsSDKVersion WindowsSdkBinPath WindowsSdkVerBinPath \
|
||||||
|
WindowsLibPath UCRTVersion UniversalCRTSdkDir VSCMD_ARG_HOST_ARCH VSCMD_ARG_TGT_ARCH VSCMD_VER \
|
||||||
|
DevEnvDir ExtensionSdkDir Platform CARGO_HOME RUSTUP_HOME RUSTUP_TOOLCHAIN \
|
||||||
|
CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER CC_aarch64_pc_windows_msvc CC CXX AR \
|
||||||
|
VCPKG_ROOT OPENSSL_DIR OPENSSL_STATIC OPENSSL_LIB_DIR OPENSSL_INCLUDE_DIR; do
|
||||||
|
if [ -n "${!_tool_var:-}" ]; then
|
||||||
|
WIN_ENV+=("$_tool_var=${!_tool_var}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# ── Test-runner knobs (computed before we drop env) ────────────────────────
|
# ── Test-runner knobs (computed before we drop env) ────────────────────────
|
||||||
# The runner's own documented environment knobs must survive the hermetic
|
# The runner's own documented environment knobs must survive the hermetic
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ import sys
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Win32 strips a trailing space from every path component, so the literal-suffix contract is
|
||||||
|
# only checkable with the leading space there.
|
||||||
|
_SPACED = " spaced" if sys.platform == "win32" else " spaced "
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.platforms("linux", "macos", "windows")
|
@pytest.mark.platforms("linux", "macos", "windows")
|
||||||
@pytest.mark.parametrize("suffix", ["", "-asdfasdf", "magic-test", " spaced "])
|
@pytest.mark.parametrize("suffix", ["", "-asdfasdf", "magic-test", _SPACED])
|
||||||
def test_suffix_scopes_default_home_and_profiles(tmp_path, suffix):
|
def test_suffix_scopes_default_home_and_profiles(tmp_path, suffix):
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env.pop("HERMES_HOME", None)
|
env.pop("HERMES_HOME", None)
|
||||||
@@ -41,8 +46,9 @@ print(json.dumps(result))
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-c", script], env=env,
|
[sys.executable, "-c", script], env=env,
|
||||||
cwd=Path(__file__).resolve().parents[2],
|
cwd=Path(__file__).resolve().parents[2],
|
||||||
text=True, capture_output=True, check=True,
|
text=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
base = tmp_path / "AppData" / "Local" / "hermes" if sys.platform == "win32" else tmp_path / ".hermes"
|
base = tmp_path / "AppData" / "Local" / "hermes" if sys.platform == "win32" else tmp_path / ".hermes"
|
||||||
root = Path(str(base) + suffix)
|
root = Path(str(base) + suffix)
|
||||||
profile = root / "profiles" / "coder"
|
profile = root / "profiles" / "coder"
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ from pm.environments import install_state_dir, site_packages
|
|||||||
|
|
||||||
@pytest.mark.platforms("windows")
|
@pytest.mark.platforms("windows")
|
||||||
def test_minted_launcher_reads_current_selection_and_editable_members(tmp_path, monkeypatch):
|
def test_minted_launcher_reads_current_selection_and_editable_members(tmp_path, monkeypatch):
|
||||||
from pm import environments as runtime_paths, runtime_state
|
from pm import environments as runtime_paths
|
||||||
|
from hermes_cli import runtime_state
|
||||||
import hermes_constants
|
import hermes_constants
|
||||||
|
|
||||||
root = tmp_path / "repo"
|
root = tmp_path / "repo"
|
||||||
|
|||||||
@@ -52,3 +52,29 @@ assert callable(sign_managed_python)
|
|||||||
env=dict(os.environ, HERMES_HOME=str(tmp_path / "home")),
|
env=dict(os.environ, HERMES_HOME=str(tmp_path / "home")),
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True, timeout=15)
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_staging_streams_uv_output_without_tomllib(tmp_path):
|
||||||
|
"""The PM runtime is staged by whatever python the host has (the Docker
|
||||||
|
arm64 image bootstraps from a 3.10 system python); streaming ``uv venv``
|
||||||
|
output must not pull in ``pm.workspace``, whose ``tomllib`` import needs 3.11+.
|
||||||
|
"""
|
||||||
|
repo = Path(__file__).resolve().parents[2]
|
||||||
|
script = """
|
||||||
|
import sys
|
||||||
|
class NoTomllib:
|
||||||
|
def find_spec(self, fullname, path=None, target=None):
|
||||||
|
if fullname in ('tomllib', 'pm.workspace', 'pm.plugin_declarations'):
|
||||||
|
raise AssertionError('runtime staging imported ' + fullname)
|
||||||
|
sys.meta_path.insert(0, NoTomllib())
|
||||||
|
import pm.runtime_stage
|
||||||
|
from pm.environment import _run_streaming
|
||||||
|
import subprocess
|
||||||
|
result = _run_streaming([sys.executable, '-c', 'print(\"no solution found\")'],
|
||||||
|
cwd='.', env={}, timeout=30, output=sys.stderr)
|
||||||
|
assert result.returncode == 0, result
|
||||||
|
"""
|
||||||
|
result = subprocess.run([sys.executable, "-S", "-c", script], cwd=repo,
|
||||||
|
env=dict(os.environ, HERMES_HOME=str(tmp_path / "home")),
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
|||||||
@@ -116,14 +116,15 @@ def test_sealed_worker_command_uses_only_its_recorded_site(tmp_path, monkeypatch
|
|||||||
shutil.copytree(Path(sys.base_prefix), base)
|
shutil.copytree(Path(sys.base_prefix), base)
|
||||||
python = base / "python.exe"
|
python = base / "python.exe"
|
||||||
else:
|
else:
|
||||||
base.mkdir()
|
# The guard resolves symlinks (a python linked outside the payload IS an escape), so the
|
||||||
python = base / "python"
|
# interpreter is a real copy inside the payload. A relocatable/framework build locates its
|
||||||
# A symlink, not a copy: a lone copied binary cannot find its stdlib on a
|
# stdlib beside the executable: supply the host's library tree the way the bundle test does.
|
||||||
# framework-style build (macOS: "Could not find platform independent libraries").
|
(base / "bin").mkdir(parents=True)
|
||||||
# The contract under test is the sealed sys.path, not the binary's location.
|
python = base / "bin" / "python"
|
||||||
python.symlink_to(Path(sys._base_executable).resolve())
|
shutil.copy2(Path(sys._base_executable).resolve(), python)
|
||||||
|
(base / "lib").symlink_to(Path(sys.base_prefix) / "lib", target_is_directory=True)
|
||||||
(runtime / "pm-runtime.json").write_text(json.dumps({
|
(runtime / "pm-runtime.json").write_text(json.dumps({
|
||||||
"python": "../python/" + python.name, "sitePackages": "site",
|
"python": os.path.relpath(python, runtime), "sitePackages": "site",
|
||||||
}))
|
}))
|
||||||
script = repo / "probe.py"
|
script = repo / "probe.py"
|
||||||
script.write_text("import sys,json; print(json.dumps(sys.path))")
|
script.write_text("import sys,json; print(json.dumps(sys.path))")
|
||||||
|
|||||||
Reference in New Issue
Block a user