diff --git a/nix/tests/desktop-backend.py b/nix/tests/desktop-backend.py index ec9b19e5bf..6d0c607290 100644 --- a/nix/tests/desktop-backend.py +++ b/nix/tests/desktop-backend.py @@ -1,4 +1,5 @@ """Launch the packaged desktop with a competing mutable install.""" +import contextlib import os from pathlib import Path import signal @@ -8,6 +9,23 @@ import tempfile 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:] with tempfile.TemporaryDirectory(prefix="hermes-desktop-backend-") as temporary: home = Path(temporary) @@ -75,3 +93,18 @@ with tempfile.TemporaryDirectory(prefix="hermes-desktop-backend-") as temporary: except subprocess.TimeoutExpired: os.killpg(child.pid, signal.SIGKILL) 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) diff --git a/pm/environment.py b/pm/environment.py index b5262ba50c..008383d18a 100644 --- a/pm/environment.py +++ b/pm/environment.py @@ -21,6 +21,19 @@ from typing import TextIO 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: """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], timeout: int, output: TextIO) -> subprocess.CompletedProcess: """Keep CI progress live, a bounded diagnostic tail, and a wall-clock timeout.""" - from pm.workspace import _RESOLVER_MARKERS - deadline = time.monotonic() + timeout proc = subprocess.Popen(command, cwd=str(cwd), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=0) diff --git a/pm/workspace.py b/pm/workspace.py index 67389fe6c0..5cadda0848 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -33,16 +33,7 @@ class ResolutionConflict(InstallError): # Markers uv prints ONLY when the resolver itself proves no solution # exists (its conflict report: "Because ...", "no solution found"). -# 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. -_RESOLVER_MARKERS = ( - "no solution found", - "conflicting requirements", - "conflicting urls", - "because only the following versions", - "and your pyproject depends on", -) +from pm.environment import _RESOLVER_MARKERS # noqa: E402 — defined beside the uv runner def classify_uv_failure(stage: str, returncode: int, output: str) -> InstallError: diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 937e2556fc..62a0f247d1 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -132,11 +132,27 @@ IFS="$_IFS_SAVE" # credentials, so forwarding them keeps the isolation intent intact. Each is # only forwarded when actually set, so POSIX runs are byte-for-byte unchanged. 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 WIN_ENV+=("$_win_var=${!_win_var}") fi 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) ──────────────────────── # The runner's own documented environment knobs must survive the hermetic diff --git a/tests/hermes_cli/test_data_dir_suffix.py b/tests/hermes_cli/test_data_dir_suffix.py index 0237fc1fe6..fd7b6879e1 100644 --- a/tests/hermes_cli/test_data_dir_suffix.py +++ b/tests/hermes_cli/test_data_dir_suffix.py @@ -9,8 +9,13 @@ import sys 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.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): env = dict(os.environ) env.pop("HERMES_HOME", None) @@ -41,8 +46,9 @@ print(json.dumps(result)) result = subprocess.run( [sys.executable, "-c", script], env=env, 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" root = Path(str(base) + suffix) profile = root / "profiles" / "coder" diff --git a/tests/hermes_cli/test_launcher_runtime_selection.py b/tests/hermes_cli/test_launcher_runtime_selection.py index a05a4a8b1e..02e788f96f 100644 --- a/tests/hermes_cli/test_launcher_runtime_selection.py +++ b/tests/hermes_cli/test_launcher_runtime_selection.py @@ -13,7 +13,8 @@ from pm.environments import install_state_dir, site_packages @pytest.mark.platforms("windows") 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 root = tmp_path / "repo" diff --git a/tests/pm/test_bootstrap_import_closure.py b/tests/pm/test_bootstrap_import_closure.py index 55d9f8a3a5..5117e10f43 100644 --- a/tests/pm/test_bootstrap_import_closure.py +++ b/tests/pm/test_bootstrap_import_closure.py @@ -52,3 +52,29 @@ assert callable(sign_managed_python) env=dict(os.environ, HERMES_HOME=str(tmp_path / "home")), capture_output=True, text=True, timeout=15) 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 diff --git a/tests/pm/test_runtime.py b/tests/pm/test_runtime.py index dc75ce5789..23c8ce7fd3 100644 --- a/tests/pm/test_runtime.py +++ b/tests/pm/test_runtime.py @@ -116,14 +116,15 @@ def test_sealed_worker_command_uses_only_its_recorded_site(tmp_path, monkeypatch shutil.copytree(Path(sys.base_prefix), base) python = base / "python.exe" else: - base.mkdir() - python = base / "python" - # A symlink, not a copy: a lone copied binary cannot find its stdlib on a - # framework-style build (macOS: "Could not find platform independent libraries"). - # The contract under test is the sealed sys.path, not the binary's location. - python.symlink_to(Path(sys._base_executable).resolve()) + # The guard resolves symlinks (a python linked outside the payload IS an escape), so the + # interpreter is a real copy inside the payload. A relocatable/framework build locates its + # stdlib beside the executable: supply the host's library tree the way the bundle test does. + (base / "bin").mkdir(parents=True) + python = base / "bin" / "python" + 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({ - "python": "../python/" + python.name, "sitePackages": "site", + "python": os.path.relpath(python, runtime), "sitePackages": "site", })) script = repo / "probe.py" script.write_text("import sys,json; print(json.dumps(sys.path))")