unsloth/studio/install_node_prebuilt.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

943 lines
36 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Cross-platform Node.js prebuilt installer for Unsloth Studio.
Downloads an official Node.js archive from nodejs.org into an isolated
``<UNSLOTH_HOME>/node`` and never touches the system Node/npm. Pinning Node 24+
LTS clears the Unsloth frontend build floor (Vite 8: Node ^20.19 || >=22.12,
npm >= 11) with the npm it bundles.
Archives are verified against sha256 digests pinned in ``node_prebuilt_pins.json``
(committed in-tree), not a checksum re-fetched from the same origin as the archive.
Mirrors ``install_llama_prebuilt.py`` so the setup scripts drive it the same way.
Exit codes: 0 success, 1 error, 2 fallback, 3 busy. A re-run that already matches
logs "already matches" and returns 0 without downloading (the scripts grep it).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import random
import shutil
import socket
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
try:
from filelock import FileLock, Timeout as FileLockTimeout
except ImportError:
FileLock = None
FileLockTimeout = None
EXIT_SUCCESS = 0
EXIT_ERROR = 1
EXIT_FALLBACK = 2
EXIT_BUSY = 3
# Node 24 LTS bundles npm 11, clearing Vite 8's floor (Node ^20.19 || >=22.12, npm >= 11).
NODE_MIN_LTS_MAJOR = 24
NPM_MIN_MAJOR = 11
NODE_DIST_BASE = "https://nodejs.org/dist"
NODE_DIST_INDEX = f"{NODE_DIST_BASE}/index.json"
RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504}
HTTP_FETCH_ATTEMPTS = 4
HTTP_FETCH_BASE_DELAY_SECONDS = 0.75
INSTALL_LOCK_TIMEOUT_SECONDS = 300
INSTALL_STAGING_ROOT_NAME = ".staging"
METADATA_FILENAME = "UNSLOTH_NODE_PREBUILT_INFO.json"
METADATA_SCHEMA_VERSION = 1
# Trust anchor: verify archives against sha256 pins committed in
# node_prebuilt_pins.json (in-tree, code-reviewed), not a same-origin checksum.
PINS_FILENAME = "node_prebuilt_pins.json"
PINS_SCHEMA_VERSION = 1
DEFAULT_NODE_CHANNEL = "pinned"
# Opt-in to install an unpinned version, trusting the same-origin SHASUMS256.txt.
ALLOW_UNVERIFIED_ENV = "UNSLOTH_NODE_ALLOW_UNVERIFIED"
# PowerShell renders stderr as NativeCommandError noise; main() flips logs to stdout.
_LOG_TO_STDOUT = False
class PrebuiltFallback(RuntimeError):
"""Recoverable failure -- caller should fall back (exit code 2)."""
class UnpinnedNodeRefused(PrebuiltFallback):
"""Unpinned Node requested without opt-in. Distinct type so the orchestrator
re-raises it instead of letting the keep-existing path swallow the refusal."""
class BusyInstallConflict(RuntimeError):
"""Another process holds the install lock (exit code 3)."""
def log(message: str) -> None:
print(f"[node-prebuilt] {message}", file = sys.stdout if _LOG_TO_STDOUT else sys.stderr)
# ── Host detection ──
@dataclass(frozen = True)
class HostInfo:
system: str # platform.system()
machine: str # lowered platform.machine()
node_os: str # nodejs.org token: linux | darwin | win
node_arch: str # nodejs.org token: x64 | arm64 | armv7l
archive_ext: str # .tar.gz | .zip
is_windows: bool
def detect_host() -> HostInfo:
system = platform.system()
machine = platform.machine().lower()
is_windows = system == "Windows"
if system == "Linux":
node_os = "linux"
elif system == "Darwin":
node_os = "darwin"
elif is_windows:
node_os = "win"
else:
raise PrebuiltFallback(f"unsupported operating system for Node prebuilt: {system}")
if machine in {"x86_64", "amd64", "x64"}:
node_arch = "x64"
elif machine in {"arm64", "aarch64"}:
node_arch = "arm64"
else:
# 32-bit ARM (armv7l) is intentionally unsupported: Node 24 LTS ships no
# linux-armv7l build, so there is nothing at/above the floor to install.
raise PrebuiltFallback(f"unsupported CPU architecture for Node prebuilt: {machine}")
# .tar.gz (not .tar.xz) on Unix so the extractor needs no xz; .zip on Windows.
archive_ext = ".zip" if is_windows else ".tar.gz"
return HostInfo(
system = system,
machine = machine,
node_os = node_os,
node_arch = node_arch,
archive_ext = archive_ext,
is_windows = is_windows,
)
# ── URL / asset construction (pure, unit tested) ──
def node_asset_stem(version: str, host: HostInfo) -> str:
"""e.g. node-v24.4.1-linux-x64 (no extension)."""
return f"node-v{version}-{host.node_os}-{host.node_arch}"
def node_asset_name(version: str, host: HostInfo) -> str:
return f"{node_asset_stem(version, host)}{host.archive_ext}"
def node_download_url(version: str, asset_name: str) -> str:
return f"{NODE_DIST_BASE}/v{version}/{asset_name}"
def node_shasums_url(version: str) -> str:
return f"{NODE_DIST_BASE}/v{version}/SHASUMS256.txt"
def expected_sha256_for(shasums_text: str, asset_name: str) -> str | None:
"""Parse a nodejs.org SHASUMS256.txt ('<hex> <filename>' per line)."""
for line in shasums_text.splitlines():
parts = line.split()
if len(parts) == 2 and parts[1] == asset_name:
digest = parts[0].lower()
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
return digest
return None
def _version_tuple(value: str) -> tuple[int, ...]:
try:
return tuple(int(p) for p in value.lstrip("v").split("."))
except ValueError:
return ()
def _meets_node_floor(version: str) -> bool:
"""True iff version clears the setup floor (^20.19 || >=22.12 || >=23)."""
parts = _version_tuple(version)
if not parts:
return False
major = parts[0]
minor = parts[1] if len(parts) > 1 else 0
return (major == 20 and minor >= 19) or (major == 22 and minor >= 12) or major >= 23
def select_node_version(index: list[dict], *, channel: str, min_major: int) -> str:
"""Pick a concrete Node version from nodejs.org index.json.
channel='lts' -> newest LTS release line whose major >= min_major.
channel='latest' -> newest release overall whose major >= min_major.
Otherwise the channel is treated as an explicit version string.
"""
if channel not in {"lts", "latest"}:
return channel.lstrip("v")
best: tuple[int, ...] | None = None
best_version: str | None = None
for entry in index:
version = str(entry.get("version", "")).lstrip("v")
parsed = _version_tuple(version)
if not parsed or parsed[0] < min_major:
continue
if channel == "lts" and not entry.get("lts"):
continue
if best is None or parsed > best:
best = parsed
best_version = version
if best_version is None:
raise PrebuiltFallback(
f"no Node '{channel}' release found at or above major {min_major} in {NODE_DIST_INDEX}"
)
return best_version
# ── HTTP (retry/backoff) ──
def _auth_headers() -> dict[str, str]:
# A User-Agent keeps some proxies/CDNs happy; nodejs.org needs no auth.
return {"User-Agent": "unsloth-studio-node-prebuilt"}
def is_retryable_url_error(exc: Exception) -> bool:
if isinstance(exc, urllib.error.HTTPError):
return exc.code in RETRYABLE_HTTP_STATUS
if isinstance(exc, (urllib.error.URLError, TimeoutError, socket.timeout)):
return True
return False
def sleep_backoff(attempt: int) -> None:
delay = HTTP_FETCH_BASE_DELAY_SECONDS * (2 ** max(attempt - 1, 0))
delay += random.uniform(0.0, 0.2)
time.sleep(delay)
def download_bytes(url: str, *, timeout: int = 60) -> bytes:
last_exc: Exception | None = None
for attempt in range(1, HTTP_FETCH_ATTEMPTS + 1):
try:
request = urllib.request.Request(url, headers = _auth_headers())
with urllib.request.urlopen(request, timeout = timeout) as response:
return response.read()
except Exception as exc: # noqa: BLE001
last_exc = exc
if attempt >= HTTP_FETCH_ATTEMPTS or not is_retryable_url_error(exc):
raise
log(f"fetch failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying")
sleep_backoff(attempt)
assert last_exc is not None
raise last_exc
def fetch_json(url: str) -> object:
return json.loads(download_bytes(url, timeout = 30).decode("utf-8"))
def atomic_replace_from_tempfile(tmp_path: Path, destination: Path) -> None:
destination.parent.mkdir(parents = True, exist_ok = True)
os.replace(tmp_path, destination)
def download_file(url: str, destination: Path) -> None:
destination.parent.mkdir(parents = True, exist_ok = True)
last_exc: Exception | None = None
for attempt in range(1, HTTP_FETCH_ATTEMPTS + 1):
tmp_path: Path | None = None
try:
request = urllib.request.Request(url, headers = _auth_headers())
with tempfile.NamedTemporaryFile(
prefix = destination.name + ".tmp-",
dir = destination.parent,
delete = False,
) as handle:
tmp_path = Path(handle.name)
with urllib.request.urlopen(request, timeout = 120) as response:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
handle.write(chunk)
handle.flush()
os.fsync(handle.fileno())
if not tmp_path.exists() or tmp_path.stat().st_size == 0:
raise RuntimeError(f"downloaded empty file from {url}")
atomic_replace_from_tempfile(tmp_path, destination)
return
except Exception as exc: # noqa: BLE001
last_exc = exc
if tmp_path is not None:
try:
tmp_path.unlink(missing_ok = True)
except Exception: # noqa: BLE001
pass
if attempt >= HTTP_FETCH_ATTEMPTS or not is_retryable_url_error(exc):
raise
log(f"download failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying")
sleep_backoff(attempt)
assert last_exc is not None
raise last_exc
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def download_file_verified(
url: str, destination: Path, *, expected_sha256: str, label: str
) -> None:
for attempt in range(1, 3):
download_file(url, destination)
actual = sha256_file(destination)
if actual == expected_sha256:
log(f"verified {label} sha256={actual}")
return
log(f"{label} checksum mismatch {attempt}/2: expected={expected_sha256} actual={actual}")
destination.unlink(missing_ok = True)
if attempt == 2:
raise PrebuiltFallback(f"{label} checksum mismatch after retry")
# ── Pinned digest manifest (trust anchor) ──
def pins_path() -> Path:
return Path(__file__).resolve().parent / PINS_FILENAME
def load_pins() -> dict:
path = pins_path()
try:
data = json.loads(path.read_text(encoding = "utf-8"))
except FileNotFoundError as exc:
raise PrebuiltFallback(f"pinned Node manifest missing: {path}") from exc
except (json.JSONDecodeError, OSError) as exc:
raise PrebuiltFallback(f"pinned Node manifest unreadable ({path}): {exc}") from exc
if not isinstance(data, dict) or data.get("schema_version") != PINS_SCHEMA_VERSION:
raise PrebuiltFallback(f"pinned Node manifest has an unexpected schema: {path}")
return data
def pinned_default_version(pins: dict) -> str:
version = str(pins.get("default_version", "")).lstrip("v")
if not _version_tuple(version):
raise PrebuiltFallback("pinned Node manifest is missing a valid 'default_version'")
return version
def pinned_sha256(pins: dict, version: str, asset_name: str) -> str | None:
versions = pins.get("versions")
if not isinstance(versions, dict):
return None
entry = versions.get(version.lstrip("v"))
if not isinstance(entry, dict):
return None
digest = entry.get(asset_name)
if not isinstance(digest, str):
return None
digest = digest.strip().lower()
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
return digest
return None
def allow_unverified_node() -> bool:
return os.environ.get(ALLOW_UNVERIFIED_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
def resolve_expected_sha256(pins: dict, version: str, asset: str, *, allow_unverified: bool) -> str:
"""Sha256 to verify the archive against: the committed pin, or (only with explicit
opt-in) the same-origin SHASUMS256.txt. Unpinned without opt-in is refused."""
pinned = pinned_sha256(pins, version, asset)
if pinned is not None:
log(f"verifying {asset} against pinned sha256 from {PINS_FILENAME}")
return pinned
if not allow_unverified:
raise UnpinnedNodeRefused(
f"refusing to install Node v{version}: {asset} is not in the pinned manifest "
f"({PINS_FILENAME}); its only checksum would arrive over the same channel as the "
f"archive. Use the pinned default version (unset UNSLOTH_NODE_VERSION), add a pin "
f"for {asset}, or set {ALLOW_UNVERIFIED_ENV}=1 to trust the upstream SHASUMS256.txt "
f"at your own risk."
)
log(
f"WARNING: {asset} is not pinned; trusting upstream SHASUMS256.txt because "
f"{ALLOW_UNVERIFIED_ENV} is set. This checksum shares the archive's origin and is "
f"not an independent integrity guarantee."
)
# A non-UTF8 body just yields no hex match below -> clean PrebuiltFallback.
shasums = download_bytes(node_shasums_url(version), timeout = 30).decode("utf-8", "replace")
expected = expected_sha256_for(shasums, asset)
if not expected:
raise PrebuiltFallback(f"no sha256 for {asset} in SHASUMS256.txt (v{version})")
return expected
# ── Safe archive extraction (zip + tar.gz, traversal/symlink guarded) ──
def _safe_extract_path(base: Path, member_name: str) -> Path:
member_path = Path(member_name.replace("\\", "/"))
if member_path.is_absolute():
raise PrebuiltFallback(f"archive member used an absolute path: {member_name}")
target = (base / member_path).resolve()
try:
target.relative_to(base.resolve())
except ValueError as exc:
raise PrebuiltFallback(f"archive member escaped destination: {member_name}") from exc
return target
def _extract_zip_safely(source: Path, base: Path) -> None:
with zipfile.ZipFile(source) as archive:
for member in archive.infolist():
target = _safe_extract_path(base, member.filename)
mode = (member.external_attr >> 16) & 0o170000
if mode == 0o120000:
raise PrebuiltFallback(f"zip archive contained a symlink entry: {member.filename}")
if member.is_dir():
target.mkdir(parents = True, exist_ok = True)
continue
target.parent.mkdir(parents = True, exist_ok = True)
with archive.open(member, "r") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst)
def _extract_tar_safely(source: Path, base: Path) -> None:
# Node Unix tarballs ship bin/npm, bin/npx, bin/corepack as relative
# symlinks into lib/node_modules; defer links and resolve after files.
pending_links: list[tuple[tarfile.TarInfo, Path]] = []
with tarfile.open(source, "r:gz") as archive:
for member in archive.getmembers():
target = _safe_extract_path(base, member.name)
if member.isdir():
target.mkdir(parents = True, exist_ok = True)
continue
if member.islnk() or member.issym():
pending_links.append((member, target))
continue
if not member.isfile():
raise PrebuiltFallback(f"tar archive contained an unsupported entry: {member.name}")
target.parent.mkdir(parents = True, exist_ok = True)
extracted = archive.extractfile(member)
if extracted is None:
raise PrebuiltFallback(f"tar archive entry could not be read: {member.name}")
with extracted, target.open("wb") as dst:
shutil.copyfileobj(extracted, dst)
if member.mode & 0o111:
os.chmod(target, target.stat().st_mode | 0o111)
for member, target in pending_links:
link_name = member.linkname.replace("\\", "/")
link_path = Path(link_name)
if link_path.is_absolute() or not link_name:
raise PrebuiltFallback(
f"archive link used an unsafe target: {member.name} -> {link_name}"
)
# tar symlink names are link-parent relative; hard-link names are archive-root relative.
resolved = (target.parent / link_path if member.issym() else base / link_path).resolve()
try:
resolved.relative_to(base.resolve())
except ValueError as exc:
raise PrebuiltFallback(
f"archive link escaped destination: {member.name} -> {link_name}"
) from exc
target.parent.mkdir(parents = True, exist_ok = True)
if target.exists() or target.is_symlink():
target.unlink()
if member.issym():
target.symlink_to(link_name)
else: # hard link
shutil.copy2(resolved, target)
def extract_archive(archive_path: Path, destination: Path) -> None:
destination.mkdir(parents = True, exist_ok = True)
if archive_path.name.endswith(".zip"):
_extract_zip_safely(archive_path, destination)
elif archive_path.name.endswith(".tar.gz"):
_extract_tar_safely(archive_path, destination)
else:
raise PrebuiltFallback(f"unsupported archive format: {archive_path.name}")
# ── Install lock (concurrent setup runs share one UNSLOTH_HOME) ──
def install_lock_path(install_dir: Path) -> Path:
return install_dir.parent / f".{install_dir.name}.install.lock"
def _pid_is_alive(pid: int) -> bool:
"""Best-effort process liveness check that never signals the process on Windows."""
if pid <= 0:
return False
if sys.platform == "win32":
try:
result = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
capture_output = True,
text = True,
timeout = 5,
**_windows_hidden_kwargs(),
)
except (OSError, ValueError, subprocess.SubprocessError):
# Be conservative if tasklist itself is unavailable.
return True
return f'"{pid}"' in result.stdout
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except ValueError:
return False
return True
@contextmanager
def install_lock(lock_path: Path) -> Iterator[None]:
lock_path.parent.mkdir(parents = True, exist_ok = True)
if FileLock is None:
fd: int | None = None
deadline = time.monotonic() + INSTALL_LOCK_TIMEOUT_SECONDS
while True:
try:
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR)
os.write(fd, f"{os.getpid()}\n".encode())
os.fsync(fd)
break
except FileExistsError:
try:
# errors="replace" so an undecodable lock reaches the int()
# below and is treated as a stale PID, not retried forever.
raw = lock_path.read_text(encoding = "utf-8", errors = "replace").strip()
except FileNotFoundError:
continue
stale = False
if raw:
try:
stale = not _pid_is_alive(int(raw))
except ValueError:
stale = True
if stale:
# Atomically rename before unlinking so only one racer removes
# the stale lock; a process recreating it loses the rename and waits.
try:
stale_path = lock_path.with_name(f"{lock_path.name}.stale.{os.getpid()}")
os.replace(str(lock_path), str(stale_path))
stale_path.unlink(missing_ok = True)
except (OSError, ValueError):
pass
continue
if time.monotonic() >= deadline:
raise BusyInstallConflict(
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for install lock: {lock_path}"
)
time.sleep(0.5)
try:
yield
finally:
if fd is not None:
os.close(fd)
lock_path.unlink(missing_ok = True)
return
try:
with FileLock(str(lock_path), timeout = INSTALL_LOCK_TIMEOUT_SECONDS):
yield
except FileLockTimeout as exc:
raise BusyInstallConflict(
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for install lock: {lock_path}"
) from exc
# ── Install layout / metadata / health ──
def node_binary_path(install_dir: Path, host: HostInfo) -> Path:
return install_dir / "node.exe" if host.is_windows else install_dir / "bin" / "node"
def npm_cli_path(install_dir: Path, host: HostInfo) -> Path:
# Windows ships npm at <root>\node_modules\npm; Unix at <root>/lib/node_modules/npm.
if host.is_windows:
return install_dir / "node_modules" / "npm" / "bin" / "npm-cli.js"
return install_dir / "lib" / "node_modules" / "npm" / "bin" / "npm-cli.js"
def _windows_hidden_kwargs() -> dict[str, object]:
if sys.platform != "win32":
return {}
kwargs: dict[str, object] = {}
flag = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if flag:
kwargs["creationflags"] = flag
return kwargs
def _run_node(
install_dir: Path,
host: HostInfo,
args: list[str],
*,
timeout: int = 120,
) -> str:
node_bin = node_binary_path(install_dir, host)
env = os.environ.copy()
# Keep any `npm -g` writes inside the isolated prefix; Windows npm otherwise
# defaults its global prefix to %APPDATA%\npm and touches the system install.
env["NPM_CONFIG_PREFIX"] = str(install_dir)
env["npm_config_prefix"] = str(install_dir)
env.pop("NODE_PATH", None)
result = subprocess.run(
[str(node_bin), *args],
capture_output = True,
text = True,
timeout = timeout,
env = env,
**_windows_hidden_kwargs(),
)
if result.returncode != 0:
raise RuntimeError(
f"node {' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}"
)
return result.stdout.strip()
def installed_node_version(install_dir: Path, host: HostInfo) -> str | None:
node_bin = node_binary_path(install_dir, host)
if not node_bin.exists():
return None
try:
return _run_node(install_dir, host, ["-v"], timeout = 30).lstrip("v")
except Exception: # noqa: BLE001
return None
def installed_npm_major(install_dir: Path, host: HostInfo) -> int | None:
cli = npm_cli_path(install_dir, host)
if not cli.exists():
return None
try:
out = _run_node(install_dir, host, [str(cli), "--version"], timeout = 60)
return _version_tuple(out)[0]
except Exception: # noqa: BLE001
return None
def metadata_path(install_dir: Path) -> Path:
return install_dir / METADATA_FILENAME
def write_metadata(install_dir: Path, *, version: str, asset: str, sha256: str) -> None:
payload = {
"schema_version": METADATA_SCHEMA_VERSION,
"kind": "node",
"version": version,
"asset": asset,
"sha256": sha256,
}
metadata_path(install_dir).write_text(json.dumps(payload, indent = 2) + "\n", encoding = "utf-8")
def load_metadata(install_dir: Path) -> dict | None:
path = metadata_path(install_dir)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding = "utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return None
return data if isinstance(data, dict) else None
def existing_install_matches(
install_dir: Path,
host: HostInfo,
*,
version: str,
expected_sha: str | None = None,
) -> bool:
"""True iff the on-disk install is exactly this version, runs, and (when
expected_sha is given) was recorded with that digest, so a non-pinned or
tampered artifact is not kept just because its version string matches."""
meta = load_metadata(install_dir)
if not meta or meta.get("version") != version:
return False
if expected_sha is not None and meta.get("sha256") != expected_sha:
return False
if installed_node_version(install_dir, host) != version:
return False
npm_major = installed_npm_major(install_dir, host)
return npm_major is not None and npm_major >= NPM_MIN_MAJOR
def existing_install_usable(install_dir: Path, host: HostInfo) -> bool:
"""True iff the on-disk install runs and clears the npm floor, ignoring version."""
if not load_metadata(install_dir):
return False
if installed_node_version(install_dir, host) is None:
return False
npm_major = installed_npm_major(install_dir, host)
return npm_major is not None and npm_major >= NPM_MIN_MAJOR
def _replace_with_retry(
src: Path,
dst: Path,
*,
attempts: int = 8,
) -> None:
"""os.replace, retried against transient Windows sharing violations.
A directory rename fails with WinError 5/32 while any process holds a handle inside
it, and Defender or the indexer routinely does right after extraction (seen in CI on
a fresh install, with no existing directory to conflict with). Handles clear in a
second or two, so a bounded backoff turns the failure into a pause; other errors
raise immediately rather than stalling on a real problem.
"""
delay = 0.25
for attempt in range(attempts):
try:
os.replace(src, dst)
return
except OSError as exc:
transient = os.name == "nt" and getattr(exc, "winerror", None) in (5, 32, 145)
if not transient or attempt == attempts - 1:
raise
log(
f"rename blocked ({exc.winerror}), retrying in {delay:.2f}s "
f"-- a scanner is likely still holding the extracted files"
)
time.sleep(delay)
delay = min(delay * 2, 4.0)
def _swap_into_place(extracted_root: Path, install_dir: Path) -> None:
"""Atomically replace install_dir with extracted_root (same filesystem)."""
install_dir.parent.mkdir(parents = True, exist_ok = True)
backup: Path | None = None
if install_dir.exists():
backup = install_dir.parent / f".{install_dir.name}.old-{os.getpid()}"
_replace_with_retry(install_dir, backup)
try:
_replace_with_retry(extracted_root, install_dir)
except OSError:
# The forward rename retries ~16s, ample time for a scanner to grab the backup too.
# A plain os.replace would then raise over the original error and leave no
# install_dir at all, so the rollback gets the same backoff and never masks it.
if backup is not None and not install_dir.exists():
try:
_replace_with_retry(backup, install_dir)
except OSError as rollback_exc:
log(f"could not restore the previous Node install from {backup}: {rollback_exc}")
raise
if backup is not None:
shutil.rmtree(backup, ignore_errors = True)
def _ensure_npm_floor(install_dir: Path, host: HostInfo) -> None:
"""Self-upgrade npm inside the isolated prefix if a pinned build ships npm < 11 (no-op on Node 24+)."""
npm_major = installed_npm_major(install_dir, host)
if npm_major is not None and npm_major >= NPM_MIN_MAJOR:
return
log(f"bundled npm {npm_major} below {NPM_MIN_MAJOR}; upgrading npm inside the isolated prefix")
cli = npm_cli_path(install_dir, host)
_run_node(install_dir, host, [str(cli), "install", "-g", f"npm@^{NPM_MIN_MAJOR}"], timeout = 300)
# ── Orchestration ──
def install_prebuilt(install_dir: Path, *, channel: str, min_major: int, force: bool) -> int:
host = detect_host()
pins = load_pins()
if channel in {"", "pinned", "default"}:
# Default path: the committed pin, no index.json round-trip.
version = pinned_default_version(pins)
elif channel in {"lts", "latest"}:
try:
index = fetch_json(NODE_DIST_INDEX)
except Exception as exc: # noqa: BLE001
# nodejs.org unreachable: keep a working isolated Node instead of aborting.
if not force and existing_install_usable(install_dir, host):
log(f"Node dist index unreachable ({exc}); keeping existing isolated Node")
return EXIT_SUCCESS
raise
if not isinstance(index, list):
raise PrebuiltFallback(f"unexpected index.json payload from {NODE_DIST_INDEX}")
version = select_node_version(index, channel = channel, min_major = min_major)
else:
version = channel.lstrip("v")
# Explicit version bypasses min_major; reject anything Vite/OXC cannot use.
if not _meets_node_floor(version):
raise PrebuiltFallback(
f"requested Node v{version} is below the floor (^20.19 || >=22.12 || >=23)"
)
asset = node_asset_name(version, host)
log(f"target Node v{version} ({asset})")
# Only keep an existing install if it matches the committed pin (or the caller
# opted out of pinning); an unpinned target without opt-in falls through to the
# refusal in resolve_expected_sha256 rather than short-circuiting on it.
pin = pinned_sha256(pins, version, asset)
allow_unverified = allow_unverified_node()
may_keep = pin is not None or allow_unverified
if (
not force
and may_keep
and existing_install_matches(install_dir, host, version = version, expected_sha = pin)
):
log(f"existing Node install already matches v{version}; nothing to do")
return EXIT_SUCCESS
with install_lock(install_lock_path(install_dir)):
# Re-check under the lock: a concurrent run may have just finished.
if (
not force
and may_keep
and existing_install_matches(install_dir, host, version = version, expected_sha = pin)
):
log(f"existing Node install already matches v{version}; nothing to do")
return EXIT_SUCCESS
try:
expected_sha = resolve_expected_sha256(
pins, version, asset, allow_unverified = allow_unverified
)
staging_root = install_dir.parent / INSTALL_STAGING_ROOT_NAME
staging_root.mkdir(parents = True, exist_ok = True)
staging = Path(
tempfile.mkdtemp(prefix = f"{install_dir.name}.staging-", dir = staging_root)
)
try:
archive_path = staging / asset
log(f"downloading {node_download_url(version, asset)}")
download_file_verified(
node_download_url(version, asset),
archive_path,
expected_sha256 = expected_sha,
label = asset,
)
extract_dir = staging / "extracted"
extract_archive(archive_path, extract_dir)
roots = [p for p in extract_dir.iterdir() if p.is_dir()]
if len(roots) != 1:
raise PrebuiltFallback(f"unexpected archive layout: {[p.name for p in roots]}")
extracted_root = roots[0]
_ensure_npm_floor(extracted_root, host)
write_metadata(extracted_root, version = version, asset = asset, sha256 = expected_sha)
_swap_into_place(extracted_root, install_dir)
finally:
shutil.rmtree(staging, ignore_errors = True)
try:
staging_root.rmdir()
except OSError:
pass
except UnpinnedNodeRefused:
# A policy refusal, not a transient failure: fail closed, never keep-existing.
raise
except Exception as exc: # noqa: BLE001
# Transient download/verify failure: keep an existing usable Node, but
# never keep a same-version install whose recorded digest is not the pin
# (the artifact the short-circuit above just rejected). A different usable
# version is still kept for offline resilience.
meta = load_metadata(install_dir)
pin_mismatch = (
pin is not None
and bool(meta)
and meta.get("version") == version
and meta.get("sha256") != pin
)
if not force and not pin_mismatch and existing_install_usable(install_dir, host):
log(f"Node download failed ({exc}); keeping existing isolated Node")
return EXIT_SUCCESS
raise
final_version = installed_node_version(install_dir, host)
npm_major = installed_npm_major(install_dir, host)
if final_version != version or npm_major is None or npm_major < NPM_MIN_MAJOR:
raise PrebuiltFallback(
f"post-install verification failed: node={final_version} npm_major={npm_major}"
)
log(f"installed isolated Node v{final_version} (npm {npm_major}.x) at {install_dir}")
return EXIT_SUCCESS
def main(argv: list[str] | None = None) -> int:
global _LOG_TO_STDOUT
_LOG_TO_STDOUT = True
parser = argparse.ArgumentParser(description = "Install an isolated Node.js for Unsloth Studio")
parser.add_argument(
"--install-dir", required = True, help = "isolated Node directory, e.g. <UNSLOTH_HOME>/node"
)
parser.add_argument(
"--node-version",
default = os.environ.get("UNSLOTH_NODE_VERSION", DEFAULT_NODE_CHANNEL),
help = (
f"'pinned' (default; installs the digest-pinned version from {PINS_FILENAME}), "
f"'lts', 'latest', or an explicit version like 24.4.1. Non-pinned versions require "
f"{ALLOW_UNVERIFIED_ENV}=1."
),
)
parser.add_argument("--min-major", type = int, default = NODE_MIN_LTS_MAJOR)
parser.add_argument(
"--force", action = "store_true", help = "reinstall even if the version matches"
)
args = parser.parse_args(argv)
install_dir = Path(args.install_dir).expanduser().resolve()
try:
return install_prebuilt(
install_dir,
channel = args.node_version,
min_major = args.min_major,
force = args.force,
)
except BusyInstallConflict as exc:
log(str(exc))
return EXIT_BUSY
except UnpinnedNodeRefused as exc:
# Catch before PrebuiltFallback so the refusal logs its own message.
log(str(exc))
return EXIT_FALLBACK
except PrebuiltFallback as exc:
log(f"prebuilt unavailable: {exc}")
return EXIT_FALLBACK
except Exception as exc: # noqa: BLE001
log(f"unexpected error: {exc}")
return EXIT_ERROR
if __name__ == "__main__":
raise SystemExit(main())