diff --git a/install.ps1 b/install.ps1 index 4406c3ab29..2b7fd0533b 100644 --- a/install.ps1 +++ b/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) { diff --git a/install.sh b/install.sh index 6c28b6eda4..95ab382f2d 100755 --- a/install.sh +++ b/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" "" diff --git a/studio/frontend/src/app/auth-guards.ts b/studio/frontend/src/app/auth-guards.ts index 509b8f61af..52230f0b6f 100644 --- a/studio/frontend/src/app/auth-guards.ts +++ b/studio/frontend/src/app/auth-guards.ts @@ -9,7 +9,6 @@ import { hasRefreshToken, mustChangePassword, refreshSession, - tauriAutoAuth, } from "@/features/auth"; async function hasActiveSession(): Promise { @@ -39,7 +38,7 @@ function authRedirect(to: "/login" | "/change-password"): never { export async function requireAuth(): Promise { 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 { export async function requireGuest(): Promise { if (isTauri) { - await tauriAutoAuth(); throw redirect({ to: "/chat" }); } if (!(await hasActiveSession())) return; @@ -68,7 +66,6 @@ export async function requireGuest(): Promise { export async function requirePasswordChangeFlow(): Promise { if (isTauri) { - await tauriAutoAuth(); throw redirect({ to: "/chat" }); } diff --git a/studio/frontend/src/hooks/use-tauri-backend.ts b/studio/frontend/src/hooks/use-tauri-backend.ts index ed697fe31e..445e37c1f6 100644 --- a/studio/frontend/src/hooks/use-tauri-backend.ts +++ b/studio/frontend/src/hooks/use-tauri-backend.ts @@ -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 { + 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("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("checking"); const statusRef = useRef(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("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 diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 24ef3ef1eb..53557ad39c 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -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 diff --git a/studio/src-tauri/Cargo.toml b/studio/src-tauri/Cargo.toml index ccb07cb9e2..e8cd44d039 100644 --- a/studio/src-tauri/Cargo.toml +++ b/studio/src-tauri/Cargo.toml @@ -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" diff --git a/studio/src-tauri/src/commands.rs b/studio/src-tauri/src/commands.rs index f2acb92e09..1bd92d9b64 100644 --- a/studio/src-tauri/src/commands.rs +++ b/studio/src-tauri/src/commands.rs @@ -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", ()); diff --git a/studio/src-tauri/src/desktop_auth.rs b/studio/src-tauri/src/desktop_auth.rs index df09df7a14..dca28a1321 100644 --- a/studio/src-tauri/src/desktop_auth.rs +++ b/studio/src-tauri/src/desktop_auth.rs @@ -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, 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, 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, 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 { diff --git a/studio/src-tauri/src/install.rs b/studio/src-tauri/src/install.rs index 5760989353..b8198899d5 100644 --- a/studio/src-tauri/src/install.rs +++ b/studio/src-tauri/src/install.rs @@ -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(()) } diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs index 4dc6f764d2..2d12c468a2 100644 --- a/studio/src-tauri/src/main.rs +++ b/studio/src-tauri/src/main.rs @@ -67,6 +67,21 @@ fn setup_custom_titlebar(app: &tauri::App) -> Result<(), Box() { + let _ = install::stop_install(&install_state); + } + if let Some(update_state) = app.try_state::() { + let _ = update::stop_update(&update_state); + } + if let Some(backend_state) = app.try_state::() { + let shutdown = app + .try_state::() + .expect("ShutdownFlag must be managed"); + let _ = process::stop_backend(&backend_state, &shutdown); + } +} + fn setup_tray(app: &tauri::App) -> Result<(), Box> { 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> { let _ = app.emit("tray-toggle-server", ()); } "quit" => { - let install_state = app.state::(); - let _ = crate::install::stop_install(&install_state); - let update_state = app.state::(); - 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::().inner().clone(); - let backend_state = app.state::().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::() { - let _ = install::stop_install(&install_state); - } - if let Some(update_state) = app.try_state::() { - let _ = update::stop_update(&update_state); - } - if let Some(backend_state) = app.try_state::() { - let shutdown = app - .try_state::() - .expect("ShutdownFlag must be managed"); - let _ = process::stop_backend(&backend_state, &shutdown); - } + cleanup_child_processes(app); } }); } diff --git a/studio/src-tauri/src/process.rs b/studio/src-tauri/src/process.rs index beba8c1208..cc611ce17d 100644 --- a/studio/src-tauri/src/process.rs +++ b/studio/src-tauri/src/process.rs @@ -15,6 +15,7 @@ pub struct BackendProcess { pub port: Option, pub logs: VecDeque, 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 { 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( 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( }; // 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::() { - 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::().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( // 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); - }); -} diff --git a/studio/src-tauri/windows/hooks.nsh b/studio/src-tauri/windows/hooks.nsh index 64655e8f6e..8c68a3e54f 100644 --- a/studio/src-tauri/windows/hooks.nsh +++ b/studio/src-tauri/windows/hooks.nsh @@ -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