fix: [Studio] avoid Windows in-use unsloth.exe lock during update

install.ps1 now stages a standalone unsloth.exe in
$LOCALAPPDATA\Unsloth Studio\bin and prepends that dir to user PATH so
the running launcher during `unsloth studio update` lives outside the
venv. setup.ps1 gains an idempotent migration path that parks any
pre-existing locked venv launcher (MoveFileEx-allowed) before uv runs
and syncs the standalone copy afterwards via SHA256 compare.
This commit is contained in:
Roland Tannous 2026-04-16 13:29:04 +04:00
parent 14ab6fbfae
commit d6aff00de3
2 changed files with 82 additions and 1 deletions

View file

@ -906,11 +906,34 @@ shell.Run cmd, 0, False
}
}
# ── Stage standalone launcher outside the venv ──
# Copying the distlib-generated unsloth.exe to $LOCALAPPDATA\Unsloth Studio\bin
# (and prepending that dir to User PATH) means future `unsloth studio update`
# invocations run from the standalone copy, so pip/uv can freely rewrite
# the venv's in-use Scripts\unsloth.exe during dependency upgrades.
$VenvScriptsExe = Join-Path $VenvDir "Scripts\unsloth.exe"
$StandaloneBinDir = Join-Path $env:LOCALAPPDATA "Unsloth Studio\bin"
$StandaloneExe = Join-Path $StandaloneBinDir "unsloth.exe"
if (Test-Path $VenvScriptsExe) {
if (-not (Test-Path $StandaloneBinDir)) {
New-Item -ItemType Directory -Force $StandaloneBinDir | Out-Null
}
try { Copy-Item -LiteralPath $VenvScriptsExe -Destination $StandaloneExe -Force } catch {}
$_userPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
if (-not $_userPath -or $_userPath -notlike "*$StandaloneBinDir*") {
$_newPath = if ($_userPath) { "$StandaloneBinDir;$_userPath" } else { $StandaloneBinDir }
[System.Environment]::SetEnvironmentVariable("Path", $_newPath, "User")
Refresh-SessionPath
}
}
# ── Run studio setup ──
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
# CUDA Toolkit, Node.js, and other dependencies automatically via winget.
step "setup" "running unsloth studio setup..."
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
# Prefer the standalone launcher (so the running .exe is not inside the venv)
# and fall back to the venv copy if the staging step above did not succeed.
$UnslothExe = if (Test-Path $StandaloneExe) { $StandaloneExe } else { $VenvScriptsExe }
if (-not (Test-Path $UnslothExe)) {
Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red
Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow

View file

@ -1418,6 +1418,22 @@ $ErrorActionPreference = "Continue"
$ActivateScript = Join-Path $VenvDir "Scripts\Activate.ps1"
. $ActivateScript
# ── Standalone launcher locations (Windows in-use .exe lock workaround) ──
# Windows forbids DeleteFile on a mapped (running) .exe but permits
# MoveFileEx. Keeping the CLI launcher outside the venv means pip/uv
# never fights a lock when it reinstalls the unsloth wheel during update.
$StandaloneBinDir = Join-Path $env:LOCALAPPDATA "Unsloth Studio\bin"
$StandaloneExe = Join-Path $StandaloneBinDir "unsloth.exe"
$VenvExe = Join-Path $VenvDir "Scripts\unsloth.exe"
# Sweep stale parked launchers left by prior migrations (safe: not in use)
foreach ($_dir in @((Join-Path $VenvDir "Scripts"), $StandaloneBinDir)) {
if (Test-Path $_dir) {
Get-ChildItem $_dir -Filter "unsloth.exe.old-*" -ErrorAction SilentlyContinue |
ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue }
}
}
# Try to use uv (much faster than pip), fall back to pip if unavailable
$UseUv = $false
if (Get-Command uv -ErrorAction SilentlyContinue) {
@ -1490,6 +1506,15 @@ if ($env:SKIP_STUDIO_BASE -ne "1" -and $env:STUDIO_LOCAL_INSTALL -ne "1") {
if (-not $SkipPythonDeps) {
# Migration: if there is no standalone launcher yet AND install.ps1 is not
# the caller (SKIP_STUDIO_BASE != 1 => uv will reinstall the unsloth wheel),
# park the (possibly-running) venv launcher so uv's reinstall succeeds.
# Fresh-install flow leaves $VenvExe alone: install.ps1 creates the
# standalone copy before invoking setup, so this branch is a no-op there.
if (-not (Test-Path $StandaloneExe) -and (Test-Path $VenvExe) -and $env:SKIP_STUDIO_BASE -ne "1") {
try { Move-Item -LiteralPath $VenvExe -Destination "$VenvExe.old-$PID" -Force } catch {}
}
if ($script:UnslothVerbose) {
Fast-Install --upgrade pip
} else {
@ -1587,6 +1612,39 @@ if ($stackExit -ne 0) {
$ErrorActionPreference = $prevEAP
}
# ── Sync standalone launcher and prepend its dir to user PATH ──
# Runs unconditionally (both fast-path and reinstall branches) so that users
# on an old layout migrate on the first update where no standalone exists.
# Copy path uses park-and-replace (MoveFileEx) so even a currently-running
# $StandaloneExe can be swapped when distlib's launcher bytes actually change.
if (Test-Path $VenvExe) {
if (-not (Test-Path $StandaloneBinDir)) {
New-Item -ItemType Directory -Force $StandaloneBinDir | Out-Null
}
$needsCopy = -not (Test-Path $StandaloneExe)
if (-not $needsCopy) {
try {
$needsCopy = (Get-FileHash $VenvExe).Hash -ne (Get-FileHash $StandaloneExe).Hash
} catch {
$needsCopy = $true
}
}
if ($needsCopy) {
if (Test-Path $StandaloneExe) {
try { Move-Item -LiteralPath $StandaloneExe -Destination "$StandaloneExe.old-$PID" -Force } catch {}
}
try { Copy-Item -LiteralPath $VenvExe -Destination $StandaloneExe -Force } catch {}
}
}
if (Test-Path $StandaloneExe) {
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath -or $userPath -notlike "*$StandaloneBinDir*") {
$newPath = if ($userPath) { "$StandaloneBinDir;$userPath" } else { $StandaloneBinDir }
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
}
}
# ── Pre-install transformers 5.x into .venv_t5_530/ and .venv_t5_550/ ──
# Runs outside the deps fast-path gate so that upgrades from the legacy
# single .venv_t5 are always migrated to the tiered layout.