mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-24 00:04:14 +00:00
* 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>
356 lines
12 KiB
Python
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
|