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
commit d6aff00de3
2 changed files with 82 additions and 1 deletions

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.