fix desktop auth runtime installer regressions
This commit is contained in:
parent
7d3e01a9c5
commit
f8c7ca67b1
12 changed files with 601 additions and 205 deletions
162
install.ps1
162
install.ps1
|
|
@ -8,6 +8,29 @@ function Install-UnslothStudio {
|
|||
$ErrorActionPreference = "Stop"
|
||||
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq "1")
|
||||
|
||||
# ── Tauri structured output ──
|
||||
function Write-TauriLog {
|
||||
param([string]$Tag, [string]$Message)
|
||||
if ($TauriMode) {
|
||||
Write-Host "[TAURI:$Tag] $Message"
|
||||
}
|
||||
}
|
||||
|
||||
function Exit-InstallFailure {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Message,
|
||||
[int]$Code = 1
|
||||
)
|
||||
if ($Code -eq 0) { $Code = 1 }
|
||||
Write-TauriLog "ERROR" $Message
|
||||
if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) {
|
||||
Restore-StudioVenvRollback
|
||||
}
|
||||
if ($TauriMode) {
|
||||
exit $Code
|
||||
}
|
||||
}
|
||||
|
||||
# ── Parse flags ──
|
||||
$StudioLocalInstall = $false
|
||||
$PackageName = "unsloth"
|
||||
|
|
@ -26,7 +49,7 @@ function Install-UnslothStudio {
|
|||
$i++
|
||||
if ($i -ge $argList.Count) {
|
||||
Write-Host "[ERROR] --package requires an argument." -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "--package requires an argument.")
|
||||
}
|
||||
$PackageName = $argList[$i]
|
||||
}
|
||||
|
|
@ -42,22 +65,14 @@ function Install-UnslothStudio {
|
|||
$RepoRoot = (Resolve-Path (Split-Path -Parent $PSCommandPath)).Path
|
||||
if (-not (Test-Path (Join-Path $RepoRoot "pyproject.toml"))) {
|
||||
Write-Host "[ERROR] --local must be run from the unsloth repo root (pyproject.toml not found at $RepoRoot)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "--local must be run from the unsloth repo root")
|
||||
}
|
||||
}
|
||||
|
||||
# Validate --package to prevent injection into shell/Python commands
|
||||
if ($PackageName -notmatch '^[a-zA-Z0-9][a-zA-Z0-9._-]*$') {
|
||||
Write-Host "[ERROR] --package name contains invalid characters (allowed: a-z A-Z 0-9 . _ -)" -ForegroundColor Red
|
||||
return
|
||||
}
|
||||
|
||||
# ── Tauri structured output ──
|
||||
function Write-TauriLog {
|
||||
param([string]$Tag, [string]$Message)
|
||||
if ($TauriMode) {
|
||||
Write-Host "[TAURI:$Tag] $Message"
|
||||
}
|
||||
return (Exit-InstallFailure "--package name contains invalid characters")
|
||||
}
|
||||
|
||||
$PythonVersion = "3.13"
|
||||
|
|
@ -630,7 +645,7 @@ shell.Run cmd, 0, False
|
|||
step "winget" "not available" "Red"
|
||||
substep "Install it from https://aka.ms/getwinget" "Yellow"
|
||||
substep "or install Python $PythonVersion and uv manually, then re-run." "Yellow"
|
||||
return
|
||||
return (Exit-InstallFailure "winget is not available")
|
||||
}
|
||||
|
||||
# ── Helper: detect a working Python 3.11-3.13 on the system ──
|
||||
|
|
@ -749,7 +764,7 @@ shell.Run cmd, 0, False
|
|||
Write-Host " Please install Python $PythonVersion manually from https://www.python.org/downloads/" -ForegroundColor Yellow
|
||||
Write-Host " Make sure to check 'Add Python to PATH' during installation." -ForegroundColor Yellow
|
||||
Write-Host " Then re-run this installer." -ForegroundColor Yellow
|
||||
return
|
||||
return (Exit-InstallFailure "Python installation failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -773,7 +788,7 @@ shell.Run cmd, 0, False
|
|||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
step "uv" "could not be installed" "Red"
|
||||
substep "Install it from https://docs.astral.sh/uv/" "Yellow"
|
||||
return
|
||||
return (Exit-InstallFailure "uv could not be installed")
|
||||
}
|
||||
|
||||
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
|
||||
|
|
@ -786,11 +801,68 @@ shell.Run cmd, 0, False
|
|||
|
||||
$VenvPython = Join-Path $VenvDir "Scripts\python.exe"
|
||||
$_Migrated = $false
|
||||
$script:StudioVenvRollbackDir = $null
|
||||
$script:StudioVenvRollbackTarget = $VenvDir
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
|
||||
function Start-StudioVenvRollback {
|
||||
param([Parameter(Mandatory = $true)][string]$ExistingDir)
|
||||
$stamp = Get-Date -Format "yyyyMMddHHmmss"
|
||||
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID"
|
||||
$suffix = 0
|
||||
while (Test-Path $candidate) {
|
||||
$suffix++
|
||||
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID.$suffix"
|
||||
}
|
||||
Move-Item -Path $ExistingDir -Destination $candidate -ErrorAction Stop
|
||||
$script:StudioVenvRollbackDir = $candidate
|
||||
$script:StudioVenvRollbackTarget = $ExistingDir
|
||||
$script:StudioVenvRollbackActive = $true
|
||||
substep "previous environment preserved for rollback"
|
||||
}
|
||||
|
||||
function Restore-StudioVenvRollback {
|
||||
if (-not $script:StudioVenvRollbackActive) { return }
|
||||
$backup = $script:StudioVenvRollbackDir
|
||||
$target = $script:StudioVenvRollbackTarget
|
||||
if (-not $backup -or -not (Test-Path $backup)) {
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
return
|
||||
}
|
||||
substep "restoring previous environment after failed install..." "Yellow"
|
||||
try {
|
||||
if (Test-Path $target) {
|
||||
Remove-Item -Recurse -Force $target -ErrorAction SilentlyContinue
|
||||
}
|
||||
Move-Item -Path $backup -Destination $target -Force -ErrorAction Stop
|
||||
substep "restored previous environment"
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
$script:StudioVenvRollbackDir = $null
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not restore previous environment from $backup to $target" -ForegroundColor Yellow
|
||||
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
function Complete-StudioVenvRollback {
|
||||
if (-not $script:StudioVenvRollbackActive) { return }
|
||||
$backup = $script:StudioVenvRollbackDir
|
||||
if ($backup -and (Test-Path $backup)) {
|
||||
Remove-Item -Recurse -Force $backup -ErrorAction SilentlyContinue
|
||||
}
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
$script:StudioVenvRollbackDir = $null
|
||||
}
|
||||
|
||||
if (Test-Path $VenvPython) {
|
||||
# New layout already exists -- nuke for fresh install
|
||||
substep "removing existing environment for fresh install..."
|
||||
Remove-Item -Recurse -Force $VenvDir
|
||||
# New layout already exists -- replace only after preserving rollback copy.
|
||||
substep "preserving existing environment for rollback..."
|
||||
try {
|
||||
Start-StudioVenvRollback -ExistingDir $VenvDir
|
||||
} catch {
|
||||
Write-Host "[ERROR] Could not prepare existing environment for reinstall: $($_.Exception.Message)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Could not prepare existing environment for reinstall")
|
||||
}
|
||||
} elseif (Test-Path (Join-Path $StudioHome ".venv\Scripts\python.exe")) {
|
||||
# Old layout (~/.unsloth/studio/.venv) exists -- validate before migrating
|
||||
$OldVenv = Join-Path $StudioHome ".venv"
|
||||
|
|
@ -799,18 +871,23 @@ shell.Run cmd, 0, False
|
|||
$prevEAP2 = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
& $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null
|
||||
$torchOk = ($LASTEXITCODE -eq 0)
|
||||
} catch { $torchOk = $false }
|
||||
if ($SkipTorch) {
|
||||
& $OldPy -c "import sys; print(sys.executable)" 2>$null | Out-Null
|
||||
} else {
|
||||
& $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null
|
||||
}
|
||||
$legacyOk = ($LASTEXITCODE -eq 0)
|
||||
} catch { $legacyOk = $false }
|
||||
$ErrorActionPreference = $prevEAP2
|
||||
if ($torchOk) {
|
||||
if ($legacyOk) {
|
||||
substep "legacy environment is healthy -- migrating..."
|
||||
Move-Item -Path $OldVenv -Destination $VenvDir -Force
|
||||
substep "moved .venv -> unsloth_studio"
|
||||
$_Migrated = $true
|
||||
} else {
|
||||
substep "legacy environment failed validation -- creating fresh environment" "Yellow"
|
||||
Remove-Item -Recurse -Force $OldVenv -ErrorAction SilentlyContinue
|
||||
$invalidVenv = Join-Path $StudioHome (".venv.invalid.{0}.{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), $PID)
|
||||
Move-Item -Path $OldVenv -Destination $invalidVenv -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} elseif (Test-Path (Join-Path $env:USERPROFILE "unsloth_studio\Scripts\python.exe")) {
|
||||
# CWD-relative venv from old install.ps1 -- migrate to absolute path
|
||||
|
|
@ -826,9 +903,8 @@ shell.Run cmd, 0, False
|
|||
substep "$VenvDir"
|
||||
$venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" }
|
||||
if ($venvExit -ne 0) {
|
||||
Write-TauriLog "ERROR" "Failed to create virtual environment (exit code $venvExit)"
|
||||
Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to create virtual environment (exit code $venvExit)" $venvExit)
|
||||
}
|
||||
} else {
|
||||
step "venv" "using migrated environment"
|
||||
|
|
@ -946,14 +1022,14 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
}
|
||||
if ($StudioLocalInstall) {
|
||||
substep "overlaying local repo (editable)..."
|
||||
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
||||
if ($overlayExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
||||
}
|
||||
}
|
||||
} elseif ($TorchIndexUrl) {
|
||||
|
|
@ -964,9 +1040,8 @@ shell.Run cmd, 0, False
|
|||
substep "installing PyTorch ($TorchIndexUrl)..."
|
||||
$torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
|
||||
if ($torchInstallExit -ne 0) {
|
||||
Write-TauriLog "ERROR" "Failed to install PyTorch (exit code $torchInstallExit)"
|
||||
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -988,9 +1063,8 @@ shell.Run cmd, 0, False
|
|||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-TauriLog "ERROR" "Failed to install unsloth (exit code $baseInstallExit)"
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
}
|
||||
|
||||
if ($StudioLocalInstall) {
|
||||
|
|
@ -998,7 +1072,7 @@ shell.Run cmd, 0, False
|
|||
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
||||
if ($overlayExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1009,20 +1083,19 @@ shell.Run cmd, 0, False
|
|||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.4.8" --torch-backend=auto }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
}
|
||||
substep "overlaying local repo (editable)..."
|
||||
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
||||
if ($overlayExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --torch-backend=auto -- "$PackageName" }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-TauriLog "ERROR" "Failed to install unsloth (exit code $baseInstallExit)"
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1034,12 +1107,11 @@ shell.Run cmd, 0, False
|
|||
step "setup" "running unsloth studio setup..."
|
||||
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
|
||||
if (-not (Test-Path $UnslothExe)) {
|
||||
Write-TauriLog "ERROR" "unsloth CLI was not installed correctly"
|
||||
Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red
|
||||
Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow
|
||||
Write-Host " This usually means an older unsloth version was installed that does not include the Studio CLI." -ForegroundColor Yellow
|
||||
Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow
|
||||
return
|
||||
return (Exit-InstallFailure "unsloth CLI was not installed correctly")
|
||||
}
|
||||
# Tell setup.ps1 to skip base package installation (install.ps1 already did it)
|
||||
$env:SKIP_STUDIO_BASE = "1"
|
||||
|
|
@ -1061,15 +1133,16 @@ shell.Run cmd, 0, False
|
|||
# and bypass the fast-path version check from PR #4667.
|
||||
$studioArgs = @('studio', 'setup')
|
||||
if ($script:UnslothVerbose) { $studioArgs += '--verbose' }
|
||||
& $UnslothExe @studioArgs
|
||||
$setupExit = $LASTEXITCODE
|
||||
$env:UNSLOTH_INSTALL_ROLLBACK_MANAGED = "1"
|
||||
try {
|
||||
& $UnslothExe @studioArgs
|
||||
$setupExit = $LASTEXITCODE
|
||||
} finally {
|
||||
Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($setupExit -ne 0) {
|
||||
Write-TauriLog "ERROR" "unsloth studio setup failed (exit code $setupExit)"
|
||||
Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red
|
||||
if ($TauriMode) {
|
||||
exit $setupExit
|
||||
}
|
||||
return
|
||||
return (Exit-InstallFailure "unsloth studio setup failed (exit code $setupExit)" $setupExit)
|
||||
}
|
||||
|
||||
# ── Expose `unsloth` via a shim dir containing only unsloth.exe ──
|
||||
|
|
@ -1142,6 +1215,7 @@ shell.Run cmd, 0, False
|
|||
step "path" "added unsloth launcher to PATH"
|
||||
}
|
||||
Refresh-SessionPath # sync current session with registry
|
||||
Complete-StudioVenvRollback
|
||||
|
||||
# ── Tauri mode: done, skip shortcuts and auto-launch ──
|
||||
if ($TauriMode) {
|
||||
|
|
|
|||
87
install.sh
87
install.sh
|
|
@ -165,6 +165,60 @@ tauri_log() {
|
|||
PYTHON_VERSION="" # resolved after platform detection
|
||||
STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
VENV_DIR="$STUDIO_HOME/unsloth_studio"
|
||||
_VENV_ROLLBACK_DIR=""
|
||||
_VENV_ROLLBACK_TARGET="$VENV_DIR"
|
||||
_VENV_ROLLBACK_ACTIVE=false
|
||||
|
||||
_start_studio_venv_replacement() {
|
||||
_existing_dir="$1"
|
||||
_stamp=$(date +%Y%m%d%H%M%S 2>/dev/null || echo "time")
|
||||
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$"
|
||||
_suffix=0
|
||||
while [ -e "$_candidate" ]; do
|
||||
_suffix=$((_suffix + 1))
|
||||
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$.$_suffix"
|
||||
done
|
||||
mv "$_existing_dir" "$_candidate"
|
||||
_VENV_ROLLBACK_DIR="$_candidate"
|
||||
_VENV_ROLLBACK_TARGET="$_existing_dir"
|
||||
_VENV_ROLLBACK_ACTIVE=true
|
||||
substep "previous environment preserved for rollback"
|
||||
}
|
||||
|
||||
_restore_studio_venv_replacement() {
|
||||
[ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0
|
||||
[ -n "$_VENV_ROLLBACK_DIR" ] && [ -d "$_VENV_ROLLBACK_DIR" ] || {
|
||||
_VENV_ROLLBACK_ACTIVE=false
|
||||
return 0
|
||||
}
|
||||
substep "restoring previous environment after failed install..." "$C_WARN"
|
||||
rm -rf "$_VENV_ROLLBACK_TARGET"
|
||||
if mv "$_VENV_ROLLBACK_DIR" "$_VENV_ROLLBACK_TARGET"; then
|
||||
substep "restored previous environment"
|
||||
_VENV_ROLLBACK_ACTIVE=false
|
||||
_VENV_ROLLBACK_DIR=""
|
||||
else
|
||||
echo "⚠️ Could not restore previous environment from $_VENV_ROLLBACK_DIR to $_VENV_ROLLBACK_TARGET" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
_commit_studio_venv_replacement() {
|
||||
[ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0
|
||||
if [ -n "$_VENV_ROLLBACK_DIR" ] && [ -d "$_VENV_ROLLBACK_DIR" ]; then
|
||||
rm -rf "$_VENV_ROLLBACK_DIR" || true
|
||||
fi
|
||||
_VENV_ROLLBACK_ACTIVE=false
|
||||
_VENV_ROLLBACK_DIR=""
|
||||
}
|
||||
|
||||
_on_install_exit() {
|
||||
_status=$?
|
||||
if [ "$_status" -ne 0 ]; then
|
||||
_restore_studio_venv_replacement
|
||||
fi
|
||||
exit "$_status"
|
||||
}
|
||||
trap _on_install_exit EXIT
|
||||
|
||||
# ── Helper: download a URL to a file (supports curl and wget) ──
|
||||
download() {
|
||||
|
|
@ -883,9 +937,15 @@ if [ -n "$MISSING" ]; then
|
|||
if command -v apt-get >/dev/null 2>&1; then
|
||||
_smart_apt_install $MISSING
|
||||
else
|
||||
echo " apt-get is not available. Please install with your package manager:"
|
||||
echo " Automatic system package installation is supported on apt-based"
|
||||
echo " Linux distributions (Ubuntu/Debian) only. Please install the"
|
||||
echo " missing dependencies with your package manager, then re-run setup:"
|
||||
echo " $MISSING"
|
||||
echo " Then re-run Unsloth Studio setup."
|
||||
echo ""
|
||||
echo " Examples:"
|
||||
echo " Fedora/RHEL: sudo dnf install cmake git gcc gcc-c++ make libcurl-devel"
|
||||
echo " Arch: sudo pacman -S --needed cmake git base-devel curl"
|
||||
echo " openSUSE: sudo zypper install cmake git gcc gcc-c++ make libcurl-devel"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
|
@ -957,12 +1017,19 @@ mkdir -p "$STUDIO_HOME"
|
|||
_MIGRATED=false
|
||||
|
||||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||||
# New layout already exists — nuke for fresh install
|
||||
rm -rf "$VENV_DIR"
|
||||
# New layout already exists — replace only after preserving rollback copy.
|
||||
substep "preserving existing environment for rollback..."
|
||||
_start_studio_venv_replacement "$VENV_DIR"
|
||||
elif [ -x "$STUDIO_HOME/.venv/bin/python" ]; then
|
||||
# Old layout exists — validate before migrating
|
||||
# Old layout exists — validate before migrating.
|
||||
# In no-torch mode, a missing torch package is expected; validate Python only.
|
||||
substep "found legacy Studio environment, validating..."
|
||||
if "$STUDIO_HOME/.venv/bin/python" -c "
|
||||
_legacy_ok=false
|
||||
if [ "$SKIP_TORCH" = true ]; then
|
||||
if "$STUDIO_HOME/.venv/bin/python" -c "import sys; print(sys.executable)" >/dev/null 2>&1; then
|
||||
_legacy_ok=true
|
||||
fi
|
||||
elif "$STUDIO_HOME/.venv/bin/python" -c "
|
||||
import torch
|
||||
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
||||
A = torch.ones((10, 10), device=device)
|
||||
|
|
@ -972,13 +1039,17 @@ D = A + B
|
|||
E = D @ C
|
||||
torch.testing.assert_close(torch.unique(E), torch.tensor((20,), device=E.device, dtype=E.dtype))
|
||||
" >/dev/null 2>&1; then
|
||||
_legacy_ok=true
|
||||
fi
|
||||
if [ "$_legacy_ok" = true ]; then
|
||||
echo "✅ Legacy environment is healthy — migrating..."
|
||||
mv "$STUDIO_HOME/.venv" "$VENV_DIR"
|
||||
echo " Moved ~/.unsloth/studio/.venv → $VENV_DIR"
|
||||
_MIGRATED=true
|
||||
else
|
||||
echo "⚠️ Legacy environment failed validation — creating fresh environment"
|
||||
rm -rf "$STUDIO_HOME/.venv"
|
||||
_invalid_venv="$STUDIO_HOME/.venv.invalid.$(date +%Y%m%d%H%M%S 2>/dev/null || echo time).$$"
|
||||
mv "$STUDIO_HOME/.venv" "$_invalid_venv" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
@ -1679,6 +1750,8 @@ if [ "$_SETUP_EXIT" -ne 0 ]; then
|
|||
exit "$_SETUP_EXIT"
|
||||
fi
|
||||
|
||||
_commit_studio_venv_replacement
|
||||
|
||||
# ── Tauri mode: done, skip shortcuts and auto-launch ──
|
||||
if [ "$TAURI_MODE" = true ]; then
|
||||
tauri_log "DONE" ""
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
hasRefreshToken,
|
||||
mustChangePassword,
|
||||
refreshSession,
|
||||
tauriAutoAuth,
|
||||
} from "@/features/auth";
|
||||
|
||||
async function hasActiveSession(): Promise<boolean> {
|
||||
|
|
@ -39,7 +38,7 @@ function authRedirect(to: "/login" | "/change-password"): never {
|
|||
|
||||
export async function requireAuth(): Promise<void> {
|
||||
if (isTauri) {
|
||||
await tauriAutoAuth();
|
||||
// AppProvider owns backend startup + desktop auth; route guards run before it mounts.
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +58,6 @@ export async function requireAuth(): Promise<void> {
|
|||
|
||||
export async function requireGuest(): Promise<void> {
|
||||
if (isTauri) {
|
||||
await tauriAutoAuth();
|
||||
throw redirect({ to: "/chat" });
|
||||
}
|
||||
if (!(await hasActiveSession())) return;
|
||||
|
|
@ -68,7 +66,6 @@ export async function requireGuest(): Promise<void> {
|
|||
|
||||
export async function requirePasswordChangeFlow(): Promise<void> {
|
||||
if (isTauri) {
|
||||
await tauriAutoAuth();
|
||||
throw redirect({ to: "/chat" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,54 @@ interface DesktopPreflightResult {
|
|||
managed_bin: string | null;
|
||||
}
|
||||
|
||||
const MANAGED_STARTUP_TIMEOUT_MS = 5 * 60_000;
|
||||
const MANAGED_STARTUP_POLL_MS = 500;
|
||||
|
||||
type TauriInvoke = typeof import("@tauri-apps/api/core").invoke;
|
||||
type ManagedStartupResult =
|
||||
| { status: "ready"; port: number }
|
||||
| { status: "aborted" }
|
||||
| { status: "missing-port" }
|
||||
| { status: "unhealthy" };
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForManagedServerReady(
|
||||
invoke: TauriInvoke,
|
||||
getPort: () => number | null,
|
||||
shouldContinue: () => boolean,
|
||||
): Promise<ManagedStartupResult> {
|
||||
const deadline = Date.now() + MANAGED_STARTUP_TIMEOUT_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (!shouldContinue()) {
|
||||
return { status: "aborted" };
|
||||
}
|
||||
|
||||
const port = getPort();
|
||||
if (port === null) {
|
||||
await wait(MANAGED_STARTUP_POLL_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
const healthy = await invoke<boolean>("check_health", { port });
|
||||
if (!shouldContinue()) {
|
||||
return { status: "aborted" };
|
||||
}
|
||||
if (healthy && getPort() === port) {
|
||||
return { status: "ready", port };
|
||||
}
|
||||
|
||||
await wait(MANAGED_STARTUP_POLL_MS);
|
||||
}
|
||||
|
||||
return getPort() === null
|
||||
? { status: "missing-port" }
|
||||
: { status: "unhealthy" };
|
||||
}
|
||||
|
||||
export function useTauriBackend() {
|
||||
const [status, setStatus] = useState<BackendStatus>("checking");
|
||||
const statusRef = useRef<BackendStatus>(status);
|
||||
|
|
@ -181,8 +229,11 @@ export function useTauriBackend() {
|
|||
|
||||
async function startManagedServer() {
|
||||
// Prevent double-start race condition
|
||||
if (startingRef.current) return;
|
||||
if (startingRef.current) {
|
||||
return;
|
||||
}
|
||||
startingRef.current = true;
|
||||
portRef.current = null;
|
||||
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
|
|
@ -192,23 +243,27 @@ export function useTauriBackend() {
|
|||
|
||||
// Wait for the owned backend's server-port event. Do not attach to an
|
||||
// external backend if the managed start does not report a port.
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (portRef.current) {
|
||||
const healthy = await invoke<boolean>("check_health", {
|
||||
port: portRef.current,
|
||||
});
|
||||
if (healthy) {
|
||||
setApiBase(portRef.current);
|
||||
setRunningStatus();
|
||||
startingRef.current = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const startupResult = await waitForManagedServerReady(
|
||||
invoke,
|
||||
() => portRef.current,
|
||||
() => startingRef.current,
|
||||
);
|
||||
|
||||
if (startupResult.status === "ready") {
|
||||
setApiBase(startupResult.port);
|
||||
setRunningStatus();
|
||||
startingRef.current = false;
|
||||
return;
|
||||
}
|
||||
const message = !portRef.current
|
||||
? "Managed server started without reporting a port. Check the logs for details."
|
||||
: "Server started but is not responding. Check the logs for details.";
|
||||
|
||||
if (startupResult.status === "aborted") {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
startupResult.status === "missing-port"
|
||||
? "Managed server started without reporting a port. Check the logs for details."
|
||||
: "Server started but is not responding. Check the logs for details.";
|
||||
setBackendError(message);
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
|
|
@ -283,12 +338,12 @@ export function useTauriBackend() {
|
|||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
try {
|
||||
await invoke("start_install");
|
||||
// Install completed — this is the ONLY path that starts the server
|
||||
// after install. The install-complete event listener does NOT call
|
||||
// Install completed — validate the managed binary's desktop capability
|
||||
// before starting it. The install-complete event listener does NOT call
|
||||
// startServer() to avoid a double-start race condition.
|
||||
setBackendStatus("starting");
|
||||
elevationResumeRef.current = null;
|
||||
await startServer();
|
||||
await checkInstallAndStart();
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
// NEEDS_ELEVATION is not a real error — the Rust side also emits
|
||||
|
|
|
|||
|
|
@ -1460,8 +1460,14 @@ substep "Using $PythonCmd ($(& $PythonCmd --version 2>&1))"
|
|||
$VenvDir = Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio"
|
||||
|
||||
# Stale-venv detection: if the venv exists but its torch flavor no longer
|
||||
# matches the current machine, wipe it so we get a clean install.
|
||||
if (Test-Path $VenvDir -PathType Container) {
|
||||
# matches the current machine, repair according to invocation context.
|
||||
# - install.ps1 sets UNSLOTH_INSTALL_ROLLBACK_MANAGED=1 so setup can delegate
|
||||
# to the installer-level rollback that restores the previous environment.
|
||||
# - direct `unsloth studio update` keeps the pre-existing self-repair behavior.
|
||||
# In no-torch mode, a missing torch package is expected.
|
||||
$NoTorchMode = $env:UNSLOTH_NO_TORCH -match '^(?i:true|1|yes)$'
|
||||
$InstallerManagedSetup = $env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -match '^(?i:true|1|yes)$'
|
||||
if ((Test-Path $VenvDir -PathType Container) -and -not $NoTorchMode) {
|
||||
$VenvPyExe = Join-Path $VenvDir "Scripts\python.exe"
|
||||
$installedTorchTag = $null
|
||||
$shouldRebuild = $false
|
||||
|
|
@ -1508,6 +1514,12 @@ if (Test-Path $VenvDir -PathType Container) {
|
|||
|
||||
if ($shouldRebuild) {
|
||||
$reason = if ($installedTorchTag) { "torch $installedTorchTag != required $expectedTorchTag" } else { "torch could not be imported" }
|
||||
if ($InstallerManagedSetup) {
|
||||
substep "Stale venv detected ($reason)." "Yellow"
|
||||
Write-Host " [ERROR] The existing Studio environment needs repair." -ForegroundColor Red
|
||||
Write-Host " Re-run install.ps1 so it can replace the environment safely with rollback." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
substep "Stale venv detected ($reason) -- rebuilding..." "Yellow"
|
||||
try {
|
||||
Remove-Item $VenvDir -Recurse -Force -ErrorAction Stop
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "unsloth-studio"
|
||||
version = "2026.4.7"
|
||||
version = "2026.4.8"
|
||||
description = "Unsloth Studio Desktop App"
|
||||
authors = ["Unsloth AI"]
|
||||
edition = "2021"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,17 @@ use crate::install;
|
|||
use crate::process::{self, BackendState, ShutdownFlag};
|
||||
use crate::update;
|
||||
use log::{error, info, warn};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
const BACKEND_STARTUP_GRACE_PERIOD: Duration = Duration::from_secs(5 * 60);
|
||||
const HEALTH_WATCHDOG_INTERVAL: Duration = Duration::from_secs(15);
|
||||
const HEALTH_WATCHDOG_MAX_FAILURES: u32 = 3;
|
||||
|
||||
fn should_count_watchdog_failure(has_seen_healthy: bool, elapsed_since_start: Duration) -> bool {
|
||||
has_seen_healthy || elapsed_since_start >= BACKEND_STARTUP_GRACE_PERIOD
|
||||
}
|
||||
|
||||
async fn managed_install_ready_after_repair() -> bool {
|
||||
crate::preflight::managed_install_ready().await
|
||||
}
|
||||
|
|
@ -86,7 +95,7 @@ pub async fn start_server(
|
|||
) -> Result<(), String> {
|
||||
info!("start_server command called with port {}", port);
|
||||
|
||||
process::start_backend(&app, &state, port, &shutdown)?;
|
||||
let generation = process::start_backend(&app, &state, port, &shutdown)?;
|
||||
|
||||
// Spawn health watchdog for the owned backend — detects
|
||||
// deadlocks and hangs that stdout-based crash detection misses.
|
||||
|
|
@ -94,7 +103,7 @@ pub async fn start_server(
|
|||
let watchdog_shutdown = shutdown.inner().clone();
|
||||
let watchdog_app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
health_watchdog(watchdog_app, watchdog_state, watchdog_shutdown).await;
|
||||
health_watchdog(watchdog_app, watchdog_state, watchdog_shutdown, generation).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
|
@ -109,13 +118,13 @@ pub async fn start_managed_server(
|
|||
port: u16,
|
||||
) -> Result<(), String> {
|
||||
info!("start_managed_server command called with port {}", port);
|
||||
process::start_backend(&app, &state, port, &shutdown)?;
|
||||
let generation = process::start_backend(&app, &state, port, &shutdown)?;
|
||||
|
||||
let watchdog_state = state.inner().clone();
|
||||
let watchdog_shutdown = shutdown.inner().clone();
|
||||
let watchdog_app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
health_watchdog(watchdog_app, watchdog_state, watchdog_shutdown).await;
|
||||
health_watchdog(watchdog_app, watchdog_state, watchdog_shutdown, generation).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
|
@ -403,6 +412,8 @@ pub async fn start_managed_repair(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn repair_elevation_is_not_a_terminal_repair_failure() {
|
||||
assert!(!super::should_emit_repair_failed("NEEDS_ELEVATION"));
|
||||
|
|
@ -410,61 +421,119 @@ mod tests {
|
|||
"Installer exited with code 1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_ignores_startup_failures_within_grace_period() {
|
||||
assert!(!super::should_count_watchdog_failure(
|
||||
false,
|
||||
super::BACKEND_STARTUP_GRACE_PERIOD - Duration::from_secs(1)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_counts_failures_after_backend_was_healthy() {
|
||||
assert!(super::should_count_watchdog_failure(
|
||||
true,
|
||||
Duration::from_secs(1)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_counts_startup_failures_after_grace_period() {
|
||||
assert!(super::should_count_watchdog_failure(
|
||||
false,
|
||||
super::BACKEND_STARTUP_GRACE_PERIOD
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic health check that detects deadlocked or hung backends.
|
||||
/// Starts 30s after the backend is launched (to allow initial startup),
|
||||
/// then pings /api/health every 15s. After 3 consecutive failures (45s)
|
||||
/// with the process still alive, emits `server-crashed` so the frontend
|
||||
/// can offer a restart.
|
||||
async fn health_watchdog(app: AppHandle, state: BackendState, shutdown: ShutdownFlag) {
|
||||
/// During startup, failures are ignored for a generous grace period so a slow
|
||||
/// but legitimate backend boot is not killed. After the backend has answered at
|
||||
/// least once, or after the startup grace expires, 3 consecutive failed checks
|
||||
/// emit `server-crashed` so the frontend can offer a restart.
|
||||
async fn health_watchdog(
|
||||
app: AppHandle,
|
||||
state: BackendState,
|
||||
shutdown: ShutdownFlag,
|
||||
generation: u64,
|
||||
) {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
// Give the backend time to start up
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
|
||||
let started_at = Instant::now();
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
let mut has_seen_healthy = false;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
|
||||
tokio::time::sleep(HEALTH_WATCHDOG_INTERVAL).await;
|
||||
|
||||
if shutdown.load(Ordering::SeqCst) {
|
||||
info!("Health watchdog: shutdown flag set, exiting");
|
||||
break;
|
||||
}
|
||||
|
||||
let (port, has_child) = {
|
||||
let (port, has_child, current_generation) = {
|
||||
let proc = match state.lock() {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
(proc.port, proc.child.is_some())
|
||||
(proc.port, proc.child.is_some(), proc.generation)
|
||||
};
|
||||
|
||||
if current_generation != generation {
|
||||
info!("Health watchdog: backend generation changed, exiting");
|
||||
break;
|
||||
}
|
||||
|
||||
// Stop watching if the backend is gone
|
||||
if !has_child {
|
||||
info!("Health watchdog: backend stopped, exiting");
|
||||
break;
|
||||
}
|
||||
|
||||
let should_count_failure =
|
||||
should_count_watchdog_failure(has_seen_healthy, started_at.elapsed());
|
||||
|
||||
let Some(port) = port else {
|
||||
continue; // Port not yet known
|
||||
if should_count_failure {
|
||||
consecutive_failures += 1;
|
||||
warn!(
|
||||
"Health watchdog: backend has not reported a port ({}/{})",
|
||||
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES
|
||||
);
|
||||
}
|
||||
|
||||
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
|
||||
error!(
|
||||
"Health watchdog: backend never reported a port, killing and declaring dead"
|
||||
);
|
||||
let _ = process::stop_backend(&state, &shutdown);
|
||||
let _ = app.emit("server-crashed", ());
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
};
|
||||
|
||||
match check_health_inner(port).await {
|
||||
Ok(true) => {
|
||||
has_seen_healthy = true;
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
_ if !should_count_failure => {
|
||||
info!(
|
||||
"Health watchdog: startup health check failed on port {} before grace period elapsed",
|
||||
port
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
consecutive_failures += 1;
|
||||
warn!(
|
||||
"Health watchdog: failure {}/3 on port {}",
|
||||
consecutive_failures, port
|
||||
"Health watchdog: failure {}/{} on port {}",
|
||||
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES, port
|
||||
);
|
||||
if consecutive_failures >= 3 {
|
||||
error!(
|
||||
"Health watchdog: backend unresponsive for 45s, killing and declaring dead"
|
||||
);
|
||||
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
|
||||
error!("Health watchdog: backend unresponsive, killing and declaring dead");
|
||||
// Kill the zombie process so retry can start fresh
|
||||
let _ = process::stop_backend(&state, &shutdown);
|
||||
let _ = app.emit("server-crashed", ());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::preflight::{DesktopPreflightDisposition, DesktopPreflightResult};
|
||||
use crate::process::BackendState;
|
||||
use log::info;
|
||||
use log::{info, warn};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -39,13 +39,16 @@ struct BackendPort {
|
|||
#[derive(Debug)]
|
||||
enum AuthError {
|
||||
Connectivity(String),
|
||||
StaleResponder(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl AuthError {
|
||||
fn message(self) -> String {
|
||||
match self {
|
||||
Self::Connectivity(message) | Self::Failed(message) => message,
|
||||
Self::Connectivity(message) | Self::StaleResponder(message) | Self::Failed(message) => {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -73,11 +76,14 @@ fn read_secret_if_exists(path: &Path) -> Result<Option<String>, String> {
|
|||
match std::fs::read_to_string(path) {
|
||||
Ok(s) => Ok(Some(s.trim().to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(format!(
|
||||
"Failed to read auth secret at {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
)),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Desktop auth: ignoring unreadable auth secret at {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +144,10 @@ fn classify_auth_send_error(error: reqwest::Error) -> AuthError {
|
|||
fn should_retry_with_discovered_port(source: PortSource, error: &AuthError) -> bool {
|
||||
matches!(
|
||||
(source, error),
|
||||
(PortSource::Cached, AuthError::Connectivity(_))
|
||||
(
|
||||
PortSource::Cached,
|
||||
AuthError::Connectivity(_) | AuthError::StaleResponder(_)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +166,7 @@ async fn exchange_desktop_secret(
|
|||
.map_err(classify_auth_send_error)?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(AuthError::Failed(
|
||||
return Err(AuthError::StaleResponder(
|
||||
"Running Studio backend is too old for this desktop app. Update that backend and restart."
|
||||
.to_string(),
|
||||
));
|
||||
|
|
@ -166,7 +175,7 @@ async fn exchange_desktop_secret(
|
|||
return Ok(None);
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(AuthError::Failed("Desktop auth failed".to_string()));
|
||||
return Err(AuthError::StaleResponder("Desktop auth failed".to_string()));
|
||||
}
|
||||
|
||||
response
|
||||
|
|
@ -213,6 +222,33 @@ async fn provision_desktop_auth() -> Result<(), String> {
|
|||
))
|
||||
}
|
||||
|
||||
async fn retry_on_discovered_port(
|
||||
client: &Client,
|
||||
state: &tauri::State<'_, BackendState>,
|
||||
previous: BackendPort,
|
||||
secret: &str,
|
||||
) -> Result<Option<(Option<DesktopAuthResponse>, BackendPort)>, String> {
|
||||
if previous.source != PortSource::Cached {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(port) = discover_compatible_backend_port().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
if port == previous.port {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
update_backend_port(state, port)?;
|
||||
let backend = BackendPort {
|
||||
port,
|
||||
source: PortSource::Discovered,
|
||||
};
|
||||
exchange_desktop_secret(client, port, secret)
|
||||
.await
|
||||
.map(|tokens| Some((tokens, backend)))
|
||||
.map_err(AuthError::message)
|
||||
}
|
||||
|
||||
async fn authenticate_with_stale_port_retry(
|
||||
client: &Client,
|
||||
state: &tauri::State<'_, BackendState>,
|
||||
|
|
@ -220,20 +256,18 @@ async fn authenticate_with_stale_port_retry(
|
|||
secret: &str,
|
||||
) -> Result<(Option<DesktopAuthResponse>, BackendPort), String> {
|
||||
match exchange_desktop_secret(client, backend.port, secret).await {
|
||||
Ok(tokens) => Ok((tokens, backend)),
|
||||
Ok(Some(tokens)) => Ok((Some(tokens), backend)),
|
||||
Ok(None) => {
|
||||
if let Some(retried) = retry_on_discovered_port(client, state, backend, secret).await? {
|
||||
return Ok(retried);
|
||||
}
|
||||
Ok((None, backend))
|
||||
}
|
||||
Err(error) if should_retry_with_discovered_port(backend.source, &error) => {
|
||||
let Some(port) = discover_compatible_backend_port().await else {
|
||||
return Err(error.message());
|
||||
};
|
||||
update_backend_port(state, port)?;
|
||||
let backend = BackendPort {
|
||||
port,
|
||||
source: PortSource::Discovered,
|
||||
};
|
||||
exchange_desktop_secret(client, port, secret)
|
||||
.await
|
||||
.map(|tokens| (tokens, backend))
|
||||
.map_err(AuthError::message)
|
||||
if let Some(retried) = retry_on_discovered_port(client, state, backend, secret).await? {
|
||||
return Ok(retried);
|
||||
}
|
||||
Err(error.message())
|
||||
}
|
||||
Err(error) => Err(error.message()),
|
||||
}
|
||||
|
|
@ -317,11 +351,15 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn retry_discovery_only_for_cached_connectivity_errors() {
|
||||
fn retry_discovery_only_for_cached_recoverable_errors() {
|
||||
assert!(should_retry_with_discovered_port(
|
||||
PortSource::Cached,
|
||||
&AuthError::Connectivity("connection refused".to_string())
|
||||
));
|
||||
assert!(should_retry_with_discovered_port(
|
||||
PortSource::Cached,
|
||||
&AuthError::StaleResponder("old responder".to_string())
|
||||
));
|
||||
assert!(!should_retry_with_discovered_port(
|
||||
PortSource::Discovered,
|
||||
&AuthError::Connectivity("connection refused".to_string())
|
||||
|
|
@ -332,6 +370,64 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_secret_returns_none_for_missing_file() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"unsloth-missing-desktop-secret-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
assert_eq!(read_secret_if_exists(&path).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_secret_trims_existing_file() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("unsloth-desktop-secret-{}", std::process::id()));
|
||||
std::fs::write(&path, " desktop-secret\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
read_secret_if_exists(&path).unwrap(),
|
||||
Some("desktop-secret".to_string())
|
||||
);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_secret_treats_invalid_utf8_as_missing_for_repair() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"unsloth-invalid-desktop-secret-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, [0xff, 0xfe]).unwrap();
|
||||
|
||||
assert_eq!(read_secret_if_exists(&path).unwrap(), None);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_secret_treats_permission_denied_as_missing_for_repair() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"unsloth-unreadable-desktop-secret-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, "desktop-stale").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
|
||||
let result = read_secret_if_exists(&path);
|
||||
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
if matches!(result, Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
assert_eq!(result.unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_ready_port_requires_attached_ready_with_port() {
|
||||
let compatible = DesktopPreflightResult {
|
||||
|
|
|
|||
|
|
@ -481,7 +481,7 @@ pub fn stop_install(state: &InstallState) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Install system packages with elevated permissions (Linux only).
|
||||
/// Install apt system packages with elevated permissions (Linux only).
|
||||
/// Uses `elevated-command` crate for native auth dialog.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn install_system_packages(packages: &[String]) -> Result<(), String> {
|
||||
|
|
@ -497,42 +497,44 @@ pub fn install_system_packages(packages: &[String]) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
// AppImage bundles run on non-Debian distros too. Pick the first package
|
||||
// manager we find. Names in `packages` are Debian-style; callers that want
|
||||
// cross-distro support should translate before invoking.
|
||||
let (program, base_args): (&str, &[&str]) = if Path::new("/usr/bin/apt-get").exists() {
|
||||
("apt-get", &["install", "-y"])
|
||||
} else if Path::new("/usr/bin/dnf").exists() {
|
||||
("dnf", &["install", "-y"])
|
||||
} else if Path::new("/usr/bin/zypper").exists() {
|
||||
("zypper", &["install", "-y"])
|
||||
} else if Path::new("/usr/bin/pacman").exists() {
|
||||
("pacman", &["-S", "--noconfirm"])
|
||||
} else {
|
||||
// install.sh reports Debian package names. Do not pass them to dnf,
|
||||
// zypper, or pacman where names differ; show an explicit support boundary
|
||||
// instead of offering an elevation flow that is likely to fail.
|
||||
if !Path::new("/usr/bin/apt-get").exists() {
|
||||
return Err(
|
||||
"No supported system package manager found (apt-get, dnf, zypper, pacman)".to_string(),
|
||||
"Automatic system package installation is supported on apt-based Linux distributions (Ubuntu/Debian) only. Install the missing dependencies with your package manager and retry."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
info!(
|
||||
"[install] Elevated install of packages via {}: {}",
|
||||
program,
|
||||
"[install] Elevated install of apt packages: {}",
|
||||
packages.join(", ")
|
||||
);
|
||||
|
||||
let mut cmd = StdCommand::new(program);
|
||||
cmd.args(base_args).args(packages);
|
||||
let mut update_cmd = StdCommand::new("apt-get");
|
||||
update_cmd.args(["update", "-y"]);
|
||||
let elevated_update = elevated_command::Command::new(update_cmd)
|
||||
.output()
|
||||
.map_err(|e| format!("Elevated apt update failed: {}", e))?;
|
||||
if !elevated_update.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&elevated_update.stderr);
|
||||
return Err(format!("Package index update failed: {}", stderr));
|
||||
}
|
||||
|
||||
let elevated = elevated_command::Command::new(cmd)
|
||||
let mut install_cmd = StdCommand::new("apt-get");
|
||||
install_cmd.args(["install", "-y"]).args(packages);
|
||||
|
||||
let elevated_install = elevated_command::Command::new(install_cmd)
|
||||
.output()
|
||||
.map_err(|e| format!("Elevated install failed: {}", e))?;
|
||||
|
||||
if !elevated.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&elevated.stderr);
|
||||
if !elevated_install.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&elevated_install.stderr);
|
||||
return Err(format!("Package installation failed: {}", stderr));
|
||||
}
|
||||
|
||||
info!("[install] Elevated package install succeeded");
|
||||
info!("[install] Elevated apt package install succeeded");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,21 @@ fn setup_custom_titlebar(app: &tauri::App) -> Result<(), Box<dyn std::error::Err
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_child_processes(app: &tauri::AppHandle) {
|
||||
if let Some(install_state) = app.try_state::<install::InstallState>() {
|
||||
let _ = install::stop_install(&install_state);
|
||||
}
|
||||
if let Some(update_state) = app.try_state::<update::UpdateState>() {
|
||||
let _ = update::stop_update(&update_state);
|
||||
}
|
||||
if let Some(backend_state) = app.try_state::<process::BackendState>() {
|
||||
let shutdown = app
|
||||
.try_state::<process::ShutdownFlag>()
|
||||
.expect("ShutdownFlag must be managed");
|
||||
let _ = process::stop_backend(&backend_state, &shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let open = MenuItemBuilder::with_id("open", "Open Studio").build(app)?;
|
||||
let toggle = MenuItemBuilder::with_id("toggle", "Start/Stop Server").build(app)?;
|
||||
|
|
@ -90,17 +105,15 @@ fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||
let _ = app.emit("tray-toggle-server", ());
|
||||
}
|
||||
"quit" => {
|
||||
let install_state = app.state::<crate::install::InstallState>();
|
||||
let _ = crate::install::stop_install(&install_state);
|
||||
let update_state = app.state::<crate::update::UpdateState>();
|
||||
let _ = crate::update::stop_update(&update_state);
|
||||
// Detach the 5s graceful-wait so the tray click does not
|
||||
// block the Tauri main loop. Exit runs the RunEvent::Exit
|
||||
// safety net which also calls stop_backend synchronously.
|
||||
let shutdown = app.state::<crate::process::ShutdownFlag>().inner().clone();
|
||||
let backend_state = app.state::<crate::process::BackendState>().inner().clone();
|
||||
crate::process::stop_backend_detached(backend_state, shutdown);
|
||||
app.exit(0);
|
||||
// Run cleanup off the menu callback, but only exit after the
|
||||
// backend tree has been reaped. Exiting first can terminate this
|
||||
// process while a detached cleanup thread is still waiting,
|
||||
// leaving the backend orphaned.
|
||||
let app_handle = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
cleanup_child_processes(&app_handle);
|
||||
app_handle.exit(0);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
|
|
@ -183,18 +196,7 @@ fn main() {
|
|||
.run(|app, event| {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
// Cleanup on ALL exit paths — safety net for non-tray exits
|
||||
if let Some(install_state) = app.try_state::<install::InstallState>() {
|
||||
let _ = install::stop_install(&install_state);
|
||||
}
|
||||
if let Some(update_state) = app.try_state::<update::UpdateState>() {
|
||||
let _ = update::stop_update(&update_state);
|
||||
}
|
||||
if let Some(backend_state) = app.try_state::<process::BackendState>() {
|
||||
let shutdown = app
|
||||
.try_state::<process::ShutdownFlag>()
|
||||
.expect("ShutdownFlag must be managed");
|
||||
let _ = process::stop_backend(&backend_state, &shutdown);
|
||||
}
|
||||
cleanup_child_processes(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub struct BackendProcess {
|
|||
pub port: Option<u16>,
|
||||
pub logs: VecDeque<String>,
|
||||
pub intentional_stop: bool,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
impl Default for BackendProcess {
|
||||
|
|
@ -24,6 +25,7 @@ impl Default for BackendProcess {
|
|||
port: None,
|
||||
logs: VecDeque::with_capacity(MAX_LOG_LINES),
|
||||
intentional_stop: false,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -245,7 +247,7 @@ pub fn start_backend(
|
|||
state: &BackendState,
|
||||
port: u16,
|
||||
shutdown: &ShutdownFlag,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<u64, String> {
|
||||
let bin = resolve_backend_binary()?;
|
||||
|
||||
shutdown.store(false, Ordering::SeqCst);
|
||||
|
|
@ -308,17 +310,19 @@ pub fn start_backend(
|
|||
let stderr = child.stderr().take();
|
||||
|
||||
// Store child in state
|
||||
{
|
||||
let generation = {
|
||||
let mut proc = state.lock().map_err(|e| e.to_string())?;
|
||||
proc.child = Some(child);
|
||||
}
|
||||
proc.generation = proc.generation.wrapping_add(1);
|
||||
proc.generation
|
||||
};
|
||||
|
||||
// Spawn stdout reader thread
|
||||
if let Some(stdout) = stdout {
|
||||
let app_handle = app.clone();
|
||||
let state_clone = Arc::clone(state);
|
||||
std::thread::spawn(move || {
|
||||
read_output_stream(stdout, &app_handle, &state_clone, false);
|
||||
read_output_stream(stdout, &app_handle, &state_clone, false, generation);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -327,11 +331,11 @@ pub fn start_backend(
|
|||
let app_handle = app.clone();
|
||||
let state_clone = Arc::clone(state);
|
||||
std::thread::spawn(move || {
|
||||
read_output_stream(stderr, &app_handle, &state_clone, true);
|
||||
read_output_stream(stderr, &app_handle, &state_clone, true, generation);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(generation)
|
||||
}
|
||||
|
||||
/// Read lines from a child process stream (stdout or stderr).
|
||||
|
|
@ -342,6 +346,7 @@ fn read_output_stream<R: std::io::Read>(
|
|||
app: &AppHandle,
|
||||
state: &BackendState,
|
||||
is_stderr: bool,
|
||||
generation: u64,
|
||||
) {
|
||||
let mut reader = std::io::BufReader::new(stream);
|
||||
let port_re = Regex::new(r"TAURI_PORT=(\d+)").unwrap();
|
||||
|
|
@ -360,26 +365,42 @@ fn read_output_stream<R: std::io::Read>(
|
|||
};
|
||||
|
||||
// Check for TAURI_PORT on stdout only
|
||||
if !is_stderr {
|
||||
if let Some(caps) = port_re.captures(&text) {
|
||||
if let Some(port_str) = caps.get(1) {
|
||||
if let Ok(port) = port_str.as_str().parse::<u16>() {
|
||||
info!("Detected backend port: {}", port);
|
||||
if let Ok(mut proc) = state.lock() {
|
||||
proc.port = Some(port);
|
||||
}
|
||||
let _ = app.emit("server-port", port);
|
||||
}
|
||||
let detected_port = if !is_stderr {
|
||||
port_re
|
||||
.captures(&text)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.and_then(|port_str| port_str.as_str().parse::<u16>().ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Buffer the log line only for the current backend generation.
|
||||
// Old reader threads can briefly outlive a stop/start cycle;
|
||||
// they must not overwrite the new backend's port or logs.
|
||||
let current_generation = if let Ok(mut proc) = state.lock() {
|
||||
if proc.generation != generation {
|
||||
false
|
||||
} else {
|
||||
if let Some(port) = detected_port {
|
||||
proc.port = Some(port);
|
||||
}
|
||||
if proc.logs.len() >= MAX_LOG_LINES {
|
||||
proc.logs.pop_front();
|
||||
}
|
||||
proc.logs.push_back(log_line.clone());
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !current_generation {
|
||||
break;
|
||||
}
|
||||
|
||||
// Buffer the log line
|
||||
if let Ok(mut proc) = state.lock() {
|
||||
if proc.logs.len() >= MAX_LOG_LINES {
|
||||
proc.logs.pop_front();
|
||||
}
|
||||
proc.logs.push_back(log_line.clone());
|
||||
if let Some(port) = detected_port {
|
||||
info!("Detected backend port: {}", port);
|
||||
let _ = app.emit("server-port", port);
|
||||
}
|
||||
|
||||
info!("[backend] {}", log_line);
|
||||
|
|
@ -401,6 +422,9 @@ fn read_output_stream<R: std::io::Read>(
|
|||
// Stream closed. Only the stdout reader checks for crashes.
|
||||
if !is_stderr {
|
||||
if let Ok(mut proc) = state.lock() {
|
||||
if proc.generation != generation {
|
||||
return;
|
||||
}
|
||||
let intentional = proc.intentional_stop;
|
||||
let exited = if let Some(ref mut child) = proc.child {
|
||||
match child.try_wait() {
|
||||
|
|
@ -526,12 +550,3 @@ pub fn stop_backend(state: &BackendState, shutdown: &ShutdownFlag) -> Result<(),
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `stop_backend` on a background thread and return immediately.
|
||||
/// Used by the tray "quit" path so the 5s graceful-wait does not block
|
||||
/// the Tauri main event loop before `app.exit(0)` fires.
|
||||
pub fn stop_backend_detached(state: BackendState, shutdown: ShutdownFlag) {
|
||||
std::thread::spawn(move || {
|
||||
let _ = stop_backend(&state, &shutdown);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
; Unsloth Studio NSIS installer hooks
|
||||
|
||||
!macro NSIS_HOOK_POSTUNINSTALL
|
||||
MessageBox MB_YESNO|MB_ICONQUESTION "Remove all Unsloth data ($PROFILE\.unsloth)?$\n$\nThis deletes installed models, training outputs, and configuration." IDNO skip_cleanup
|
||||
RMDir /r "$PROFILE\.unsloth"
|
||||
DetailPrint "Removed $PROFILE\.unsloth"
|
||||
skip_cleanup:
|
||||
; Desktop uninstall must not remove $PROFILE\.unsloth. The CLI/web
|
||||
; installers also use that tree for environments, models, outputs, and
|
||||
; configuration, and there has been no prior public desktop release whose
|
||||
; private state needs cleanup here.
|
||||
DetailPrint "Preserved shared Unsloth data at $PROFILE\.unsloth"
|
||||
!macroend
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue