diff --git a/.github/scripts/interrupt-install.ps1 b/.github/scripts/interrupt-install.ps1 index 6860ad0107..92f6019c31 100644 --- a/.github/scripts/interrupt-install.ps1 +++ b/.github/scripts/interrupt-install.ps1 @@ -12,7 +12,7 @@ # # Usage: # pwsh -File .github/scripts/interrupt-install.ps1 -Marker 'studio deps' ` -# -LogPath logs/install.log -InstallArgs '-SkipTorch' +# -LogPath logs/install.log -InstallArgs '--tauri --no-torch --local' [CmdletBinding()] param( [string]$Marker = '', @@ -88,8 +88,12 @@ if ($killed) { # never under the studio home anyway (install.ps1 takes it from winget or # astral.sh). Normalise the separators, and take uv by name since the runner is # ephemeral and runs no other uv. - $homeNorm = if ([string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $null } - else { ($env:UNSLOTH_STUDIO_HOME -replace '/', '\').TrimEnd('\') } + # Under --tauri there is no UNSLOTH_STUDIO_HOME, so fall back to the root + # install.ps1 uses then, or the sweep would only ever see uv. + $studioRoot = if ([string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { Join-Path $HOME '.unsloth\studio' } + else { $env:UNSLOTH_STUDIO_HOME } + $homeNorm = if ([string]::IsNullOrWhiteSpace($studioRoot)) { $null } + else { ($studioRoot -replace '/', '\').TrimEnd('\') } foreach ($p in @(Get-Process -Name 'uv', 'python', 'pythonw' -ErrorAction SilentlyContinue)) { $path = $null try { $path = $p.Path } catch { } diff --git a/.github/scripts/interrupted_install_probe.py b/.github/scripts/interrupted_install_probe.py index e7a27c98b9..6c2015facd 100644 --- a/.github/scripts/interrupted_install_probe.py +++ b/.github/scripts/interrupted_install_probe.py @@ -22,7 +22,8 @@ ManagedReady with can_auto_repair=false and the backend dies on `import structlo Verdicts: HEALTHY the backend boots -- the interruption did no lasting harm - REPAIRABLE the backend is broken AND something reports it, so the app can repair + REPAIRABLE the backend is broken AND a probe the DESKTOP consumes reports it, + so the app can offer a repair FALSE_READY the backend is broken and every probe says ready -> THE BUG Exit: 0 for HEALTHY/REPAIRABLE/NO_CLI, 1 for FALSE_READY, 2 for a usage error. @@ -107,6 +108,12 @@ def main(argv: list[str]) -> int: say("capabilities.studio_install_ok", install_ok) # ── the deeper probes the fix PRs add ──────────────────────────────────── + # RECORDED, but NOT repair evidence: preflight/managed.rs runs only `-h` and + # `studio desktop-capabilities --json` (managed.rs:357) and reads + # studio_install_ok from that payload (managed.rs:445). It never invokes these + # two commands, so counting them would let a leg pass while the real app still + # reports ManagedReady over a torn install -- the exact false negative this + # workflow exists to catch. for label, args in ( ("verify_install", ["studio", "verify-install"]), ("desktop_runtime_check", ["studio", "desktop-runtime-check"]), @@ -142,16 +149,25 @@ def main(argv: list[str]) -> int: # then be false for a perfectly good install. blog_path = out / "backend.log" blog_fh = blog_path.open("w", encoding = "utf-8", errors = "replace") - proc = subprocess.Popen( - [binp, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)], - stdout = blog_fh, - stderr = subprocess.STDOUT, - text = True, - **popen_kw, - ) + # An interrupted install can leave the console script in place while its venv + # interpreter is gone: the earlier probes then report failure through run()'s + # OSError catch, but an unguarded spawn here raises instead, so no verdict.json + # is written and both workflows die on the json.load rather than reporting. An + # unlaunchable CLI is a broken backend that `-h` already flags -> REPAIRABLE. + proc = None + try: + proc = subprocess.Popen( + [binp, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)], + stdout = blog_fh, + stderr = subprocess.STDOUT, + text = True, + **popen_kw, + ) + except OSError as e: + say("backend_spawn_error", f"{type(e).__name__}: {e}") backend_ok = False deadline = time.time() + a.boot_timeout - while time.time() < deadline: + while proc is not None and time.time() < deadline: if proc.poll() is not None: break for path in ("/api/health", "/healthz"): @@ -167,6 +183,8 @@ def main(argv: list[str]) -> int: time.sleep(1) def reap() -> None: + if proc is None: + return if os.name == "posix": import signal for sig in (signal.SIGTERM, signal.SIGKILL): @@ -180,11 +198,20 @@ def main(argv: list[str]) -> int: except subprocess.TimeoutExpired: continue else: - proc.terminate() + # On win32 the CLI re-spawns the server as a CHILD and waits on it + # (unsloth_cli/commands/studio.py:1543); CREATE_NEW_PROCESS_GROUP does not + # make terminate() reach descendants, so killing the wrapper alone leaves a + # server holding the venv open and the repair step reinstalls into files + # Windows has locked. taskkill /T takes the tree. + run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], timeout = 30) try: proc.wait(timeout = 10) except subprocess.TimeoutExpired: - proc.kill() + proc.terminate() + try: + proc.wait(timeout = 10) + except subprocess.TimeoutExpired: + proc.kill() reap() blog_fh.close() @@ -202,9 +229,7 @@ def main(argv: list[str]) -> int: if backend_ok: verdict = "HEALTHY" elif ( - facts.get("verify_install") == "failed" - or facts.get("desktop_runtime_check") == "failed" - or facts.get("capabilities.studio_install_ok") is False + facts.get("capabilities.studio_install_ok") is False or not facts.get("cli_h_ok") or not facts.get("capabilities_ok") ): diff --git a/.github/workflows/interrupted-install-ci.yml b/.github/workflows/interrupted-install-ci.yml index c59b31fdd2..7d8d3438b4 100644 --- a/.github/workflows/interrupted-install-ci.yml +++ b/.github/workflows/interrupted-install-ci.yml @@ -201,11 +201,14 @@ jobs: # ── Windows: no process groups, so the kill path differs ────────────────── interrupt-windows: name: windows kill@${{ matrix.label }} - env: - # These legs run install.ps1 WITHOUT --tauri (install.ps1:189-215 rejects a custom - # root under --tauri exactly like install.sh:100-147), so the workspace-scoped - # root is usable here. - UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home + # No UNSLOTH_STUDIO_HOME here. The desktop app always invokes the installer as + # `--tauri [--local]` and scrubs the variable first (install.rs:202 and :356), and + # install.ps1:189-215 rejects a custom root under --tauri, so a workspace-scoped + # root forced these legs down the shell-install path instead: UNSLOTH_TAURI_MODE=0, + # frontend build enabled, different root resolution, no bundled-file overlay. Worse, + # "Installing PyTorch" is only ever printed by Write-TauriLog (install.ps1:2440), so + # without --tauri the torch leg's marker could not appear at all. The runner is + # ephemeral, so the default root is safe to install into. runs-on: windows-latest timeout-minutes: 60 strategy: @@ -215,8 +218,8 @@ jobs: # install.ps1 parses `--no-torch` (install.ps1:121); `-SkipTorch` matches no # case in that switch and is silently dropped. The torch leg must NOT skip # torch, or its marker never appears. - - {label: studio-deps, marker: 'studio deps', installArgs: '--no-torch --local'} - - {label: torch, marker: 'Installing PyTorch', installArgs: '--local'} + - {label: studio-deps, marker: 'studio deps', installArgs: '--tauri --no-torch --local'} + - {label: torch, marker: 'Installing PyTorch', installArgs: '--tauri --local'} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -251,7 +254,8 @@ jobs: id: probe shell: pwsh run: | - $bin = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\unsloth.exe' + # --tauri refuses a custom root, so this is where install.ps1:254-262 puts it. + $bin = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\unsloth.exe' if (-not (Test-Path $bin)) { "verdict=NO_CLI" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 Write-Host '[probe] no unsloth CLI -> preflight reports NotInstalled (safe)' @@ -278,7 +282,7 @@ jobs: run: | pwsh -NoProfile -NonInteractive -File install.ps1 ${{ matrix.installArgs }} *>&1 | Tee-Object -FilePath logs/repair.log - $bin = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\unsloth.exe' + $bin = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\unsloth.exe' if (-not (Test-Path $bin)) { Write-Host "::error::after a full re-run there is still no unsloth CLI at $bin" Get-Content logs/repair.log -Tail 30 -ErrorAction SilentlyContinue