unsloth/unsloth_cli/_studio_runtime_gate.py
Etherl d495a09bf0
Guard Windows Studio installs against active runtimes (#7764)
* Fix Studio installer runtime race

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

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

* Close remaining Studio installer races

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

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

* Keep Windows installer tests Windows-only

* Stabilize Windows process guard test

* Coordinate terminal Studio launches

* Guard all managed Studio launches

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

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

* Coordinate custom Studio roots

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

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

* Handle direct GGUF settings rows

* Apply repository formatter

* Close remaining Studio update races

* Handle Windows runtime gate CI edge cases

* Verify the updater parent shim by image

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

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

* Allow verified updater shim chains

* Stabilize Windows process guard test

* Handle Windows console-script redirectors

* Close remaining Windows updater guard gaps

* Handle spaced and repeated Windows update shells

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

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

* Resolve Tauri root aliases before validation

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

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

* Fix custom-root locks and updater ancestry

* Align Windows runtime identity checks

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

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

* Scope desktop fallback to the current user

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

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

* Preserve drive-root mutex identity

* Use ordinal semantics in Studio idle scans

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

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

* Guard standalone Studio setup mutations

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

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

* Restore the setup gate handoff independently

* Version the Windows installer native helper

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

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

* Use ordinal path comparison in Studio runtime scan

* Tighten Studio runtime gate test comments

* Exempt the venv Python redirector in the Studio runtime gate

Windows venv Scripts\python.exe is a redirector that runs base Python as a
child, so `unsloth studio setup` runs as unsloth.exe -> python.exe -> us. The
ancestor walk only exempted the unsloth.exe shims and stopped at the redirector,
so the installer flagged its own launcher and every Windows install failed with
"The managed Studio environment is in use by unsloth.exe".

Carry one redirector as pending and exempt it only when a shim sits directly
above it, so a managed backend that spawns an update still blocks. Also stop
gating the protected shim paths on exists(), so a shim renamed out of the way
mid-update is still recognised.

Drop "Studio" from the two runtime-lock messages so process.rs satisfies the
desktop branding contract.

* Close the redirector exemption when the updater is the managed image

Only a base interpreter runs under a venv redirector. If our own executable is
inside the managed root there is no redirector above us, so a managed parent is
a real consumer and must keep blocking. Adds the regression to the redirector
test.

* Key the redirector exemption on sys.executable, not on a shim above it

The Tauri updater runs `<venv>\Scripts\python.exe -I -c ... studio update`
directly, so its chain is tauri.exe -> redirector -> base Python with no
unsloth.exe in it. Requiring a shim above the redirector made every desktop
update block on its own launcher.

A venv redirector starts base Python as a child and waits, so when we are the
base image and sys.executable still names the managed interpreter, our direct
parent is that launcher. Exempt exactly that hop; ancestors above it must still
be shims, and a managed image at depth two or more keeps blocking.

* Stop the x86 guard test racing its own probe

The 32-bit leg fired one scan against a probe that lives about five seconds,
while a WOW64 shell start plus the Add-Type compile regularly costs more than
that, so it read an empty list on a Windows runner. Give the probe a long life
and retry like the 64-bit sibling already does. The assertion is unchanged.

* Tighten comments in the Studio runtime gate changes

---------

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

356 lines
12 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Coordinate Windows launches that consume the Tauri-managed Studio environment."""
from __future__ import annotations
import contextlib
import ctypes
import hashlib
import ntpath
import os
import sys
from ctypes import wintypes
from pathlib import Path
from typing import Iterator, Mapping
_RUNTIME_MUTEX_PREFIX = "Global\\UnslothStudioManagedEnvironment-"
_PATH_RUNTIME_MUTEX_PREFIX = "Global\\UnslothStudioManagedEnvironmentPath-"
_RUNTIME_GATE_HANDOFF_ENV = "_UNSLOTH_STUDIO_RUNTIME_GATE_HANDOFF"
class StudioRuntimeGateBusy(RuntimeError):
"""The managed Studio environment is being installed or repaired."""
class _SidAndAttributes(ctypes.Structure):
_fields_ = [
("sid", ctypes.c_void_p),
("attributes", wintypes.DWORD),
]
class _TokenUser(ctypes.Structure):
_fields_ = [("user", _SidAndAttributes)]
def runtime_mutex_name_for_sid(sid: str) -> str:
return f"{_RUNTIME_MUTEX_PREFIX}{sid}"
def _windows_profile_path() -> Path:
shell32 = ctypes.WinDLL("shell32", use_last_error = True)
get_folder_path = shell32.SHGetFolderPathW
get_folder_path.argtypes = [
wintypes.HWND,
ctypes.c_int,
wintypes.HANDLE,
wintypes.DWORD,
wintypes.LPWSTR,
]
get_folder_path.restype = ctypes.c_long
buffer = ctypes.create_unicode_buffer(32768)
result = get_folder_path(None, 0x0028, None, 0, buffer) # CSIDL_PROFILE
if result != 0:
raise OSError(
f"SHGetFolderPathW(CSIDL_PROFILE) failed with HRESULT 0x{result & 0xFFFFFFFF:08x}"
)
return Path(buffer.value)
def _resolved_windows_path(path: Path) -> str:
resolved = str(path.resolve(strict = False))
if resolved.startswith("\\\\?\\UNC\\"):
resolved = "\\\\" + resolved[8:]
elif resolved.startswith("\\\\?\\"):
resolved = resolved[4:]
drive, tail = ntpath.splitdrive(resolved)
if drive and tail and not tail.rstrip("\\/"):
return drive + "\\"
return resolved.rstrip("\\/")
def _canonical_windows_path(path: Path) -> str:
return _resolved_windows_path(path).replace("/", "\\")
def _windows_paths_equal(left: str, right: str) -> bool:
kernel32 = ctypes.WinDLL("kernel32", use_last_error = True)
compare = kernel32.CompareStringOrdinal
compare.argtypes = [
wintypes.LPCWSTR,
ctypes.c_int,
wintypes.LPCWSTR,
ctypes.c_int,
wintypes.BOOL,
]
compare.restype = ctypes.c_int
result = compare(left, -1, right, -1, True)
if result == 0:
raise ctypes.WinError(ctypes.get_last_error())
return result == 2 # CSTR_EQUAL
def uses_tauri_managed_root(studio_home: Path) -> bool:
if sys.platform != "win32":
return False
managed_root = _windows_profile_path() / ".unsloth" / "studio"
return _windows_paths_equal(
_resolved_windows_path(studio_home),
_resolved_windows_path(managed_root),
)
def runtime_mutex_name_for_studio_home(studio_home: Path) -> str:
if uses_tauri_managed_root(studio_home):
return runtime_mutex_name_for_sid(_current_windows_user_sid())
canonical = _resolved_windows_path(studio_home)
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return f"{_PATH_RUNTIME_MUTEX_PREFIX}{digest}"
def _current_windows_user_sid() -> str:
kernel32 = ctypes.WinDLL("kernel32", use_last_error = True)
advapi32 = ctypes.WinDLL("advapi32", use_last_error = True)
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
kernel32.LocalFree.restype = ctypes.c_void_p
advapi32.OpenProcessToken.argtypes = [
wintypes.HANDLE,
wintypes.DWORD,
ctypes.POINTER(wintypes.HANDLE),
]
advapi32.OpenProcessToken.restype = wintypes.BOOL
advapi32.GetTokenInformation.argtypes = [
wintypes.HANDLE,
ctypes.c_int,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
]
advapi32.GetTokenInformation.restype = wintypes.BOOL
advapi32.ConvertSidToStringSidW.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(wintypes.LPWSTR),
]
advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL
token = wintypes.HANDLE()
if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token)):
raise ctypes.WinError(ctypes.get_last_error())
try:
needed = wintypes.DWORD()
advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(needed)) # TokenUser
if not needed.value:
raise ctypes.WinError(ctypes.get_last_error())
token_buffer = ctypes.create_string_buffer(needed.value)
if not advapi32.GetTokenInformation(
token,
1,
token_buffer,
needed,
ctypes.byref(needed),
):
raise ctypes.WinError(ctypes.get_last_error())
token_user = ctypes.cast(token_buffer, ctypes.POINTER(_TokenUser)).contents
sid_text = wintypes.LPWSTR()
if not advapi32.ConvertSidToStringSidW(token_user.user.sid, ctypes.byref(sid_text)):
raise ctypes.WinError(ctypes.get_last_error())
try:
return sid_text.value
finally:
kernel32.LocalFree(ctypes.cast(sid_text, ctypes.c_void_p))
finally:
kernel32.CloseHandle(token)
@contextlib.contextmanager
def studio_runtime_launch_guard(studio_home: Path, *, inherited: bool = False) -> Iterator[bool]:
"""Hold the shared Windows launch gate through backend admission."""
if sys.platform != "win32" or inherited:
yield False
return
kernel32 = ctypes.WinDLL("kernel32", use_last_error = True)
kernel32.CreateMutexW.argtypes = [
ctypes.c_void_p,
wintypes.BOOL,
wintypes.LPCWSTR,
]
kernel32.CreateMutexW.restype = wintypes.HANDLE
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
kernel32.WaitForSingleObject.restype = wintypes.DWORD
kernel32.ReleaseMutex.argtypes = [wintypes.HANDLE]
kernel32.ReleaseMutex.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
name = runtime_mutex_name_for_studio_home(studio_home)
handle = kernel32.CreateMutexW(None, False, name)
if not handle:
raise ctypes.WinError(ctypes.get_last_error())
wait_result = kernel32.WaitForSingleObject(handle, 0)
if wait_result not in (0x00000000, 0x00000080): # WAIT_OBJECT_0, WAIT_ABANDONED
kernel32.CloseHandle(handle)
if wait_result == 0x00000102: # WAIT_TIMEOUT
raise StudioRuntimeGateBusy(name)
raise ctypes.WinError(ctypes.get_last_error())
try:
yield True
finally:
kernel32.ReleaseMutex(handle)
kernel32.CloseHandle(handle)
def _windows_path_is_within(candidate: str, root: str) -> bool:
candidate_key = candidate.rstrip("\\/").replace("/", "\\")
root_key = root.rstrip("\\/").replace("/", "\\")
if _windows_paths_equal(candidate_key, root_key):
return True
prefix = root_key + "\\"
if len(candidate_key) < len(prefix):
return False
return _windows_paths_equal(candidate_key[: len(prefix)], prefix)
def ensure_managed_environment_is_idle(studio_home: Path) -> None:
"""Reject a Windows update while a confirmed managed executable is running."""
if sys.platform != "win32":
return
import json
import subprocess
venv = studio_home / "unsloth_studio"
protected_root = _canonical_windows_path(venv)
# Not gated on exists(): a shim renamed out of the way mid-update still runs.
protected_files = {
_canonical_windows_path(candidate)
for candidate in (
venv / "Scripts" / "unsloth.exe",
studio_home / "bin" / "unsloth.exe",
)
}
script = (
"$ErrorActionPreference='Stop';"
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false);"
"$items=@(Get-CimInstance Win32_Process -ErrorAction Stop|"
"Select-Object ProcessId,ParentProcessId,Name,ExecutablePath);"
"[Console]::Out.Write(($items|ConvertTo-Json -Compress))"
)
result = subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0),
check = False,
)
if result.returncode != 0:
detail = result.stderr.strip() or f"exit code {result.returncode}"
raise RuntimeError(f"Could not inspect running processes before Studio update: {detail}")
try:
payload = json.loads(result.stdout or "[]")
except json.JSONDecodeError as error:
raise RuntimeError(
f"Could not decode the running-process list before Studio update: {error}"
) from error
processes = payload if isinstance(payload, list) else [payload]
process_by_pid = {
int(process.get("ProcessId") or -1): process
for process in processes
if int(process.get("ProcessId") or -1) > 0
}
# The updater may itself have been entered through the managed console shim,
# so exempt verified launcher ancestors only: a managed backend that starts
# an update as its child must still block replacement.
#
# venv\Scripts\python.exe is a redirector (bpo-34977): it starts base Python
# as a child and waits, so a venv launch arrives as python.exe -> us. Our
# image is then the base interpreter while sys.executable still names the
# redirector, which identifies our direct parent as that launcher rather
# than a live consumer. Exempt that one hop only; above it must be a shim.
excluded_pids = {os.getpid()}
descendant_pid = os.getpid()
self_executable = (process_by_pid.get(os.getpid()) or {}).get("ExecutablePath")
interpreter = _canonical_windows_path(Path(sys.executable)) if sys.executable else ""
launcher_redirector = ""
if (
interpreter
and self_executable
and _windows_path_is_within(interpreter, protected_root)
and not _windows_paths_equal(
_canonical_windows_path(Path(str(self_executable))), interpreter
)
):
launcher_redirector = interpreter
for _ in range(16):
descendant = process_by_pid.get(descendant_pid)
if descendant is None:
break
parent_pid = int(descendant.get("ParentProcessId") or -1)
if parent_pid <= 0 or parent_pid in excluded_pids:
break
parent = process_by_pid.get(parent_pid)
if parent is None:
break
parent_executable = parent.get("ExecutablePath")
if not parent_executable:
break
parent_image = _canonical_windows_path(Path(str(parent_executable)))
is_shim = any(
_windows_paths_equal(parent_image, protected_file) for protected_file in protected_files
)
is_our_redirector = (
descendant_pid == os.getpid()
and bool(launcher_redirector)
and _windows_paths_equal(parent_image, launcher_redirector)
)
if not (is_shim or is_our_redirector):
break
excluded_pids.add(parent_pid)
descendant_pid = parent_pid
for process_id, process in process_by_pid.items():
if process_id in excluded_pids:
continue
executable = process.get("ExecutablePath")
if not executable:
continue
image = _canonical_windows_path(Path(str(executable)))
if _windows_path_is_within(image, protected_root) or any(
_windows_paths_equal(image, protected_file) for protected_file in protected_files
):
name = process.get("Name") or "process"
raise RuntimeError(
"The managed Studio environment is in use by "
f"{name} (PID {process_id}). Stop that process, then retry the update."
)
def consume_runtime_gate_handoff() -> bool:
return os.environ.pop(_RUNTIME_GATE_HANDOFF_ENV, None) == "1"
def runtime_gate_child_environment(base: Mapping[str, str] | None = None) -> dict[str, str]:
child_env = dict(os.environ if base is None else base)
child_env[_RUNTIME_GATE_HANDOFF_ENV] = "1"
return child_env