fix desktop auth runtime installer regressions

This commit is contained in:
shine1i 2026-04-28 10:44:22 +02:00
commit f8c7ca67b1
12 changed files with 601 additions and 205 deletions

View file

@ -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) {

View file

@ -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" ""

View file

@ -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" });
}

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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", ());

View file

@ -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 {

View file

@ -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(())
}

View file

@ -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);
}
});
}

View file

@ -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);
});
}

View file

@ -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