unsloth/tests/python/test_windows_installer_concurrency_guard.py
Daniel Han 21296c4274
Install Studio on a Windows host whose C# compiler cannot run (#9178)
* Install Studio on a Windows host whose C# compiler cannot run

A first launch of the desktop app on Windows 10 died with

    [ERROR] Could not create the Studio install lock: (0) : Die Quelldatei
            'C:\Windows\TEMP\05po312x.0.cs' konnte nicht gefunden werden.
    (1) : using System;

which is C# compiler error CS2001, not a locking problem. Get-StudioFinalPath
compiles a GetFinalPathNameByHandleW helper with Add-Type, and Windows
PowerShell 5.1 (the interpreter install.rs spawns) compiles by writing the
source into %TEMP% and running csc.exe. When that directory cannot hold a file,
or a scanner eats the source before csc opens it, Add-Type throws, and the
throw travelled up Get-StudioPathHash into Enter-StudioInstallMutex, where it
was reported as a lock failure. install.rs never sets or validates TEMP, so the
installer inherits whatever the app was started with.

Three parts:

Probe TMP and TEMP once with a write, read back and delete, and if the
inherited one cannot hold a file, point both at a per-user directory for the
rest of the run. The compiler is not the only thing that stages through there;
so do the Python, uv and VC++ downloads. It is restored on every exit path.

Split Get-StudioFinalPath into a cached native initializer and a compiler-free
resolver. The initializer skips compiling under Constrained Language Mode,
retries once with a private %TEMP%, and then remembers the answer. The fallback
resolves reparse points component-wise from the root, since a link on a parent
component is the ordinary Windows shape. Callers get an Exact flag so
Test-StudioPathEqual can answer "unknown" rather than "different", which the
runtime lock already reads as "take both locks".

Stop the in-use check failing open. Get-RunningStudioVenvProcesses called the
compiled helper inside catch { continue }, so a host that could not compile
found no running processes and would happily overwrite a venv Studio had open.
It now falls back to the process image path and Win32_Process, and says so.

With the native helper present every output is byte-identical to before: the
install mutex name, the runtime lock names, path identity and the process scan.

Fixes #9140

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

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

* Put the private temp directory where uninstall already looks

The fallback temp directory outlives the install on purpose: a Studio started
from it inherits it as its own %TEMP%. That means it has to sit somewhere
scripts/uninstall.ps1 reclaims, and LOCALAPPDATA\UnslothStudio was a folder of
my own invention that nothing would ever clean.

The USERPROFILE candidate was worse than litter. ~\.unsloth is removed only when
it is empty, so a leftover ~\.unsloth\temp would have stopped the uninstaller
clearing the directory at all.

Now LOCALAPPDATA\"Unsloth Studio"\temp, which the uninstaller deletes wholesale
as the data dir, and ~\.unsloth\.cache\temp, which is on its explicit sibling
list. Creating the data dir early cannot be mistaken for an install: the desktop
app decides that from find_unsloth_binary(), not from this directory existing.

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

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

* Prove the private temp retry recovers, not just that it survives

Surviving a dead compiler is the floor. The retry is supposed to get the native
resolver back, and nothing asserted that it does. Add-Type is stubbed to behave
the way 5.1's CodeDom does, writing the source into %TEMP% and failing while that
cannot hold a file, then calling the real cmdlet once it can, so the type really
is defined on the second attempt.

Pins the whole sequence: one failure then exactly one retry, the type loaded
afterwards, no degraded warning, TMP and TEMP restored to the broken values they
came in with, and the retry directory cleaned up.

* Reclaim a stale junction in the private temp sweep instead of leaking it

Running the sweep on Windows PowerShell 5.1 showed the reparse-point branch
never removing the link: Remove-Item without -Recurse reports the junction
target's contents and refuses as "directory not empty", so a junction left
under the private temp root would sit there forever. Directory.Delete with
recursive:$false removes the reparse point itself and, unlike -Recurse on
5.1, cannot follow it into the target.

* Say what the junction branch actually guards against

Measured on windows-latest under PowerShell 5.1: Remove-Item -Recurse on a
junction removed only the link and left the target alone, while Remove-Item
without -Recurse threw a NullReferenceException that -ErrorAction did not
suppress. The comment claimed the first case as the hazard; the second is
what is reproducible today. Directory.Delete avoids both.

* Make the installer resolver tests hold on Windows too

Running them on windows-latest turned up three things the Linux-only run could
not see. The junction-alias test extracts Get-StudioFinalPath alone, which is a
dispatcher now, so every call in it was undefined and it read as "could not
resolve"; give it the whole resolver chain. The link-shape cases compared a
POSIX fixture against a GetFullPath result, which is drive-rooted and
backslashed on Windows, so compare paths rather than spellings. And utime with
follow_symlinks=False does not exist on Windows, where aging a link any other
way writes through onto the target; skip there and say why.

Resolve-StudioLinkTarget now normalises $Path before the self-reference check,
so a caller passing an unnormalised spelling still trips the guard instead of
being handed a link that points at itself.

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

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

* Restore the caller's temp on every early exit, and keep a live owner's directory

Four review findings, all reachable through paths this change itself opened.

Two early throws sit before the try/finally that owns the locks: rejecting a
custom root under --tauri, and --shortcuts-only finding no managed Python.
Resolving a path can redirect TMP and TEMP, so under irm | iex both left the
caller's own session pointed at an installer-owned directory. The tauri branch
restores before it throws; the shortcuts-only block is now a try/finally, which
also covers the tauri return inside it.

The stale sweep treated age as proof a directory was unused. A Studio
autostarted by an earlier install inherits one as its %TEMP% and can outlive the
one-day cutoff without writing to it, and the sweep runs before the runtime
mutex is taken. The owner PID is in the name, so a directory whose owner is
still running is left alone.

The in-use scan resolved the venv and each process image through the same
resolver, which is enough while it is exact. Without the native helper an alias
it cannot canonicalize keeps its own spelling while the process reports the
physical one, so the prefix test found nothing and the install would overwrite a
venv Studio had open. When the identity is inexact it now also compares the two
paths below their roots, and fails closed.

* Fold SUBST aliases, fix the UNC device form, record the real temp owner

Three follow-ups, each on a path the earlier round opened rather than closed.

A SUBST drive kept its own spelling on a compiler-blocked host, so one directory
reached as X:\venv and as its physical target produced two different install
mutexes and hid a running Studio from the in-use scan. The Python runtime gate
resolves it, since Path.resolve does. Measured on windows-latest that subst.exe
is the only source available without a compiler: Get-PSDrive.DisplayRoot,
Win32_LogicalDisk.ProviderName, GetFullPath and Resolve-Path all reveal nothing.
Get-StudioLexicalPath now folds the alias before it walks components, so the
identity, the mutex and the scan all agree with the runtime gate.

Stripping the four-character device prefix from \??\UNC\server\share left
UNC\server\share, which reads as relative and was combined with the link's own
parent. It becomes \\server\share instead.

The PID baked into a ust-<pid>- name is the installer's, and the installer is
gone by the time the next sweep runs; the process that keeps using the directory
is the Studio it autostarted. That one is recorded in owner.pid when the
autostart happens, and the sweep prefers it over the name.

* Keep a mounted folder's volume GUID target rooted

Same trap as the UNC device form, in a different shape. A mounted folder
reports its target as \??\Volume{GUID}\..., and after the four-character strip
Volume{...}\... is not rooted either, so it was combined with the link's own
parent and the resulting identity named a directory that does not exist. It
becomes the extended-length spelling \\?\Volume{GUID}\... instead, which is the
same device path and stays the volume it names.

* Keep the volume GUID prefix through final normalization

The rewrite in the resolver was undone one step later. Resolve-StudioFinalPathInfo
strips a leading extended-length prefix, which is right for \\?\C:\x -- that still
names a drive afterwards -- and wrong for \\?\Volume{GUID}\x, which becomes the
unrooted Volume{GUID}\x. That hashes to a different identity than the same
directory reached by its drive letter, and leaves GetPathRoot empty so the
relaxed process comparison cannot run either. The volume GUID branch is tested
before the general one and keeps the prefix.

* Say only what was measured about the volume GUID root

The previous commit gave two reasons for keeping the prefix and one of them is
false. Measured on Windows PowerShell 5.1: GetPathRoot returns empty for both
\\?\Volume{GUID}\x and Volume{GUID}\x, so keeping the prefix does not restore a
root and does not re-enable the relaxed process comparison. The real and only
reason is IsPathRooted, which is true for the extended form and false for the
bare one, so the link resolver stops combining the target with the link's own
parent and inventing a directory that does not exist.

* Drop the root-relative fallback in the in-use scan

It was written to catch an aliased root the lexical resolver could not
canonicalize, and it was too broad to keep. Without the native helper every path
is inexact, so it compared path tails across unrelated drives: an ordinary
D:\env\python.exe matched a protected C:\env and aborted a legitimate install
as "the managed Python environment is still in use". That is every
compiler-blocked host, which is the population this change exists to serve.

The alias it was written for was SUBST, and that is now folded in
Get-StudioLexicalPath instead, which is the right place and costs no false
positives. A volume reached by GUID and the same volume reached by drive letter
still cannot be matched without the compiler, and a tail match is not a safe
price to pay for it.

* Probe TMP whenever Windows would use it

GetTempPath takes the first of TMP/TEMP that is merely non-empty, so a
whitespace-only TMP is the one Windows and every child process resolve through.
IsNullOrWhiteSpace read that as unset, probed a healthy TEMP, found it fine and
returned, leaving the compile and every later Python, uv and VC++ download
pointed at a path that cannot exist. Only an absent or empty TMP falls through
to TEMP now.

* Tighten the comments on the temp and path resolver fallbacks

Collapse the explanatory prose to one line where it still reads clearly and
drop the restatements, keeping every measured Windows PowerShell 5.1 fact the
code is shaped around. No code changes: the PowerShell token stream is
identical with comments and newlines dropped, and the two test files pass the
comments-only AST check.

* Resolve the data dir the same way the installer does when uninstalling

install.ps1 falls back to GetFolderPath("LocalApplicationData") when
LOCALAPPDATA is unset, which is the service and CI case the fallback exists for,
and puts its private temp under "Unsloth Studio\temp" there. uninstall.ps1
derived the default data dir from the variable alone and skipped the removal
when it was absent, so that tree survived an uninstall on exactly those hosts.
It uses _AppDataRoot now, the same resolver the WebView2 profile removal
already used a few lines below.

* Resolve an inherited temp path before probing it, and pin what was probed

A relative TMP or TEMP (temp, or the drive-relative C:temp) is resolved by
whoever reads it, and the install relocates out of a Windows system directory
later, so the value that was probed could afterwards name somewhere else or
nowhere. The absolute form is now pinned into both variables, and the caller's
own spelling is still what the restore hands back.

Resolving happens BEFORE the probe rather than after, because the two halves of
the probe disagree on relative paths: Test-Path is relative to PowerShell's
location while the .NET file APIs are relative to the process working directory,
and Set-Location moves only the first. Probing the absolute path checks the same
directory the writes will use.

The override now records whether the directory is one this run created, so
owner.pid is written only there and never into the host's own temp.

* Do not resolve a whitespace-only temp value before probing it

Resolving it first turns "   " or a tab into the working directory plus that
name, which is creatable on some filesystems, so the probe would manufacture a
junk directory and then trust it as the host's temp. Leaving it untouched lets
the probe reject it, which is what sends the install to a private directory.

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

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

* Resolve a drive-less rooted link target on the link's own drive

A directory symlink can store a target like "\real": rooted as far as
IsPathRooted is concerned, but naming no drive. The compiler-free resolver
therefore skipped anchoring it to the link and handed it to GetFullPath, which
resolves a drive-less path against the PROCESS current drive. A link on D:
could normalize to C:\real, so the fallback mutex and the in-use process scan
would guard a different directory than the one being installed into.

Windows resolves such a target on the link's own volume, so anchor it to
GetPathRoot of the link before normalizing. A volume-GUID spelling has no root
to anchor to (GetPathRoot is empty there, measured on windows-latest), and
those keep today's behaviour rather than a guess.

* Run the Windows guard tests from a file, not a 32 KB command line

The Windows job started failing every test in this file as WinError 206, "The
filename or extension is too long". These scripts embed the whole extracted
helper chain, which has grown past the 32767 character command line cap
Windows enforces, so nothing was being tested on the platform the tests exist
for. Written to a temp .ps1 and run with -File instead, with a BOM so 5.1 does
not read it as ANSI.

test_running_venv_process_is_reported also raced: a six-ping child can exit
before Windows PowerShell 5.1 has finished its cold start and the csc.exe
compile of the native helper, which reads as the in-use scan missing a running
process. Same fix already applied to the 32-bit test: a long-lived child and a
deadline that allows for a slow shell.

* Reclaim both LocalAppData spellings on uninstall

New-StudioPrivateTempDirectory tries $env:LOCALAPPDATA first and falls through
to the LocalApplicationData known folder when that path is set but not usable,
so a non-blank variable does not tell you where "Unsloth Studio\temp" actually
landed. The uninstaller resolved a single root and stopped at the first
non-blank candidate, so on those hosts it removed a directory that was never
used and left the real tree behind.

It now collects both spellings, deduplicated, and stops servers by port file,
adds stop roots and removes the data dir for each. On an ordinary host the two
agree and the list has one entry, which is exactly today's behaviour.

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

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

* Sweep abandoned private temp directories on a healthy host too

The stale sweep only ran from New-StudioPrivateTempDirectory, which a run with
a usable inherited TMP/TEMP never reaches. So once a host's temp was fixed, by
an ACL correction or a cleaned environment, nothing ever collected what the
degraded runs had left, including the remains of interrupted downloads, and it
sat there until an uninstall.

The candidate roots move into Get-StudioPrivateTempRoots so both paths can
share them, and the healthy path now sweeps each one. A root that does not
exist is a no-op, which is every ordinary host.

* Require the temp probe file to actually be deleted

The usability probe created a file, read it back, then deleted it with the
error suppressed and reported success regardless. A directory that accepts a
file and refuses to give it back, a denied Delete ACE or a scanner sitting on
the handle, therefore passed as usable. That is the shape behind this whole
issue: csc.exe writes its source and its output into the temp directory and
then cleans up, so the private-temp fallback was being skipped for exactly the
hosts that needed it.

The delete is now verified. Retried up to three times first, since a scanner
holding a file for a moment is not the same as a directory that denies
deletion and only the second should cost a healthy host its own temp.

* Run every Windows guard script from a file, and decode its output as utf-8

The command line cap caught the other two spawn sites as well: the Add-Type
fallback tests and the two mutex holder processes still passed their script
inline, so seven tests died as WinError 206 on windows-latest without testing
anything. All of them now go through a temp .ps1 with -File, the same as the
rest of the file.

Output is also decoded as utf-8 with replacement rather than the console
codepage. cp1252 cannot decode what PowerShell writes into an error message,
and the reader thread raised UnicodeDecodeError from inside subprocess.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 08:03:21 -07:00

1061 lines
44 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
from __future__ import annotations
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALL_PS1 = REPO_ROOT / "install.ps1"
COMMANDS_RS = REPO_ROOT / "studio" / "src-tauri" / "src" / "commands.rs"
PROCESS_RS = REPO_ROOT / "studio" / "src-tauri" / "src" / "process.rs"
PREFLIGHT_MANAGED_RS = REPO_ROOT / "studio" / "src-tauri" / "src" / "preflight" / "managed.rs"
DESKTOP_AUTH_RS = REPO_ROOT / "studio" / "src-tauri" / "src" / "desktop_auth.rs"
UPDATE_RS = REPO_ROOT / "studio" / "src-tauri" / "src" / "update.rs"
STUDIO_COMMAND = REPO_ROOT / "unsloth_cli" / "commands" / "studio.py"
POWERSHELLS = [shell for shell in ("pwsh", "powershell") if shutil.which(shell)]
def _extract(pattern: str, source: str) -> str:
match = re.search(pattern, source, flags = re.DOTALL)
assert match is not None, f"install.ps1 block not found: {pattern}"
return match.group(0)
def _run_powershell(shell: str, script: str, env: dict[str, str]) -> str:
# Through a FILE, not -Command: these scripts carry the whole extracted helper
# chain, and Windows caps a command line at 32767 characters. Passed inline,
# the moment the chain grows past that every test here dies as WinError 206
# "The filename or extension is too long" instead of testing anything.
# utf-8-sig because Windows PowerShell 5.1 reads a BOM-less .ps1 as ANSI.
handle, name = tempfile.mkstemp(suffix = ".ps1")
os.close(handle)
try:
Path(name).write_text(script, encoding = "utf-8-sig")
result = subprocess.run(
[shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", name],
check = True,
capture_output = True,
text = True,
# Decoded as utf-8 with replacement, not the console codepage: cp1252
# cannot decode what PowerShell writes and the whole test then dies as
# a UnicodeDecodeError on a byte in an error message.
encoding = "utf-8",
errors = "replace",
env = env,
timeout = 60,
)
finally:
try:
os.unlink(name)
except OSError:
pass
return result.stdout.strip()
def _ps_file(directory: Path, name: str, script: str) -> str:
"""Same reason as _run_powershell: a 32 KB command line is not available here."""
path = directory / name
path.write_text(script, encoding = "utf-8-sig")
return str(path)
# The chain Get-StudioFinalPath dispatches to. It used to compile the native helper
# inline, so a test could extract it alone; extracting the dispatcher by itself now
# yields a body whose calls are all undefined, which reads as "could not resolve"
# rather than as a missing helper (issue #9140).
_FINAL_PATH_CHAIN = (
"Write-StudioLine",
"Test-StudioDirectoryUsable",
"Remove-StudioStalePrivateTempDirectories",
"Get-StudioPrivateTempRoots",
"New-StudioPrivateTempDirectory",
"Initialize-StudioTempEnvironment",
"Write-StudioFinalPathDegraded",
"Initialize-StudioFinalPathNativeType",
"Resolve-StudioLinkTarget",
"Get-StudioSubstTarget",
"Get-StudioLexicalPath",
"Resolve-StudioFinalPathInfo",
"Get-StudioFinalPath",
)
def _final_path_helpers(source: str) -> str:
return "\n".join(
_extract(rf" function {name} \{{.*?\n \}}\n", source) for name in _FINAL_PATH_CHAIN
)
def _mutex_helpers(source: str) -> str:
return "\n".join(
_extract(rf" function {name} \{{.*?\n \}}\n", source)
for name in (
# Test-StudioPathEqual reports an unresolvable identity through this,
# and these scripts run under -ErrorActionPreference Stop, so leaving it
# out made the CATCH path throw CommandNotFound and every test that
# reaches it fail for a reason that has nothing to do with what it
# measures. Extracted rather than stubbed: it is self-contained, and a
# stub would keep passing if the real call ever went wrong.
"Write-StudioLine",
"Enter-StudioNamedMutex",
# Get-StudioFinalPath is a dispatcher now: it falls back to the pure
# PowerShell resolver when the native helper did not compile (#9140).
"Test-StudioDirectoryUsable",
"Remove-StudioStalePrivateTempDirectories",
"Get-StudioPrivateTempRoots",
"New-StudioPrivateTempDirectory",
"Initialize-StudioTempEnvironment",
"Write-StudioFinalPathDegraded",
"Initialize-StudioFinalPathNativeType",
"Resolve-StudioLinkTarget",
"Get-StudioSubstTarget",
"Get-StudioLexicalPath",
"Resolve-StudioFinalPathInfo",
"Get-StudioFinalPath",
"Get-StudioPathHash",
"Get-StudioInstallMutexName",
"Test-StudioPathEqual",
"Get-StudioRuntimeMutexNameForSid",
"Get-StudioRuntimePathHash",
"Get-StudioRuntimeMutexNameForPath",
"Get-StudioCurrentUserSid",
"Get-StudioRuntimeMutexName",
"Get-StudioRuntimeMutexNames",
"Enter-StudioInstallMutex",
"Exit-StudioInstallMutex",
)
)
def _process_helpers(source: str) -> str:
return "\n".join(
_extract(rf" function {name} \{{.*?\n \}}\n", source)
for name in (
"Write-StudioLine",
"Test-StudioDirectoryUsable",
"Remove-StudioStalePrivateTempDirectories",
"Get-StudioPrivateTempRoots",
"New-StudioPrivateTempDirectory",
"Initialize-StudioTempEnvironment",
"Write-StudioFinalPathDegraded",
"Initialize-StudioFinalPathNativeType",
"Resolve-StudioLinkTarget",
"Get-StudioSubstTarget",
"Get-StudioLexicalPath",
"Resolve-StudioFinalPathInfo",
"Get-StudioFinalPath",
"Test-StudioProtectedPathMatch",
"Get-StudioProcessImagePath",
"Get-RunningStudioVenvProcesses",
)
)
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_running_venv_process_is_reported(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
detector = _process_helpers(source)
scripts = tmp_path / "unsloth_studio" / "Scripts"
scripts.mkdir(parents = True)
probe = scripts / "guard-probe.exe"
shutil.copy2(Path(os.environ["SystemRoot"]) / "System32" / "PING.EXE", probe)
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
# Long enough that the child outlives the scan itself. Windows PowerShell 5.1
# pays a cold start plus a real csc.exe compile of the native helper before it
# can look at anything, which alone can outlast a six-ping child; the process
# would then be gone by the time the scan ran, and the test would read as
# "the in-use check missed it".
child = subprocess.Popen(
[str(probe), "-n", "120", "127.0.0.1"],
creationflags = creationflags,
)
try:
script = f"""
$ErrorActionPreference = "Stop"
{detector}
@(Get-RunningStudioVenvProcesses -VenvPath $env:TEST_VENV) |
ForEach-Object {{ Write-Output $_.Id }}
"""
env = os.environ.copy()
env["TEST_VENV"] = str(scripts.parent)
deadline = time.monotonic() + 60
observed = []
while time.monotonic() < deadline:
observed = _run_powershell(shell, script, env).splitlines()
if str(child.pid) in observed:
break
time.sleep(0.1)
assert str(child.pid) in observed
finally:
child.terminate()
child.wait(timeout = 10)
@pytest.mark.skipif(
os.name != "nt" or sys.maxsize <= 2**32,
reason = "A 64-bit Windows test host is required",
)
def test_x86_powershell_reports_64_bit_managed_process(tmp_path: Path):
x86_shell = (
Path(os.environ["SystemRoot"])
/ "SysWOW64"
/ "WindowsPowerShell"
/ "v1.0"
/ "powershell.exe"
)
if not x86_shell.is_file():
pytest.skip("32-bit Windows PowerShell is unavailable")
source = INSTALL_PS1.read_text(encoding = "utf-8")
detector = _process_helpers(source)
scripts = tmp_path / "unsloth_studio" / "Scripts"
scripts.mkdir(parents = True)
probe = scripts / "guard-probe.exe"
shutil.copy2(Path(os.environ["SystemRoot"]) / "System32" / "PING.EXE", probe)
# Long-lived: a 32-bit shell pays a WOW64 start plus an Add-Type compile, so a
# short probe can exit before the scan runs.
child = subprocess.Popen(
[str(probe), "-n", "120", "127.0.0.1"],
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
try:
script = f"""
$ErrorActionPreference = "Stop"
{detector}
@(Get-RunningStudioVenvProcesses -VenvPath $env:TEST_VENV) |
ForEach-Object {{ Write-Output $_.Id }}
"""
env = os.environ.copy()
env["TEST_VENV"] = str(scripts.parent)
deadline = time.monotonic() + 30
observed = []
while time.monotonic() < deadline:
observed = _run_powershell(str(x86_shell), script, env).splitlines()
if str(child.pid) in observed:
break
time.sleep(0.5)
assert str(child.pid) in observed
finally:
child.terminate()
child.wait(timeout = 10)
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_installer_decision_stops_active_process_and_allows_idle(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
decision_start = source.index(" $protectedProcessPaths = @(")
decision_end = source.index(
" if (-not $TauriMode -and $studioUsesLegacyLayout)", decision_start
)
decision = source[decision_start:decision_end]
studio_home = tmp_path / "studio"
venv = studio_home / "unsloth_studio"
scripts = venv / "Scripts"
scripts.mkdir(parents = True)
marker = venv / "must-remain.txt"
marker.write_text("untouched", encoding = "utf-8")
worker = scripts / "worker.exe"
shutil.copy2(Path(os.environ["SystemRoot"]) / "System32" / "PING.EXE", worker)
child = subprocess.Popen(
[str(worker), "-n", "30", "127.0.0.1"],
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
script = f"""
$ErrorActionPreference = "Stop"
{_process_helpers(source)}
# The block names the blocking processes through install.ps1's UTF-8 stdout sink
# before it hands off to Exit-InstallFailure. Unstubbed that is a command-not-found
# terminating error under "Stop", so the active case never reaches RESULT:blocked.
function Write-StudioLine {{ param([string]$Message, [string]$ForegroundColor) Write-Host $Message }}
function Exit-InstallFailure {{
param([string]$Message)
return "blocked"
}}
function Invoke-InstallerDecision {{
$VenvDir = $env:TEST_VENV
$StudioHome = $env:TEST_STUDIO_HOME
$studioUsesLegacyLayout = $false
{decision}
return "continued"
}}
$result = Invoke-InstallerDecision
Write-Output ("RESULT:" + $result)
Write-Output ("MARKER:" + (Get-Content -LiteralPath $env:TEST_MARKER -Raw))
"""
env = os.environ.copy()
env["TEST_VENV"] = str(venv)
env["TEST_STUDIO_HOME"] = str(studio_home)
env["TEST_MARKER"] = str(marker)
try:
active = [
line
for line in _run_powershell(shell, script, env).splitlines()
if line.startswith(("RESULT:", "MARKER:"))
]
assert active == ["RESULT:blocked", "MARKER:untouched"]
finally:
child.terminate()
child.wait(timeout = 10)
idle = [
line
for line in _run_powershell(shell, script, env).splitlines()
if line.startswith(("RESULT:", "MARKER:"))
]
assert idle == ["RESULT:continued", "MARKER:untouched"]
def test_installer_ignores_command_line_and_cwd_only_path_mentions():
source = INSTALL_PS1.read_text(encoding = "utf-8")
for removed_helper in (
"ConvertFrom-StudioWindowsCommandLine",
"Test-StudioRawCommandLinePathReference",
"Get-StudioProcessWorkingDirectories",
"Test-StudioCommandLinePathReference",
):
assert removed_helper not in source
detector = source[
source.index(" function Get-RunningStudioVenvProcesses {") : source.index(
" function Test-VenvPythonReady {"
)
]
assert "Get-CimInstance" not in detector
assert ".CommandLine" not in detector
assert "$process.Path" not in detector
assert "Get-StudioProcessImagePath -ProcessId $process.Id" in detector
# The same contract has to hold on every rung of that helper's fallback: a
# confirmed image, never a command line. Its Win32_Process rung exists because a
# host that cannot compile the native helper would otherwise find no running
# processes and overwrite a venv Studio has open (issue #9140).
image = _extract(r" function Get-StudioProcessImagePath \{.*?\n \}\n", source)
assert ".CommandLine" not in image
assert "ExecutablePath" in image
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_versioned_native_helper_loads_after_older_installer_type(shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
final_path_helper = _mutex_helpers(source)
script = f"""
$ErrorActionPreference = "Stop"
Add-Type -TypeDefinition @'
public static class UnslothStudioFinalPath
{{
public static string Resolve(string path) {{ return path; }}
}}
'@
{final_path_helper}
Get-StudioFinalPath -Path $env:SystemRoot | Out-Null
Write-Output ([bool]("UnslothStudioFinalPathV2" -as [type]))
Write-Output ([bool]([UnslothStudioFinalPathV2]::GetProcessImagePath($PID)))
"""
assert _run_powershell(shell, script, os.environ.copy()).splitlines() == ["True", "True"]
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_desktop_process_filter_keeps_only_the_current_user_sid(shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
helper = _extract(
r" function Get-StudioDesktopProcessesForCurrentUser \{.*?\n \}\n", source
)
script = f"""
$ErrorActionPreference = "Stop"
{helper}
function Get-StudioCurrentUserSid {{ return "S-1-5-21-current" }}
function Get-CimInstance {{
[CmdletBinding()]
param([string]$ClassName, [string]$Filter)
@(
[pscustomobject]@{{ Name = "unsloth-studio.exe"; ProcessId = 101 }}
[pscustomobject]@{{ Name = "unsloth-studio.exe"; ProcessId = 202 }}
[pscustomobject]@{{ Name = "unsloth-studio.exe"; ProcessId = 303 }}
)
}}
function Invoke-CimMethod {{
[CmdletBinding()]
param($InputObject, [string]$MethodName)
if ($InputObject.ProcessId -eq 101) {{
return [pscustomobject]@{{ ReturnValue = 0; Sid = "S-1-5-21-current" }}
}}
if ($InputObject.ProcessId -eq 202) {{
return [pscustomobject]@{{ ReturnValue = 0; Sid = "S-1-5-21-other" }}
}}
throw "owner unavailable"
}}
@(Get-StudioDesktopProcessesForCurrentUser) | ForEach-Object {{ Write-Output $_.Id }}
"""
assert _run_powershell(shell, script, os.environ.copy()).splitlines() == ["101"]
assert "GetOwnerSid" in helper
assert "SessionId" not in helper
assert "QueryFullProcessImageNameW" in source
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_junction_alias_process_is_reported_for_physical_venv(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
detector = _process_helpers(source)
physical = tmp_path / "physical" / "unsloth_studio"
scripts = physical / "Scripts"
scripts.mkdir(parents = True)
alias = tmp_path / "alias"
subprocess.run(
["cmd.exe", "/d", "/c", "mklink", "/J", str(alias), str(physical)],
check = True,
capture_output = True,
text = True,
)
probe = alias / "Scripts" / "guard-probe.exe"
shutil.copy2(Path(os.environ["SystemRoot"]) / "System32" / "PING.EXE", probe)
child = subprocess.Popen(
[str(probe), "-n", "6", "127.0.0.1"],
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
try:
script = f"""
$ErrorActionPreference = "Stop"
{detector}
@(Get-RunningStudioVenvProcesses -VenvPath $env:TEST_VENV) |
ForEach-Object {{ Write-Output $_.Id }}
"""
env = os.environ.copy()
env["TEST_VENV"] = str(physical)
assert str(child.pid) in _run_powershell(shell, script, env).splitlines()
finally:
child.terminate()
child.wait(timeout = 10)
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_exact_studio_bin_shim_process_is_reported(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
detector = _process_helpers(source)
shim = tmp_path / "studio" / "bin" / "unsloth.exe"
shim.parent.mkdir(parents = True)
shutil.copy2(Path(os.environ["SystemRoot"]) / "System32" / "PING.EXE", shim)
child = subprocess.Popen(
[str(shim), "-n", "6", "127.0.0.1"],
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
try:
script = f"""
$ErrorActionPreference = "Stop"
{detector}
@(Get-RunningStudioVenvProcesses -VenvPath $env:TEST_SHIM -Exact) |
ForEach-Object {{ Write-Output $_.Id }}
"""
env = os.environ.copy()
env["TEST_SHIM"] = str(shim)
assert str(child.pid) in _run_powershell(shell, script, env).splitlines()
finally:
child.terminate()
child.wait(timeout = 10)
def test_installer_scan_protects_the_exact_studio_bin_shim():
source = INSTALL_PS1.read_text(encoding = "utf-8")
assert 'Join-Path $StudioHome "bin\\unsloth.exe"' in source
assert "[pscustomobject]@{ Path = (Join-Path $StudioHome" in source
assert "Exact = $true" in source
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_mutex_names_are_global_and_install_lock_is_path_scoped(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
studio_home = tmp_path / "studio"
studio_home.mkdir()
script = f"""
$ErrorActionPreference = "Stop"
{_mutex_helpers(source)}
Write-Output (Get-StudioInstallMutexName -Path $env:TEST_STUDIO_HOME)
Write-Output (Get-StudioRuntimeMutexNameForSid -Sid "S-1-5-21-111-222-333-1001")
Write-Output (Get-StudioRuntimeMutexNameForSid -Sid "S-1-5-21-111-222-333-1002")
$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
Write-Output ((Get-StudioRuntimeMutexName) -eq `
(Get-StudioRuntimeMutexNameForSid -Sid $currentSid))
"""
env = os.environ.copy()
env["TEST_STUDIO_HOME"] = str(studio_home)
canonical = str(studio_home.resolve()).rstrip("\\/").upper()
digest = hashlib.sha256(canonical.encode()).hexdigest()
assert _run_powershell(shell, script, env).splitlines() == [
f"Global\\UnslothStudioInstall-{digest}",
"Global\\UnslothStudioManagedEnvironment-S-1-5-21-111-222-333-1001",
"Global\\UnslothStudioManagedEnvironment-S-1-5-21-111-222-333-1002",
"True",
]
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_tauri_managed_root_path_classification(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
profile = tmp_path / "profile"
studio_home = profile / ".unsloth" / "studio"
studio_home.mkdir(parents = True)
alias = studio_home / ".." / "studio"
junction = tmp_path / "profile-alias"
junction_result = subprocess.run(
[os.environ["COMSPEC"], "/d", "/c", "mklink", "/J", str(junction), str(profile)],
capture_output = True,
text = True,
)
if junction_result.returncode != 0:
pytest.skip(f"Could not create a directory junction: {junction_result.stderr}")
junction_studio = junction / ".unsloth" / "studio"
script = f"""
$ErrorActionPreference = "Stop"
{_mutex_helpers(source)}
Write-Output (Test-StudioPathEqual -Left $env:TEST_STUDIO_HOME -Right $env:TEST_ALIAS)
Write-Output (Test-StudioPathEqual -Left $env:TEST_STUDIO_HOME -Right $env:TEST_CASE_VARIANT)
Write-Output (Test-StudioPathEqual -Left $env:TEST_STUDIO_HOME -Right $env:TEST_JUNCTION)
Write-Output (Test-StudioPathEqual -Left $env:TEST_STUDIO_HOME -Right $env:TEST_SIBLING)
"""
env = os.environ.copy()
env["TEST_STUDIO_HOME"] = str(studio_home)
env["TEST_ALIAS"] = str(alias)
env["TEST_CASE_VARIANT"] = str(studio_home).upper()
env["TEST_JUNCTION"] = str(junction_studio)
env["TEST_SIBLING"] = str(profile / ".unsloth" / "studio-backup")
assert _run_powershell(shell, script, env).splitlines() == ["True", "True", "True", "False"]
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_tauri_override_accepts_junction_alias_of_managed_root(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
profile = tmp_path / "profile"
studio_home = profile / ".unsloth" / "studio"
studio_home.mkdir(parents = True)
junction = tmp_path / "profile-alias"
junction_result = subprocess.run(
[os.environ["COMSPEC"], "/d", "/c", "mklink", "/J", str(junction), str(profile)],
capture_output = True,
text = True,
)
if junction_result.returncode != 0:
pytest.skip(f"Could not create a directory junction: {junction_result.stderr}")
validation_start = source.index(" # Custom Unsloth roots are not supported with --tauri")
validation_end = source.index(" # LOCALAPPDATA may be unset", validation_start)
validation = source[validation_start:validation_end]
final_path_helper = _final_path_helpers(source)
script = f"""
$ErrorActionPreference = "Stop"
{final_path_helper}
$TauriMode = $true
$envOverride = $env:TEST_TAURI_OVERRIDE
$envOverrideVar = "UNSLOTH_STUDIO_HOME"
$tauriProfile = $env:TEST_TAURI_PROFILE
{validation}
Write-Output "accepted"
"""
env = os.environ.copy()
env["TEST_TAURI_OVERRIDE"] = str(junction / ".unsloth" / "studio")
env["TEST_TAURI_PROFILE"] = str(profile)
assert _run_powershell(shell, script, env) == "accepted"
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_missing_root_beneath_junction_uses_the_physical_mutex_identity(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
profile = tmp_path / "profile"
profile.mkdir()
junction = tmp_path / "profile-alias"
junction_result = subprocess.run(
[os.environ["COMSPEC"], "/d", "/c", "mklink", "/J", str(junction), str(profile)],
capture_output = True,
text = True,
)
if junction_result.returncode != 0:
pytest.skip(f"Could not create a directory junction: {junction_result.stderr}")
script = f"""
$ErrorActionPreference = "Stop"
{_mutex_helpers(source)}
$aliasRoot = $env:TEST_ALIAS_ROOT
$physicalRoot = $env:TEST_PHYSICAL_ROOT
$aliasMatch = Test-StudioPathEqual -Left $aliasRoot -Right $physicalRoot
$physicalMatch = Test-StudioPathEqual -Left $physicalRoot -Right $physicalRoot
$aliasRuntime = @(Get-StudioRuntimeMutexNames -TauriRootMatch $aliasMatch -Path $aliasRoot)
$physicalRuntime = @(Get-StudioRuntimeMutexNames -TauriRootMatch $physicalMatch -Path $physicalRoot)
Write-Output $aliasMatch
Write-Output ((Get-StudioInstallMutexName -Path $aliasRoot) -eq (Get-StudioInstallMutexName -Path $physicalRoot))
Write-Output $aliasRuntime.Count
Write-Output $physicalRuntime.Count
Write-Output ($aliasRuntime[0] -eq $physicalRuntime[0])
Write-Output ($aliasRuntime[0].StartsWith("Global\\UnslothStudioManagedEnvironment-"))
"""
env = os.environ.copy()
env["TEST_ALIAS_ROOT"] = str(junction / ".unsloth" / "studio")
env["TEST_PHYSICAL_ROOT"] = str(profile / ".unsloth" / "studio")
assert _run_powershell(shell, script, env).splitlines() == [
"True",
"True",
"1",
"1",
"True",
"True",
]
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_path_identity_failure_is_reported_as_unknown(shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
script = f"""
$ErrorActionPreference = "Stop"
{_mutex_helpers(source)}
function Resolve-StudioFinalPathInfo {{ throw "identity unavailable" }}
$match = Test-StudioPathEqual -Left "C:\\one" -Right "C:\\two"
Write-Output ($null -eq $match)
"""
assert _run_powershell(shell, script, os.environ.copy()).splitlines()[-1] == "True"
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_install_mutex_blocks_before_target_mutation_and_recovers_abandonment(
tmp_path: Path, shell: str
):
source = INSTALL_PS1.read_text(encoding = "utf-8")
mutex_helpers = _mutex_helpers(source)
studio_home = tmp_path / "studio"
target = studio_home / "unsloth_studio"
target.mkdir(parents = True)
marker = target / "healthy.marker"
marker.write_text("old", encoding = "utf-8")
env = os.environ.copy()
env["TEST_STUDIO_HOME"] = str(studio_home)
env["TEST_TARGET"] = str(target)
holder_script = f"""
$ErrorActionPreference = "Stop"
{mutex_helpers}
$mutex = Enter-StudioInstallMutex -Path $env:TEST_STUDIO_HOME
if ($null -eq $mutex) {{ throw "holder did not acquire mutex" }}
Write-Output "READY"
[Console]::ReadLine() | Out-Null
Exit-StudioInstallMutex -Mutex $mutex
"""
holder = subprocess.Popen(
[
shell,
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
_ps_file(tmp_path, "holder.ps1", holder_script),
],
stdin = subprocess.PIPE,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
text = True,
encoding = "utf-8",
errors = "replace",
env = env,
)
try:
assert holder.stdout is not None
assert holder.stdout.readline().strip() == "READY"
contender_script = f"""
$ErrorActionPreference = "Stop"
{mutex_helpers}
$mutex = Enter-StudioInstallMutex -Path $env:TEST_STUDIO_HOME
if ($null -eq $mutex) {{ Write-Output "BLOCKED"; exit 0 }}
Move-Item -LiteralPath $env:TEST_TARGET -Destination ($env:TEST_TARGET + ".rollback") -ErrorAction Stop
Write-Output "MUTATED"
Exit-StudioInstallMutex -Mutex $mutex
"""
assert _run_powershell(shell, contender_script, env) == "BLOCKED"
assert marker.read_text(encoding = "utf-8") == "old"
assert not Path(f"{target}.rollback").exists()
holder.kill()
holder.wait(timeout = 10)
assert _run_powershell(shell, contender_script, env) == "MUTATED"
assert not target.exists()
assert (Path(f"{target}.rollback") / marker.name).read_text(encoding = "utf-8") == "old"
finally:
if holder.poll() is None:
holder.kill()
holder.wait(timeout = 10)
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_unknown_root_identity_acquires_sid_and_path_mutexes(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
helpers = _mutex_helpers(source)
custom_root = tmp_path / "custom-studio"
custom_root.mkdir()
script = f"""
$ErrorActionPreference = "Stop"
{helpers}
$names = @(
Get-StudioRuntimeMutexNames -TauriRootMatch $null -Path $env:TEST_STUDIO_HOME
)
$names | ForEach-Object {{ Write-Output $_ }}
"""
env = os.environ.copy()
env["TEST_STUDIO_HOME"] = str(custom_root)
names = _run_powershell(shell, script, env).splitlines()
assert len(names) == 2
assert any(name.startswith("Global\\UnslothStudioManagedEnvironment-S-1-") for name in names)
assert any(name.startswith("Global\\UnslothStudioManagedEnvironmentPath-") for name in names)
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_unicode_custom_root_mutex_name_matches_python(tmp_path: Path, shell: str):
from unsloth_cli import _studio_runtime_gate as gate
source = INSTALL_PS1.read_text(encoding = "utf-8")
helpers = _mutex_helpers(source)
custom_root = tmp_path / "Studio-ß"
custom_root.mkdir()
script = f"""
$ErrorActionPreference = "Stop"
{helpers}
Write-Output (Get-StudioRuntimeMutexNameForPath -Path $env:TEST_STUDIO_HOME)
"""
env = os.environ.copy()
env["TEST_STUDIO_HOME"] = str(custom_root)
powershell_name = _run_powershell(shell, script, env)
assert powershell_name == gate.runtime_mutex_name_for_studio_home(custom_root)
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_drive_root_identity_and_mutex_names_match_python(shell: str):
from unsloth_cli import _studio_runtime_gate as gate
source = INSTALL_PS1.read_text(encoding = "utf-8")
helpers = _mutex_helpers(source)
drive_root = Path(f"{os.environ['SystemDrive']}\\")
script = f"""
$ErrorActionPreference = "Stop"
{helpers}
Write-Output (Get-StudioFinalPath -Path $env:TEST_STUDIO_HOME)
Write-Output (Get-StudioInstallMutexName -Path $env:TEST_STUDIO_HOME)
Write-Output (Get-StudioRuntimeMutexNameForPath -Path $env:TEST_STUDIO_HOME)
"""
env = os.environ.copy()
env["TEST_STUDIO_HOME"] = str(drive_root)
final_path, install_name, runtime_name = _run_powershell(shell, script, env).splitlines()
canonical = gate._resolved_windows_path(drive_root)
install_digest = hashlib.sha256(canonical.upper().encode("utf-8")).hexdigest()
assert final_path == canonical
assert final_path.endswith("\\")
assert install_name == f"Global\\UnslothStudioInstall-{install_digest}"
runtime_digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
expected_runtime = f"Global\\UnslothStudioManagedEnvironmentPath-{runtime_digest}"
assert runtime_name == expected_runtime
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason = "Windows PowerShell is required")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_runtime_gate_blocks_a_late_backend_start(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
helper = _extract(r" function Enter-StudioNamedMutex \{.*?\n \}\n", source)
release = _extract(r" function Exit-StudioInstallMutex \{.*?\n \}\n", source)
mutex_name = f"Global\\UnslothStudioRuntimeGateTest-{os.getpid()}-{tmp_path.name}"
env = os.environ.copy()
env["TEST_RUNTIME_MUTEX"] = mutex_name
holder_script = f"""
$ErrorActionPreference = "Stop"
{helper}
{release}
$mutex = Enter-StudioNamedMutex -Name $env:TEST_RUNTIME_MUTEX
if ($null -eq $mutex) {{ throw "holder did not acquire mutex" }}
Write-Output "READY"
[Console]::ReadLine() | Out-Null
Exit-StudioInstallMutex -Mutex $mutex
"""
contender_script = f"""
$ErrorActionPreference = "Stop"
{helper}
{release}
$mutex = Enter-StudioNamedMutex -Name $env:TEST_RUNTIME_MUTEX
if ($null -eq $mutex) {{ Write-Output "BLOCKED"; exit 0 }}
Write-Output "ACQUIRED"
Exit-StudioInstallMutex -Mutex $mutex
"""
holder = subprocess.Popen(
[
shell,
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
_ps_file(tmp_path, "holder.ps1", holder_script),
],
stdin = subprocess.PIPE,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
text = True,
encoding = "utf-8",
errors = "replace",
env = env,
)
try:
assert holder.stdout is not None
assert holder.stdout.readline().strip() == "READY"
assert _run_powershell(shell, contender_script, env) == "BLOCKED"
holder.kill()
holder.wait(timeout = 10)
assert _run_powershell(shell, contender_script, env) == "ACQUIRED"
finally:
if holder.poll() is None:
holder.kill()
holder.wait(timeout = 10)
def test_runtime_path_hash_is_defined_before_custom_root_lock_uses_it():
source = INSTALL_PS1.read_text(encoding = "utf-8")
path_hash = source.index(" function Get-StudioRuntimePathHash {")
path_mutex = source.index(" function Get-StudioRuntimeMutexNameForPath {")
acquire = source.index("$studioRuntimeMutexNames = @(")
assert path_hash < path_mutex < acquire
def test_guard_and_mutex_precede_rollback_and_release_after_restore():
source = INSTALL_PS1.read_text(encoding = "utf-8")
acquire = source.index("$studioInstallMutex = Enter-StudioInstallMutex -Path $StudioHome")
root_match = source.index("$studioTauriRootMatch =", acquire)
managed_root = source.index("$studioUsesTauriManagedRoot =", root_match)
runtime_lock_needed = source.index("$studioNeedsRuntimeLock =", managed_root)
legacy_layout = source.index("$studioUsesLegacyLayout =", runtime_lock_needed)
runtime_name = source.index("Get-StudioRuntimeMutexName", legacy_layout)
legacy_scan = source.index("if ($studioUsesLegacyLayout)", runtime_name)
runtime_lock = source.index(
"Enter-StudioNamedMutex -Name $studioRuntimeMutexName",
runtime_name,
)
scan_candidates = source.index("$protectedProcessPaths = @(", runtime_lock)
legacy_source = source.index('Join-Path $StudioHome ".venv"', scan_candidates)
cwd_source = source.index('Join-Path $env:USERPROFILE "unsloth_studio"', legacy_source)
runtime_guard = source.index("foreach ($candidate in $protectedProcessPaths)", cwd_source)
desktop_guard = source.index("Get-StudioDesktopProcessesForCurrentUser", runtime_guard)
dependency_check = source.index('Write-TauriLog "STEP" "Checking system dependencies"')
rollback = source.index("Start-StudioVenvRollback -ExistingDir $VenvDir", desktop_guard)
old_venv_move = source.index("Move-Item -LiteralPath $OldVenv", rollback)
cwd_venv_move = source.index("Move-Item -LiteralPath $CwdVenv", old_venv_move)
restore = source.rindex("Restore-StudioVenvRollback")
prompt = source.index("Start Unsloth Studio now?", restore)
autostart = source.index("Start-Process -FilePath $VenvPython", prompt)
release_runtime = source.rindex("Exit-StudioInstallMutex -Mutex $studioRuntimeMutexes[$i]")
release_install = source.rindex("Exit-StudioInstallMutex -Mutex $studioInstallMutex")
wait_for_exit = source.rindex("$studioAutoStartProcess.WaitForExit()")
assert (
acquire
< root_match
< managed_root
< runtime_lock_needed
< legacy_layout
< runtime_name
< runtime_lock
)
assert runtime_lock < scan_candidates < legacy_scan < legacy_source < cwd_source
assert cwd_source < runtime_guard < desktop_guard < dependency_check < rollback
assert source.count("$studioUsesLegacyLayout `") >= 2
assert "if ($studioNeedsRuntimeLock)" in source
assert (
"$studioUsesLegacyLayout = ($StudioRedirectMode -ne 'env') -or $studioUsesTauriManagedRoot"
) in source
assert "-not $TauriMode -and $studioUsesLegacyLayout" in source
assert runtime_guard < rollback < old_venv_move < cwd_venv_move
assert (
rollback < restore < prompt < autostart < release_runtime < release_install < wait_for_exit
)
assert "if ($StudioRedirectMode -eq 'legacy')" not in source
assert "& $UnslothExe studio -p 8888" not in source
# Anchored past the command token: uv is invoked as the resolved $script:UvExe.
assert "--clear" not in source[source.index("venv $VenvDir") :][:200]
def test_tauri_runtime_uses_the_same_gate_before_backend_spawn():
install_source = INSTALL_PS1.read_text(encoding = "utf-8")
process_source = PROCESS_RS.read_text(encoding = "utf-8")
assert '"Global\\UnslothStudioManagedEnvironment-$Sid"' in install_source
assert '"Global\\\\UnslothStudioManagedEnvironment-"' in process_source
assert "Get-StudioRuntimeMutexName" in install_source
assert "Get-StudioRuntimeMutexNameForSid" in install_source
assert "studio_runtime_mutex_name_for_sid" in process_source
assert "current_windows_user_sid()?" in process_source
assert "studio_runtime_mutex_name_for_path" not in process_source
start = process_source.index("pub fn start_backend(")
guard = process_source.index("acquire_studio_runtime_launch_guard()?", start)
resolve = process_source.index("resolve_backend_binary()", guard)
handoff = process_source.index("STUDIO_RUNTIME_GATE_HANDOFF_ENV", resolve)
spawn = process_source.index("cmd.spawn()", handoff)
store = process_source.index("proc.owned = Some(", spawn)
assert guard < resolve < handoff < spawn < store
def test_every_tauri_managed_child_spawn_uses_the_runtime_gate():
process_source = PROCESS_RS.read_text(encoding = "utf-8")
commands_source = COMMANDS_RS.read_text(encoding = "utf-8")
preflight_source = PREFLIGHT_MANAGED_RS.read_text(encoding = "utf-8")
desktop_auth_source = DESKTOP_AUTH_RS.read_text(encoding = "utf-8")
update_source = UPDATE_RS.read_text(encoding = "utf-8")
assert "pub(crate) fn with_studio_runtime_launch_guard<T>" in process_source
install_check = commands_source.index("pub async fn check_install_status()")
install_guard = commands_source.index("with_studio_runtime_launch_guard", install_check)
install_spawn = commands_source.index("cmd.spawn()", install_guard)
assert install_guard < install_spawn
first_probe = preflight_source.index("async fn run_cli_probe(")
first_guard = preflight_source.index("with_studio_runtime_launch_guard", first_probe)
first_spawn = preflight_source.index("cmd.spawn()", first_guard)
capability_probe = preflight_source.index("async fn probe_cli_capability(", first_spawn)
capability_guard = preflight_source.index("with_studio_runtime_launch_guard", capability_probe)
capability_spawn = preflight_source.index("cmd.spawn()", capability_guard)
assert first_guard < first_spawn < capability_probe < capability_guard < capability_spawn
provision = desktop_auth_source.index("async fn provision_desktop_auth()")
provision_guard = desktop_auth_source.index("with_studio_runtime_launch_guard", provision)
provision_spawn = desktop_auth_source.index("cmd.spawn()", provision_guard)
provision_wait = desktop_auth_source.index("child.wait_with_output()", provision_spawn)
assert provision_guard < provision_spawn < provision_wait
update_call = update_source.index(
"let result = crate::process::with_studio_runtime_launch_guard"
)
update_scan = update_source.index(
"ensure_managed_environment_is_idle(&bin)",
update_call,
)
update_spawn = update_source.index("spawn_update(&bin, &state)", update_scan)
update_wait = update_source.index("wait_for_exit(&state)", update_spawn)
update_guard_release = update_source.index("\n });", update_wait)
assert update_call < update_scan < update_spawn < update_wait < update_guard_release
def test_runtime_gate_handoff_covers_tauri_backend_and_installer_autostart():
process_source = PROCESS_RS.read_text(encoding = "utf-8")
install_source = INSTALL_PS1.read_text(encoding = "utf-8")
studio_source = STUDIO_COMMAND.read_text(encoding = "utf-8")
start = process_source.index("pub fn start_backend(")
handoff = process_source.index('cmd.env(STUDIO_RUNTIME_GATE_HANDOFF_ENV, "1")', start)
spawn = process_source.index("cmd.spawn()", handoff)
assert handoff < spawn
prompt = install_source.index("Start Unsloth Studio now?")
save = install_source.index("$_runtimeGateHandoff =", prompt)
set_handoff = install_source.index(
'$env:_UNSLOTH_STUDIO_RUNTIME_GATE_HANDOFF = "1"',
save,
)
autostart = install_source.index("Start-Process -FilePath $VenvPython", set_handoff)
restore = install_source.index(
"$env:_UNSLOTH_STUDIO_RUNTIME_GATE_HANDOFF = $_runtimeGateHandoff",
autostart,
)
assert save < set_handoff < autostart < restore
setup_python = install_source.index("$env:UNSLOTH_SETUP_PYTHON =")
setup_save = install_source.index("$previousSetupRuntimeGateHandoff =", setup_python)
setup_set = install_source.index(
'$env:_UNSLOTH_STUDIO_RUNTIME_GATE_HANDOFF = "1"',
setup_save,
)
setup_invoke = install_source.index(
"Invoke-ManagedUnslothCli -Python $VenvPython -Arguments $studioArgs", setup_set
)
setup_restore = install_source.index(
"$env:_UNSLOTH_STUDIO_RUNTIME_GATE_HANDOFF = $previousSetupRuntimeGateHandoff",
setup_invoke,
)
tauri_remove = install_source.index("Remove-Item Env:UNSLOTH_TAURI_MODE", setup_invoke)
assert setup_python < setup_save < setup_set < setup_invoke < tauri_remove < setup_restore
assert (
studio_source.count(
"runtime_gate_handoff = _studio_runtime_gate.consume_runtime_gate_handoff()"
)
== 4
)
assert studio_source.count("inherited = runtime_gate_handoff") >= 5
def test_tauri_start_install_rejects_backend_conflicts_before_spawn():
source = COMMANDS_RS.read_text(encoding = "utf-8")
start = source.index("pub async fn start_install(")
end = source.index("\n}\n", start)
body = source[start:end]
owned_guard = body.index("has_owned_backend(&backend_state)?")
external_guard = body.index("block_external_conflict(&[]).await?")
spawn = body.index("install::run_install")
assert owned_guard < external_guard < spawn
@pytest.mark.parametrize(
"helpers",
[_mutex_helpers, _process_helpers],
ids = ["mutex", "process"],
)
def test_the_extracted_helpers_can_call_everything_they_call(helpers):
"""Every installer function these harnesses reach must be in the harness.
The scripts above run under -ErrorActionPreference Stop, so a helper that
calls an installer function nobody extracted dies with CommandNotFound, and
the test fails for a reason unrelated to what it measures. That is not
hypothetical: Test-StudioPathEqual reports an unresolvable path identity
through Write-StudioLine, which was missing, so both
test_path_identity_failure_is_reported_as_unknown cases failed on Windows
while passing nowhere they could be noticed.
Runs on every platform, unlike the scripts themselves, so the harness cannot
drift out of step again where only a Windows runner would see it.
"""
source = INSTALL_PS1.read_text(encoding = "utf-8")
extracted = helpers(source)
# Every top-level installer function, i.e. everything the harness COULD be
# missing. A call to a cmdlet or to a function defined inside the scripts is
# not this test's business.
installer_functions = set(re.findall(r"^ function ([\w-]+) \{", source, flags = re.M))
provided = set(re.findall(r"^ function ([\w-]+) \{", extracted, flags = re.M))
assert provided, "the helper extraction produced nothing"
called = set(re.findall(r"(?<![\w-])([A-Z][\w]*-[\w-]+)", extracted))
missing = sorted((called & installer_functions) - provided)
assert not missing, (
f"{helpers.__name__} extracts functions that call {missing}, which the "
"harness never defines; add them to the extraction list"
)