unsloth/tests/studio/install/test_install_node_prebuilt_logic.py
Daniel Han 9bfa18cdb0
Windows: unblock the consumer install on clean and no-winget machines (#7549)
* Windows: unblock the consumer install on clean and no-winget machines

Four independent things stop a clean Windows box today.

git was a hard Exit-SetupFailure in setup.ps1, justified as required by pip for
git+https:// deps and by npm. Neither holds on the consumer path: the unsloth-zoo
git+https URL is only used under STUDIO_LOCAL_INSTALL, node is a pinned
nodejs.org prebuilt that never touches system npm, and the frontend lockfile has
no VCS dependencies. It stays fatal for --local, where it really is needed.

Ensure-VCRedist was winget-only, so on hosts without winget (LTSC, Server,
managed corporate images) it silently did nothing while the install reported
success, and torch then failed to import on a missing VCRUNTIME140.dll. Adds a
direct aka.ms/vs/17/release/vc_redist.<arch>.exe download with /quiet /norestart,
accepting exit codes 0 and 3010. The redistributable stays required: it is the
runtime the prebuilt llama-server and torch link against, not the MSVC compiler,
which is already detection-only.

Windows on ARM has no PyTorch at all. Measured with uv against
download.pytorch.org/whl/cpu and PyPI for aarch64-pc-windows-msvc / cp313: torch,
torchvision and torchaudio all resolve to nothing, wheels exist only for
win_amd64 and the manylinux targets. The installer burned three uv retries on an
unsatisfiable resolution and reported a bare 'Failed to install PyTorch (exit
code 1)'. Now it says what is actually wrong and points at --no-torch, which
works because llama.cpp does publish windows-arm64-cpu.

install_node_prebuilt.py hit '[WinError 5] Access is denied' on os.replace of the
freshly extracted directory during a FRESH install, which is a scanner or indexer
holding handles for a moment. Retries only winerror 5, 32 and 145 with capped
exponential backoff; any other OSError still raises immediately.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Give the ARM64 dead end a recovery that works for web installs

The only remedy printed was .\install.ps1 --no-torch, but the documented path is
irm | iex, where no file exists and flags cannot be forwarded. Name the env var
the script already honours at line 145.

* Windows on ARM: drop torchaudio, do not abort the install

The fail-fast was based on a wrong premise. Counted against
download.pytorch.org/whl/cpu: torch has 42 win_arm64 wheels and torchvision 60;
only torchaudio has none. PyTorch has shipped Arm-native Windows builds since
April 2025, so aborting blocked a platform that mostly works. Drop the one
unsatisfiable pin instead.

Decide from the interpreter uv will resolve for, not the PowerShell host: an x64
CPython under emulation gets working win_amd64 wheels on an ARM64 box, and
powershell.exe inherits PROCESSOR_ARCHITECTURE from its parent.

* Carry the ARM64 torchaudio omission into studio setup

Dropping it from the first PyTorch command was not enough: install.ps1 then runs
studio setup with SKIP_STUDIO_BASE=1 and setup.ps1 reinstalls the bare trio from
the CPU index, so the ARM64 path still aborted. Apply the same interpreter-based
test there. An unreadable platform keeps the full trio.

* Build the torch spec list outside the verbose branch

The ARM64 guard landed inside `if ($script:UnslothVerbose)`, so on the default
path $_torchTrio was never assigned and the splat expanded to nothing: uv ran as
`uv pip install --index-url ...` with no package, exit 2, straight to
Exit-SetupFailure. That broke the ordinary Windows install. Hoist it above the
branch and use substep, which prints on both paths.

Realign the two parity guards to the splat form; they asserted the pre-refactor
literal command and were the actual cause of the red parity legs. Both halves
are still checked: the bounded list is built, and it reaches the install.

* Tighten the comments on the Windows install path

* Windows install: honour the ARM64 torchaudio skip everywhere and keep git for source builds

Hoist the venv-interpreter platform probe above every torch branch in
studio/setup.ps1 so the win_arm64 torchaudio omission applies to the ROCm,
CPU and CUDA/custom paths. A pinned index whose leaf is not cpu routed an
ARM64 host into the CUDA/custom branch, which still asked for torchaudio.

Require git again when a llama.cpp source build is opted into up front
(UNSLOTH_LLAMA_FORCE_COMPILE, UNSLOTH_LLAMA_PR / PR_FORCE, a non-upstream
source). Those paths git clone in phase 4, so setup used to report git as
not required, install the build toolchain, then fail at the clone. A local
llama.cpp dir overrides them, and the automatic source fallback after a
failed prebuilt download stays non-fatal.

Also tighten the comments across the changed install paths.

* Install the x64 VC++ runtime unconditionally in the direct-download fallback

The winget branch always installs Microsoft.VCRedist.2015+.x64, but the
direct-download fallback picked the package from PROCESSOR_ARCHITECTURE, which
reports the architecture of the running PowerShell process rather than the
interpreter that will load the DLLs. Find-CompatiblePython in install.ps1
selects an interpreter on version and non-Conda status alone, with no
architecture predicate, so a native ARM64 shell can settle on an emulated x64
Python whose win_amd64 torch and prebuilt llama-server need the x64 runtime,
while the fallback had just installed the ARM64-only package. Ensure-VCRedist
also runs well before the venv exists, so the interpreter cannot be probed at
that point. Microsoft ships the x64 redistributable as an Arm64X superset that
carries both ARM64 and x64 binaries, so it is correct on both machines and the
manual instruction printed on failure already pointed at it.

* Windows on ARM: prefer an x64 Python interpreter

An ARM64 host cannot complete the install with a native ARM64 interpreter.
pyarrow, pulled in by unsloth -> datasets, has never published a win_arm64
wheel on any version, and neither has hf-transfer, a direct dependency.
Both therefore fall back to a source build: pyarrow dies in scikit-build-core
CMake configuration and hf-transfer dies in openssl-sys for want of perl,
several minutes into a run that looked healthy. torch and torchvision are
not the problem, they have win_arm64 wheels and install fine.

Windows 11 on ARM runs x64 binaries under emulation and both packages ship
win_amd64 wheels, so an x64 interpreter installs cleanly.

Find-CompatiblePython accepted an interpreter on version and non-Conda
status alone. It now ranks candidates by architecture on ARM64 hosts and
returns an x64 one when present, asking each interpreter for its own
sysconfig.get_platform() rather than guessing from its path. Host
architecture comes from PROCESSOR_ARCHITEW6432 and OSArchitecture as well
as PROCESSOR_ARCHITECTURE, which describes only the current process and
reads AMD64 in an emulated shell.

This is a preference, not a requirement. If only ARM64 is found, x64 is
bootstrapped through winget --architecture x64 or the python.org fallback,
and if neither works the installer names pyarrow and hf-transfer up front
instead of failing later on a CMake or Rust error. The ARM64 torchaudio
skip stays live for that path.

Non-ARM hosts return on the first match exactly as before, with no extra
interpreter probing.

* Windows install: three correctness fixes on the ARM64 and git-less paths

Ensure-VCRedist never reached its x64 download on an ARM64 machine that already
had the arm64 redistributable: Test-VCRedistInstalled accepted System32\vcruntime140_1.dll
regardless of architecture, and there that file can be the pure-ARM64 package. An
ARM64 PE cannot load into an emulated x64 process, so the x64 Python this branch now
prefers would have been left without a usable runtime. The x64 registry entry is the
only x64-specific proof, and Microsoft registers Runtimes\{x86|x64|arm64} per
architecture, so vc_redist.x64.exe still writes Runtimes\x64 on an ARM64 host and the
check cannot loop. The DLL probe stays for x64 hosts.

Phase 1 demanded git for any non-blank UNSLOTH_LLAMA_PR_FORCE, but the promotion that
actually turns it into a source build requires a positive integer, so PR_FORCE=0 or a
non-numeric value aborted a git-less consumer install for a build that never runs. Both
sites now use the same predicate.

The automatic fallback after a failed prebuilt llama.cpp download reached git clone with
no git check anywhere in between, and Invoke-SetupCommand returns 0 for a command-not-found,
so a git-less host did not stop there: it continued into an empty directory and reported a
cmake configure failure instead. Git is now resolved where the source build is decided,
with a last winget attempt, and a missing git degrades exactly like a missing cmake rather
than aborting, since the opt-in source triggers already required git in Phase 1.

Also tightened the comments across the changed Windows install code, keeping the reasons
on the guards that prevent a specific failure.

* Rank ARM64 Python candidates by minor version before architecture

The x64 preference filtered the whole candidate list on architecture, which
outranks the version preference the candidates were collected in. With
UNSLOTH_PYTHON=3.12 on a Windows ARM64 box holding an ARM64 3.12 and an x64
3.13, it returned the x64 3.13: the explicit pin was silently broken, and
because a x64 interpreter was found the caller never ran Install-X64Python
to fetch an x64 3.12. With no pin it was worse still, since an x64 3.11
outranked a newer ARM64 3.13 and defeated the newest-first fallback.

Walk $minors in order and take the x64 build of the best minor available,
falling back to that minor's ARM64 build so the caller bootstraps x64 for
the version actually requested. x64 still wins within a minor, and non-ARM
hosts are untouched.

* Windows install: see every registered Python, order git before the toolchain

Find-CompatiblePython only ever probed `py -3.X`, which runs the launcher's
preferred build for that minor. On an ARM64 box that is the native ARM64
interpreter, so a same-minor x64 install that is registered with the launcher
but neither preferred nor on PATH never became a candidate. The x64 preference
then lost to ARM64, and Install-X64Python re-downloaded an x64 CPython that was
already on the machine; when that download is unavailable the install continues
on ARM64 and source-builds pyarrow and hf-transfer, which publish no win_arm64
wheels. Enumerate `py -0p` on ARM64 hosts and probe each listed path. The
`-3.12-64` suffix cannot be used for this: it has meant "not 32-bit" since 3.11
and does not distinguish arm64 from amd64.

studio/setup.ps1 ran Ensure-BuildToolsForLlamaSourceBuild before checking git in
Phase 4. That helper calls Exit-SetupFailure when Visual Studio Build Tools
cannot be installed, so on a clean no-winget box the git degraded path added by
this PR was unreachable and a standalone update aborted instead of finishing in
limited mode; where winget does exist it spent a multi-GB Build Tools download on
a clone that could never run. Check and install git first, skip the toolchain
helper when git is still missing, and report the git branch before the cmake
branch so the message names the real cause.

_swap_into_place retried the forward rename for about 16 seconds but rolled back
with a bare os.replace. A scanner holding the backup for the same WinError 5/32
then left no install_dir at all and stranded the working runtime in .old-*, and
its exception replaced the original failure. The rollback now uses the same
backoff and logs instead of masking the error it is recovering from.

* Installer: use an already installed x64 Python on ARM64 when none can be downloaded

Find-CompatiblePython ranks x64 within one minor and returns the native build
when that minor is ARM64-only, leaving Install-X64Python to bootstrap x64. On an
offline or winget-less box that bootstrap fails, and the retry went through the
same resolver, so an x64 build of a lower-priority supported minor already on the
machine was never picked up and setup continued on ARM64 Python, where pyarrow
and hf-transfer have no wheels.

Add an -X64Only mode that returns the best installed x64 interpreter or nothing,
and call it as the last resort in Install-X64Python. The version-first preference
is unchanged: x64 of the requested minor is still bootstrapped first.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten comments in the Windows ARM64 installer changes

* Setup: require Git for a source build behind an unbuilt local llama.cpp dir

UNSLOTH_LOCAL_LLAMA_CPP_DIR only overrides the source-build opt-ins once the
directory holds a reusable llama-server.exe. Pointing it at the canonical
install location with nothing built there falls through to the normal install,
so the Phase 1 gate now probes the same layout candidates as the Phase 4 reuse
check before dropping the requirement.

* Setup: require Git when UNSLOTH_LLAMA_TAG=master forces a source build

* Tighten comments in the Windows installer changes

* Setup: negotiate TLS 1.2 for the direct VC++ runtime download

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-07-28 22:24:40 -07:00

855 lines
35 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Logic tests for studio/install_node_prebuilt.py -- the isolated Node installer.
# No network/GPU: downloads are monkeypatched and archives are built in-memory.
import importlib.util
import io
import json
import os
import sys
import tarfile
import types
import zipfile
from pathlib import Path
import pytest
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
MODULE_PATH = PACKAGE_ROOT / "studio" / "install_node_prebuilt.py"
SPEC = importlib.util.spec_from_file_location("studio_install_node_prebuilt", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
M = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = M
SPEC.loader.exec_module(M)
HostInfo = M.HostInfo
PrebuiltFallback = M.PrebuiltFallback
def _host(node_os: str, node_arch: str) -> HostInfo:
ext = ".zip" if node_os == "win" else ".tar.gz"
return HostInfo(
system = {"linux": "Linux", "darwin": "Darwin", "win": "Windows"}[node_os],
machine = node_arch,
node_os = node_os,
node_arch = node_arch,
archive_ext = ext,
is_windows = node_os == "win",
)
# ── Host detection (per OS/arch) ──
@pytest.mark.parametrize(
"system,machine,exp_os,exp_arch,exp_ext",
[
("Linux", "x86_64", "linux", "x64", ".tar.gz"),
("Linux", "aarch64", "linux", "arm64", ".tar.gz"),
("Darwin", "x86_64", "darwin", "x64", ".tar.gz"),
("Darwin", "arm64", "darwin", "arm64", ".tar.gz"),
("Windows", "AMD64", "win", "x64", ".zip"),
("Windows", "ARM64", "win", "arm64", ".zip"),
],
)
def test_detect_host(monkeypatch, system, machine, exp_os, exp_arch, exp_ext):
monkeypatch.setattr(M.platform, "system", lambda: system)
monkeypatch.setattr(M.platform, "machine", lambda: machine)
host = M.detect_host()
assert (host.node_os, host.node_arch, host.archive_ext) == (exp_os, exp_arch, exp_ext)
assert host.is_windows == (exp_os == "win")
@pytest.mark.parametrize(
"system,machine",
[("Plan9", "x86_64"), ("Linux", "sparc64"), ("Linux", "armv7l"), ("Linux", "armhf")],
)
def test_detect_host_unsupported(monkeypatch, system, machine):
monkeypatch.setattr(M.platform, "system", lambda: system)
monkeypatch.setattr(M.platform, "machine", lambda: machine)
with pytest.raises(PrebuiltFallback):
M.detect_host()
# ── URL / asset construction (pure) ──
def test_asset_and_url_linux():
host = _host("linux", "x64")
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-linux-x64.tar.gz"
assert (
M.node_download_url("24.17.0", M.node_asset_name("24.17.0", host))
== "https://nodejs.org/dist/v24.17.0/node-v24.17.0-linux-x64.tar.gz"
)
def test_asset_windows_is_zip():
host = _host("win", "x64")
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-win-x64.zip"
def test_shasums_url():
assert M.node_shasums_url("24.17.0") == "https://nodejs.org/dist/v24.17.0/SHASUMS256.txt"
def test_binary_layout_is_host_aware():
# Windows ships node.exe + node_modules\npm at the root; Unix uses bin/ + lib/.
win = _host("win", "x64")
nix = _host("linux", "x64")
assert M.node_binary_path(Path("/n"), win) == Path("/n/node.exe")
assert M.node_binary_path(Path("/n"), nix) == Path("/n/bin/node")
assert M.npm_cli_path(Path("/n"), win) == Path("/n/node_modules/npm/bin/npm-cli.js")
assert M.npm_cli_path(Path("/n"), nix) == Path("/n/lib/node_modules/npm/bin/npm-cli.js")
# ── SHASUMS256.txt parsing ──
def test_expected_sha256_for():
asset = "node-v24.17.0-linux-x64.tar.gz"
good = "a" * 64
text = (
f"{'b' * 64} node-v24.17.0-linux-arm64.tar.gz\n"
f"{good} {asset}\n"
f"{'c' * 64} node-v24.17.0-win-x64.zip\n"
)
assert M.expected_sha256_for(text, asset) == good
assert M.expected_sha256_for(text, "node-v24.17.0-darwin-x64.tar.gz") is None
def test_expected_sha256_rejects_malformed():
asset = "node-v24.17.0-linux-x64.tar.gz"
assert M.expected_sha256_for(f"notahex {asset}\n", asset) is None
# ── Version selection from index.json ──
INDEX = [
{"version": "v26.3.1", "lts": False},
{"version": "v24.18.0", "lts": "Krypton"},
{"version": "v24.9.0", "lts": "Krypton"},
{"version": "v22.20.0", "lts": "Jod"},
{"version": "v20.19.0", "lts": "Iron"},
]
def test_select_lts_respects_min_major():
# Newest LTS at/above 24 -> 24.18.0 (22.x LTS is below the floor).
assert M.select_node_version(INDEX, channel = "lts", min_major = 24) == "24.18.0"
def test_select_latest_overall():
assert M.select_node_version(INDEX, channel = "latest", min_major = 24) == "26.3.1"
def test_select_explicit_passthrough():
assert M.select_node_version(INDEX, channel = "v24.5.0", min_major = 24) == "24.5.0"
def test_select_no_candidate_raises():
with pytest.raises(PrebuiltFallback):
M.select_node_version(INDEX, channel = "lts", min_major = 99)
# ── Archive extraction (zip + tar.gz with the npm-style symlink), traversal guard ──
def _add_file(
tar: tarfile.TarFile,
name: str,
data: bytes,
mode: int = 0o644,
):
info = tarfile.TarInfo(name)
info.size = len(data)
info.mode = mode
tar.addfile(info, io.BytesIO(data))
def _add_symlink(tar: tarfile.TarFile, name: str, target: str):
info = tarfile.TarInfo(name)
info.type = tarfile.SYMTYPE
info.linkname = target
tar.addfile(info)
@pytest.mark.skipif(
os.name == "nt",
reason = "Node ships a .zip (no symlinks) on Windows; the tar+symlink path is Unix-only",
)
def test_extract_tar_gz_with_npm_symlink(tmp_path: Path):
# Mirrors the real Node tarball: bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js
archive = tmp_path / "node.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
_add_file(tar, "node-v24/bin/node", b"#!/bin/sh\necho v24.17.0\n", mode = 0o755)
_add_file(tar, "node-v24/lib/node_modules/npm/bin/npm-cli.js", b"// npm")
_add_symlink(tar, "node-v24/bin/npm", "../lib/node_modules/npm/bin/npm-cli.js")
dest = tmp_path / "out"
M.extract_archive(archive, dest)
npm_link = dest / "node-v24" / "bin" / "npm"
assert npm_link.is_symlink()
assert (dest / "node-v24" / "bin" / "node").exists()
# executable bit preserved
assert (dest / "node-v24" / "bin" / "node").stat().st_mode & 0o111
def test_extract_zip(tmp_path: Path):
archive = tmp_path / "node.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("node-v24-win-x64/node.exe", b"MZ")
zf.writestr("node-v24-win-x64/npm.cmd", b"@echo off")
dest = tmp_path / "out"
M.extract_archive(archive, dest)
assert (dest / "node-v24-win-x64" / "node.exe").exists()
def test_extract_rejects_path_traversal(tmp_path: Path):
archive = tmp_path / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
_add_file(tar, "../escape.txt", b"pwn")
with pytest.raises(PrebuiltFallback):
M.extract_archive(archive, tmp_path / "out")
# ── Checksum-verified download (accept + reject) ──
def test_download_file_verified_accepts_match(tmp_path: Path, monkeypatch):
payload = b"real-node-archive"
sha = M.hashlib.sha256(payload).hexdigest()
def fake_download(url: str, destination: Path):
destination.write_bytes(payload)
monkeypatch.setattr(M, "download_file", fake_download)
dest = tmp_path / "a.tar.gz"
M.download_file_verified("http://x/a.tar.gz", dest, expected_sha256 = sha, label = "a")
assert dest.read_bytes() == payload
def test_download_file_verified_rejects_mismatch(tmp_path: Path, monkeypatch):
def fake_download(url: str, destination: Path):
destination.write_bytes(b"tampered")
monkeypatch.setattr(M, "download_file", fake_download)
with pytest.raises(PrebuiltFallback):
M.download_file_verified("http://x/a", tmp_path / "a", expected_sha256 = "0" * 64, label = "a")
# ── Lock liveness probe (Windows must not use os.kill(pid, 0)) ──
def test_pid_is_alive_windows_uses_tasklist_not_os_kill(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
def fail_kill(pid, sig):
raise AssertionError("Windows liveness must not call os.kill(pid, 0)")
def fake_run(cmd, **kwargs):
assert cmd[:2] == ["tasklist", "/FI"]
assert "PID eq 1234" in cmd
return types.SimpleNamespace(stdout = '"node.exe","1234","Console","1","12,345 K"\n')
monkeypatch.setattr(M.os, "kill", fail_kill)
monkeypatch.setattr(M.subprocess, "run", fake_run)
assert M._pid_is_alive(1234) is True
def test_pid_is_alive_windows_false_when_tasklist_omits_pid(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
monkeypatch.setattr(
M.subprocess,
"run",
lambda *a, **k: types.SimpleNamespace(
stdout = "INFO: No tasks are running which match the specified criteria.\n"
),
)
assert M._pid_is_alive(1234) is False
def test_pid_is_alive_windows_assumes_alive_when_tasklist_fails(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
def boom(*args, **kwargs):
raise OSError("tasklist unavailable")
monkeypatch.setattr(M.subprocess, "run", boom)
assert M._pid_is_alive(1234) is True
def test_pid_is_alive_posix_signal_zero(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "linux")
calls = []
def fake_kill(pid, sig):
calls.append((pid, sig))
if pid == 9999:
raise ProcessLookupError
monkeypatch.setattr(M.os, "kill", fake_kill)
assert M._pid_is_alive(1234) is True
assert M._pid_is_alive(9999) is False
assert calls == [(1234, 0), (9999, 0)]
# ── existing_install_matches + install_prebuilt short-circuit ──
def test_existing_install_matches_false_without_metadata(tmp_path: Path):
host = _host("linux", "x64")
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
def test_existing_install_matches_true_when_version_and_runtime_ok(tmp_path: Path, monkeypatch):
host = _host("linux", "x64")
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is True
# npm too old -> not a match
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
def test_install_prebuilt_short_circuits_when_version_matches(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
version = M.pinned_default_version(M.load_pins()) # == INDEX's newest LTS
asset = M.node_asset_name(version, _host("linux", "x64"))
pin = M.pinned_sha256(M.load_pins(), version, asset) # short-circuit now needs the pin
M.write_metadata(install_dir, version = version, asset = asset, sha256 = pin)
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: version)
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
def boom(*a, **k):
raise AssertionError("must not download when the install already matches")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_existing_install_usable_is_version_agnostic(tmp_path: Path, monkeypatch):
host = _host("linux", "x64")
assert M.existing_install_usable(tmp_path, host) is False # no metadata
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_usable(tmp_path, host) is True
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
assert M.existing_install_usable(tmp_path, host) is False # npm below floor
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: None)
assert M.existing_install_usable(tmp_path, host) is False # node does not run
def _offline(*a, **k):
raise OSError("nodejs.org unreachable")
def test_install_prebuilt_keeps_existing_when_index_unreachable(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "fetch_json", _offline)
def boom(*a, **k):
raise AssertionError("must not download when keeping the existing install")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_reraises_when_index_unreachable_and_no_install(
tmp_path: Path, monkeypatch
):
install_dir = tmp_path / "node" # nothing on disk to fall back to
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
def test_install_prebuilt_force_does_not_keep_existing_offline(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "fetch_json", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = True)
@pytest.mark.parametrize(
"ver,ok",
[
("20.19.0", True),
("20.18.9", False),
("22.12.0", True),
("22.11.5", False),
("23.0.0", True),
("24.4.1", True),
("21.7.3", False),
("24", True),
("20", False),
],
)
def test_meets_node_floor(ver, ok):
assert M._meets_node_floor(ver) is ok
def test_install_prebuilt_rejects_explicit_below_floor(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
def boom(*a, **k):
raise AssertionError("must not download a below-floor Node")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
with pytest.raises(PrebuiltFallback):
M.install_prebuilt(install_dir, channel = "20.18.0", min_major = 24, force = False)
def test_install_prebuilt_keeps_existing_when_download_fails(tmp_path: Path, monkeypatch):
# Archive download fails but a usable older Node is on disk -> keep it.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.9.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # newest LTS = 24.18.0 (pinned)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.9.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "download_file_verified", _offline) # archive download fails
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_reraises_download_failure_without_existing(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node" # nothing usable on disk
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "download_file_verified", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
# ── Isolation invariant: the installer only writes inside its own install_dir ──
def test_run_node_pins_npm_prefix_to_install_dir(tmp_path: Path, monkeypatch):
# Every node/npm call the installer makes redirects npm's global prefix into
# the isolated install_dir and drops an inherited NODE_PATH, so a stray `npm
# -g` can never write to the user's system Node/npm.
install_dir = tmp_path / "node"
monkeypatch.setenv("NPM_CONFIG_PREFIX", "/usr/local") # user's own global prefix
monkeypatch.setenv("NODE_PATH", "/usr/lib/node_modules")
captured = {}
def fake_run(cmd, **kw):
captured["env"] = kw["env"]
return types.SimpleNamespace(returncode = 0, stdout = "v24.17.0\n", stderr = "")
monkeypatch.setattr(M.subprocess, "run", fake_run)
assert M._run_node(install_dir, _host("linux", "x64"), ["-v"]) == "v24.17.0"
env = captured["env"]
assert env["NPM_CONFIG_PREFIX"] == str(install_dir)
assert env["npm_config_prefix"] == str(install_dir)
assert "NODE_PATH" not in env # inherited NODE_PATH is dropped, not leaked in
def test_ensure_npm_floor_scopes_upgrade_to_install_dir(tmp_path: Path, monkeypatch):
# A pinned build shipping npm < 11 self-upgrades, but only inside the isolated
# prefix: it goes through _run_node against install_dir, never the system.
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
calls = []
monkeypatch.setattr(M, "_run_node", lambda d, h, args, **kw: calls.append((d, args)) or "")
M._ensure_npm_floor(install_dir, _host("linux", "x64"))
assert len(calls) == 1
target_dir, args = calls[0]
assert target_dir == install_dir # upgrade scoped to the isolated dir
assert args[-3:] == ["install", "-g", f"npm@^{M.NPM_MIN_MAJOR}"]
def test_ensure_npm_floor_noop_when_npm_meets_bar(tmp_path: Path, monkeypatch):
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: M.NPM_MIN_MAJOR)
def boom(*a, **k):
raise AssertionError("must not run an npm upgrade when npm already meets the floor")
monkeypatch.setattr(M, "_run_node", boom)
M._ensure_npm_floor(tmp_path / "node", _host("linux", "x64"))
# ── Pinned digest manifest (trust anchor) ──
# Archives must be verified against committed pins, never a same-origin re-fetch.
ALL_SUPPORTED_HOSTS = [
("linux", "x64"),
("linux", "arm64"),
("darwin", "x64"),
("darwin", "arm64"),
("win", "x64"),
("win", "arm64"),
]
def test_load_pins_exposes_valid_default_version():
pins = M.load_pins()
version = M.pinned_default_version(pins)
assert M._version_tuple(version) # parses as a real version
assert M._meets_node_floor(version) # the pinned default clears the build floor
def test_pinned_manifest_covers_every_supported_asset():
# A future Node bump must pin all six os/arch archives or a host loses its anchor.
pins = M.load_pins()
version = M.pinned_default_version(pins)
for node_os, node_arch in ALL_SUPPORTED_HOSTS:
host = _host(node_os, node_arch)
asset = M.node_asset_name(version, host)
digest = M.pinned_sha256(pins, version, asset)
assert digest is not None and len(digest) == 64, f"missing pin for {asset}"
assert all(c in "0123456789abcdef" for c in digest)
def test_pinned_sha256_unknown_pair_returns_none():
pins = M.load_pins()
assert M.pinned_sha256(pins, "99.0.0", "node-v99.0.0-linux-x64.tar.gz") is None
version = M.pinned_default_version(pins)
assert M.pinned_sha256(pins, version, "node-vX-bogus-arch.tar.gz") is None
def test_load_pins_rejects_bad_schema(tmp_path: Path, monkeypatch):
bad = tmp_path / "node_prebuilt_pins.json"
bad.write_text(json.dumps({"schema_version": 999}))
monkeypatch.setattr(M, "pins_path", lambda: bad)
with pytest.raises(PrebuiltFallback):
M.load_pins()
def test_load_pins_raises_when_missing(tmp_path: Path, monkeypatch):
monkeypatch.setattr(M, "pins_path", lambda: tmp_path / "does_not_exist.json")
with pytest.raises(PrebuiltFallback):
M.load_pins()
def test_resolve_expected_sha256_uses_pin_without_touching_network(monkeypatch):
pins = M.load_pins()
version = M.pinned_default_version(pins)
asset = M.node_asset_name(version, _host("linux", "x64"))
def boom(*a, **k):
raise AssertionError("pinned path must not fetch the remote SHASUMS256.txt")
monkeypatch.setattr(M, "download_bytes", boom)
sha = M.resolve_expected_sha256(pins, version, asset, allow_unverified = False)
assert sha == M.pinned_sha256(pins, version, asset)
def test_resolve_expected_sha256_failcloses_on_unpinned(monkeypatch):
pins = M.load_pins()
def boom(*a, **k):
raise AssertionError("must not reach the network when refusing an unpinned version")
monkeypatch.setattr(M, "download_bytes", boom)
with pytest.raises(PrebuiltFallback):
M.resolve_expected_sha256(
pins, "26.3.1", "node-v26.3.1-linux-x64.tar.gz", allow_unverified = False
)
def test_resolve_expected_sha256_optin_falls_back_to_remote_shasums(monkeypatch):
pins = M.load_pins()
asset = "node-v26.3.1-linux-x64.tar.gz"
remote_sha = "d" * 64
monkeypatch.setattr(M, "download_bytes", lambda url, **k: f"{remote_sha} {asset}\n".encode())
# Only with the explicit opt-in does the legacy remote-checksum path run.
sha = M.resolve_expected_sha256(pins, "26.3.1", asset, allow_unverified = True)
assert sha == remote_sha
@pytest.mark.parametrize(
"value,expected",
[("1", True), ("true", True), ("YES", True), ("on", True), ("0", False), ("", False)],
)
def test_allow_unverified_node_reads_env(monkeypatch, value, expected):
monkeypatch.setenv(M.ALLOW_UNVERIFIED_ENV, value)
assert M.allow_unverified_node() is expected
def test_install_prebuilt_default_channel_resolves_pinned_version(tmp_path: Path, monkeypatch):
# Default channel installs the pinned version with no index.json round-trip.
pins = M.load_pins()
version = M.pinned_default_version(pins)
install_dir = tmp_path / "node"
install_dir.mkdir()
asset = M.node_asset_name(version, _host("linux", "x64"))
pin = M.pinned_sha256(pins, version, asset) # kept only if the recorded digest is the pin
M.write_metadata(install_dir, version = version, asset = asset, sha256 = pin)
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: version)
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
def boom(*a, **k):
raise AssertionError("default channel must not hit nodejs.org when the install matches")
monkeypatch.setattr(M, "fetch_json", boom) # no index.json
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "pinned", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_failcloses_on_unpinned_latest(tmp_path: Path, monkeypatch):
# Unpinned `latest`, no opt-in, nothing on disk to keep: refuse.
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # latest overall = 26.3.1 (unpinned)
monkeypatch.delenv(M.ALLOW_UNVERIFIED_ENV, raising = False)
def boom(*a, **k):
raise AssertionError("must not download an unpinned archive")
monkeypatch.setattr(M, "download_file", boom)
with pytest.raises(M.UnpinnedNodeRefused):
M.install_prebuilt(install_dir, channel = "latest", min_major = 24, force = False)
@pytest.mark.parametrize("channel", ["latest", "26.3.1"])
def test_install_prebuilt_unpinned_refusal_does_not_keep_existing(
tmp_path: Path, monkeypatch, channel
):
# Regression: an unpinned refusal must fail closed even with a usable install on
# disk; the keep-existing fallback is for transient failures only.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.9.0", asset = "old", sha256 = "old")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # latest overall = 26.3.1 (unpinned)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.9.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11) # existing install is usable
monkeypatch.delenv(M.ALLOW_UNVERIFIED_ENV, raising = False)
def boom(*a, **k):
raise AssertionError("must not download an unpinned archive")
monkeypatch.setattr(M, "download_file", boom)
with pytest.raises(M.UnpinnedNodeRefused):
M.install_prebuilt(install_dir, channel = channel, min_major = 24, force = False)
def test_unpinned_refusal_maps_to_fallback_exit_code(tmp_path: Path, monkeypatch, capsys):
# main() must surface the refusal as EXIT_FALLBACK (setup treats it as a failed
# install with guidance), not as a success masked by the keep-existing path.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.9.0", asset = "old", sha256 = "old")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.9.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.delenv(M.ALLOW_UNVERIFIED_ENV, raising = False)
rc = M.main(["--install-dir", str(install_dir), "--node-version", "latest"])
assert rc == M.EXIT_FALLBACK
# Guard the main() catch order: UnpinnedNodeRefused must be caught before the
# generic PrebuiltFallback, so assert the message, not just the exit code.
out = capsys.readouterr().out
assert "refusing to install Node" in out
assert "prebuilt unavailable" not in out
def test_resolve_expected_sha256_rejects_malformed_pins():
# A structurally-broken manifest is "not pinned" (None), never a bogus digest.
asset = "node-v24.17.0-linux-x64.tar.gz"
assert M.pinned_sha256({"versions": "nope"}, "24.17.0", asset) is None
assert M.pinned_sha256({"versions": {"24.17.0": "nope"}}, "24.17.0", asset) is None
assert M.pinned_sha256({"versions": {"24.17.0": {asset: "x" * 63}}}, "24.17.0", asset) is None
assert M.pinned_sha256({"versions": {"24.17.0": {asset: "z" * 64}}}, "24.17.0", asset) is None
# an uppercase but otherwise valid digest is normalized to lowercase
up = "A" * 64
assert M.pinned_sha256({"versions": {"24.17.0": {asset: up}}}, "24.17.0", asset) == up.lower()
for bad in [{}, {"default_version": ""}, {"default_version": "not-a-version"}]:
with pytest.raises(PrebuiltFallback):
M.pinned_default_version(bad)
def test_install_prebuilt_optin_takes_remote_shasums_path(tmp_path: Path, monkeypatch):
# With the opt-in set, an unpinned version drives the remote-SHASUMS path end to
# end: fetch SHASUMS256.txt, then the verified archive download (no refusal).
install_dir = tmp_path / "node" # nothing on disk -> errors re-raise, not keep-existing
asset = "node-v26.3.1-linux-x64.tar.gz"
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # latest = 26.3.1 (unpinned)
monkeypatch.setenv(M.ALLOW_UNVERIFIED_ENV, "1")
shasums_fetched = {"n": 0}
def fake_shasums(url, **k):
shasums_fetched["n"] += 1
return f"{'d' * 64} {asset}\n".encode()
class _ReachedDownload(Exception):
pass
def reached(*a, **k):
raise _ReachedDownload
monkeypatch.setattr(M, "download_bytes", fake_shasums)
monkeypatch.setattr(M, "download_file_verified", reached)
with pytest.raises(_ReachedDownload):
M.install_prebuilt(install_dir, channel = "latest", min_major = 24, force = False)
assert shasums_fetched["n"] == 1 # the opt-in path fetched the remote SHASUMS
def test_pins_manifest_ships_next_to_installer():
# The committed manifest must sit beside the installer so __file__ resolution finds it.
assert M.pins_path() == MODULE_PATH.parent / M.PINS_FILENAME
assert M.pins_path().is_file()
def test_pins_manifest_is_declared_in_package_data():
# An unpackaged trust anchor is no trust anchor: a pip install must ship it.
# tomllib is stdlib only on 3.11+; fall back to tomli, else skip on 3.9/3.10.
tomllib = pytest.importorskip("tomllib" if sys.version_info >= (3, 11) else "tomli")
data = tomllib.loads((PACKAGE_ROOT / "pyproject.toml").read_text(encoding = "utf-8"))
studio_globs = data["tool"]["setuptools"]["package-data"]["studio"]
assert M.PINS_FILENAME in studio_globs
def test_existing_install_matches_enforces_expected_sha(tmp_path: Path, monkeypatch):
# The short-circuit must not keep a version-matching install whose recorded digest
# is not the pin (old remote-SHASUMS install or a tampered artifact).
host = _host("linux", "x64")
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "aa")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is True # back-compat
assert M.existing_install_matches(tmp_path, host, version = "24.17.0", expected_sha = "aa") is True
assert M.existing_install_matches(tmp_path, host, version = "24.17.0", expected_sha = "bb") is False
def test_install_prebuilt_refuses_existing_unpinned_install(tmp_path: Path, monkeypatch):
# Codex P2: an unpinned version already on disk must still fail closed without the
# opt-in, not be kept by the version-only short-circuit.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "26.3.1", asset = "a", sha256 = "s")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "26.3.1")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.delenv(M.ALLOW_UNVERIFIED_ENV, raising = False)
def boom(*a, **k):
raise AssertionError("must not keep or download an unpinned install")
monkeypatch.setattr(M, "download_file", boom)
with pytest.raises(M.UnpinnedNodeRefused):
M.install_prebuilt(install_dir, channel = "26.3.1", min_major = 24, force = False)
def test_pinned_target_wrong_sha_not_kept_when_download_fails(tmp_path: Path, monkeypatch):
# Symmetry with the short-circuit guard: the transient-failure fallback must not
# keep a same-version install whose recorded digest is not the pin. (A different
# usable version is still kept for offline resilience -- covered above.)
host = _host("linux", "x64")
version = M.pinned_default_version(M.load_pins())
asset = M.node_asset_name(version, host)
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = version, asset = asset, sha256 = "0" * 64) # not the pin
monkeypatch.setattr(M, "detect_host", lambda: host)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: version)
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "download_file_verified", _offline) # transient download failure
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "pinned", min_major = 24, force = False)
# ── _replace_with_retry: transient Windows sharing violations ──────────────────
# Seen in CI: WinError 5 renaming extracted Node into place on a FRESH install, a scanner
# still holding handles inside the new files.
def _oserror(winerror: int) -> OSError:
exc = OSError(winerror, "mock")
exc.winerror = winerror
return exc
@pytest.mark.parametrize("winerror", [5, 32, 145])
def test_replace_retries_transient_windows_errors(monkeypatch, tmp_path, winerror):
monkeypatch.setattr(M.os, "name", "nt")
monkeypatch.setattr(M.time, "sleep", lambda _s: None) # no real backoff in tests
calls = {"n": 0}
def flaky(src, dst):
calls["n"] += 1
if calls["n"] < 3:
raise _oserror(winerror)
monkeypatch.setattr(M.os, "replace", flaky)
M._replace_with_retry(tmp_path / "src", tmp_path / "dst")
assert calls["n"] == 3, "should have retried until the handle was released"
def test_replace_gives_up_and_reports_the_real_error(monkeypatch, tmp_path):
monkeypatch.setattr(M.os, "name", "nt")
monkeypatch.setattr(M.time, "sleep", lambda _s: None)
monkeypatch.setattr(M.os, "replace", lambda s, d: (_ for _ in ()).throw(_oserror(5)))
# A scanner that never lets go must surface as a failure, not a hang.
with pytest.raises(OSError) as excinfo:
M._replace_with_retry(tmp_path / "src", tmp_path / "dst", attempts = 3)
assert excinfo.value.winerror == 5
def test_replace_does_not_retry_a_genuine_error(monkeypatch, tmp_path):
# A cross-device move or real permissions problem must fail immediately.
monkeypatch.setattr(M.os, "name", "nt")
monkeypatch.setattr(M.time, "sleep", lambda _s: None)
calls = {"n": 0}
def hard_fail(src, dst):
calls["n"] += 1
raise _oserror(17) # ERROR_NOT_SAME_DEVICE
monkeypatch.setattr(M.os, "replace", hard_fail)
with pytest.raises(OSError):
M._replace_with_retry(tmp_path / "src", tmp_path / "dst")
assert calls["n"] == 1
def test_replace_is_a_plain_rename_on_posix(monkeypatch, tmp_path):
# POSIX has no sharing violations, so the retry must add no latency there.
monkeypatch.setattr(M.os, "name", "posix")
calls = {"n": 0}
def once(src, dst):
calls["n"] += 1
raise _oserror(5)
monkeypatch.setattr(M.os, "replace", once)
with pytest.raises(OSError):
M._replace_with_retry(tmp_path / "src", tmp_path / "dst")
assert calls["n"] == 1
def test_swap_into_place_survives_a_transient_lock(monkeypatch, tmp_path):
# End-to-end through the function the installer actually calls.
monkeypatch.setattr(M.os, "name", "nt")
monkeypatch.setattr(M.time, "sleep", lambda _s: None)
extracted = tmp_path / "extracted" / "node-v24"
extracted.mkdir(parents = True)
(extracted / "marker.txt").write_text("node", encoding = "utf-8")
install_dir = tmp_path / "node"
real_replace = os.replace
state = {"failed": False}
def flaky(src, dst):
if not state["failed"]:
state["failed"] = True
raise _oserror(32)
real_replace(src, dst)
monkeypatch.setattr(M.os, "replace", flaky)
M._swap_into_place(extracted, install_dir)
assert (install_dir / "marker.txt").read_text(encoding = "utf-8") == "node"