diff --git a/.github/scripts/interrupt-install.ps1 b/.github/scripts/interrupt-install.ps1 new file mode 100644 index 0000000000..038c6a794a --- /dev/null +++ b/.github/scripts/interrupt-install.ps1 @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Windows counterpart of interrupt-install.sh: run install.ps1 and kill it partway +# through, reproducing a user quitting the desktop app mid-install. +# +# Windows has no process groups, which is why the app carries windows_job.rs -- a Job +# Object is what makes "kill the installer and everything it spawned" work there. This +# script kills the whole process TREE for the same reason: killing only the leader +# leaves uv/python children to finish the dependency pass, and the interruption would +# prove nothing. +# +# Usage: +# pwsh -File .github/scripts/interrupt-install.ps1 -Marker 'studio deps' ` +# -LogPath logs/install.log -InstallArgs '-SkipTorch' +[CmdletBinding()] +param( + [string]$Marker = '', + [string]$LogPath = 'logs/install.log', + [string]$InstallArgs = '', + [int]$KillAtSeconds = 900, + [int]$KillAfterMarkerSeconds = 3 +) + +$ErrorActionPreference = 'Continue' +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null +Set-Content -Path $LogPath -Value '' -Encoding utf8 + +# Stand in for the desktop app, which creates this before spawning the installer and +# clears it only on a terminal outcome (install.rs). We kill install.ps1 directly +# rather than driving the real app, so without this the marker #7490 relies on is +# absent for a reason that has nothing to do with #7490 -- which is exactly what the +# Windows legs reported. Both locations because the Rust side hardcodes +# ~/.unsloth/studio while CI overrides UNSLOTH_STUDIO_HOME. Never cleared: being +# killed is the whole point. +foreach ($dir in @($env:UNSLOTH_STUDIO_HOME, (Join-Path $HOME '.unsloth\studio'))) { + if ([string]::IsNullOrWhiteSpace($dir)) { continue } + try { + New-Item -ItemType Directory -Force -Path $dir -ErrorAction Stop | Out-Null + Set-Content -Path (Join-Path $dir '.desktop-install-in-progress') -Value '' -ErrorAction Stop + } catch { Write-Host "[interrupt] could not seed install marker in ${dir}: $_" } +} + +# Run the installer in its own pwsh so stdout can be redirected to the log while we poll. +$argList = @('-NoProfile', '-NonInteractive', '-File', 'install.ps1') +if ($InstallArgs) { $argList += $InstallArgs.Split(' ') } +$proc = Start-Process -FilePath 'pwsh' -ArgumentList $argList ` + -RedirectStandardOutput $LogPath -RedirectStandardError "$LogPath.err" ` + -PassThru -NoNewWindow +Write-Host "[interrupt] installer pid=$($proc.Id) marker='$Marker' deadline=${KillAtSeconds}s" + +function Stop-Tree([int]$RootId) { + # Depth-first: children before parents, so a parent cannot respawn a child we already + # killed. CIM gives us the parent link Windows does not expose via process groups. + $kids = @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$RootId" -ErrorAction SilentlyContinue) + foreach ($k in $kids) { Stop-Tree ([int]$k.ProcessId) } + try { Stop-Process -Id $RootId -Force -ErrorAction Stop; Write-Host "[interrupt] killed pid=$RootId" } + catch { } +} + +$killed = $false +$reason = '' +for ($i = 0; $i -lt $KillAtSeconds; $i++) { + if ($proc.HasExited) { $reason = 'exited-before-marker'; break } + if ($Marker) { + $hit = Select-String -Path $LogPath -Pattern $Marker -SimpleMatch:$false -ErrorAction SilentlyContinue + if ($hit) { + $reason = 'marker-hit' + Start-Sleep -Seconds $KillAfterMarkerSeconds + $killed = $true + break + } + } + Start-Sleep -Seconds 1 +} +if (-not $killed -and -not $proc.HasExited) { if (-not $reason) { $reason = 'deadline' }; $killed = $true } + +if ($killed) { + Write-Host "[interrupt] killing process tree of $($proc.Id) ($reason)" + Stop-Tree $proc.Id + # Any straggler uv/python that reparented away from the installer. + foreach ($name in 'uv', 'python') { + Get-Process -Name $name -ErrorAction SilentlyContinue | + Where-Object { $_.Path -and $_.Path -like "*$env:UNSLOTH_STUDIO_HOME*" } | + ForEach-Object { try { Stop-Process -Id $_.Id -Force } catch { } } + } +} + +try { $proc.WaitForExit(30000) | Out-Null } catch { } +$rc = if ($proc.HasExited) { $proc.ExitCode } else { 'running' } +Write-Host "[interrupt] installer exit=$rc reason=$reason killed=$killed" +Write-Host '[interrupt] last log lines:' +Get-Content $LogPath -Tail 15 -ErrorAction SilentlyContinue + +if ($Marker -and -not (Select-String -Path $LogPath -Pattern $Marker -ErrorAction SilentlyContinue)) { + Write-Host "::warning::marker '$Marker' never appeared -- killed at the deadline, not the intended step" +} +@( + "interrupt_reason=$reason" + "interrupt_killed=$killed" + "installer_exit=$rc" +) | Set-Content -Path (Join-Path (Split-Path -Parent $LogPath) 'interrupt.env') -Encoding utf8 +exit 0 diff --git a/.github/scripts/interrupt-install.sh b/.github/scripts/interrupt-install.sh new file mode 100755 index 0000000000..9f3fa3a49d --- /dev/null +++ b/.github/scripts/interrupt-install.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Run install.sh and SIGTERM it partway through, reproducing what the desktop app does +# when the user quits mid-install: main.rs cleanup_child_processes() -> +# install::stop_install() -> kill the installer PROCESS GROUP (install.rs:798-807). +# +# Killing only the leader would leave `uv`/`python` children running and finishing the +# dep pass, so the interruption has to target the group -- otherwise the test quietly +# proves nothing. +# +# Usage: bash .github/scripts/interrupt-install.sh "" "" [-- install args] +# regex to wait for in the install log before killing, e.g. "studio deps" +# or "\[TAURI:STEP\] Installing PyTorch". Use "" to kill after --at-seconds. +# Env: +# KILL_AT_SECONDS hard deadline; kill even if the marker never appears (default 900) +# KILL_GRACE seconds to wait for the group to die before SIGKILL (default 10) +set -uo pipefail + +MARKER="${1:-}" +LOG="${2:-logs/install.log}" +shift 2 || true +[ "${1:-}" = "--" ] && shift +KILL_AT_SECONDS="${KILL_AT_SECONDS:-900}" +KILL_GRACE="${KILL_GRACE:-10}" + +mkdir -p "$(dirname "$LOG")" +: > "$LOG" + +# Stand in for the desktop app, which creates this before spawning the installer and +# clears it only on a terminal outcome (install.rs). We kill the installer directly +# rather than driving the real app, so without this the marker #7490 relies on is +# absent for a reason that has nothing to do with #7490. Written to both locations +# because the Rust side hardcodes ~/.unsloth/studio while CI overrides +# UNSLOTH_STUDIO_HOME. Deliberately never cleared: being killed is the whole point. +for _marker_dir in "${UNSLOTH_STUDIO_HOME:-}" "$HOME/.unsloth/studio"; do + [ -n "$_marker_dir" ] || continue + mkdir -p "$_marker_dir" 2>/dev/null || continue + : > "$_marker_dir/.desktop-install-in-progress" 2>/dev/null || true +done + +# Job control puts the child in its own process group, so $! is the pgid leader and +# `kill -- -$!` reaches every descendant -- matching the Rust side. +set -m +bash install.sh "$@" > "$LOG" 2>&1 & +PID=$! +set +m +echo "[interrupt] installer pid/pgid=$PID marker='${MARKER}' deadline=${KILL_AT_SECONDS}s" + +killed=false +reason="" +for i in $(seq 1 "$KILL_AT_SECONDS"); do + if ! kill -0 "$PID" 2>/dev/null; then + reason="exited-before-marker" + break + fi + if [ -n "$MARKER" ] && grep -qE "$MARKER" "$LOG" 2>/dev/null; then + reason="marker-hit" + # Let it get a beat into the step, so the kill lands mid-work rather than on the + # boundary where the step has not started touching the venv yet. + sleep "${KILL_AFTER_MARKER_SECONDS:-3}" + killed=true + break + fi + sleep 1 +done + +if [ "$killed" != "true" ] && kill -0 "$PID" 2>/dev/null; then + reason="${reason:-deadline}" + killed=true +fi + +if [ "$killed" = "true" ]; then + echo "[interrupt] SIGTERM to process group -$PID ($reason)" + kill -TERM -- -"$PID" 2>/dev/null || kill -TERM "$PID" 2>/dev/null || true + for _ in $(seq 1 "$KILL_GRACE"); do + kill -0 "$PID" 2>/dev/null || break + sleep 1 + done + if kill -0 "$PID" 2>/dev/null; then + echo "[interrupt] group survived SIGTERM; SIGKILL" + kill -KILL -- -"$PID" 2>/dev/null || kill -KILL "$PID" 2>/dev/null || true + fi +fi + +wait "$PID" 2>/dev/null +rc=$? +echo "[interrupt] installer exit=$rc reason=$reason killed=$killed" +echo "[interrupt] last log lines:" +tail -15 "$LOG" || true + +# Report how far it got, so a leg that never reached the target step is visible as such +# rather than passing for the wrong reason. +if [ -n "$MARKER" ] && ! grep -qE "$MARKER" "$LOG" 2>/dev/null; then + echo "::warning::marker '$MARKER' never appeared -- this leg killed at the deadline, not at the intended step" +fi +{ + echo "interrupt_reason=$reason" + echo "interrupt_killed=$killed" + echo "installer_exit=$rc" +} > "$(dirname "$LOG")/interrupt.env" +exit 0 diff --git a/.github/scripts/interrupted_install_probe.py b/.github/scripts/interrupted_install_probe.py new file mode 100644 index 0000000000..1d9a04890b --- /dev/null +++ b/.github/scripts/interrupted_install_probe.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""After an install is interrupted, decide whether the desktop app WOULD report the +resulting venv as healthy -- reproducing the Tauri preflight probes so the regression +is testable without building the app. + +One implementation for all three platforms. There were briefly two (a shell probe and +an inline PowerShell one), and they diverged: the PowerShell version only ran `-h` and +`desktop-capabilities`, so it could not see the `studio_install_ok`, `verify-install` +or `desktop-runtime-check` signals that the fix PRs introduce -- it would have +reported those PRs as failing no matter how well they worked. A probe that cannot +observe the fix is worse than no probe, hence a single shared one. + +The reported bug: quitting the app during the dependency pass SIGTERMs the installer +(install.rs stop_install). Landing in the "studio deps" step drops +studio/backend/requirements/studio.txt, where structlog is declared. Preflight then +probes `unsloth -h` (preflight/managed.rs:419) and `studio desktop-capabilities` +(managed.rs:318); both SUCCEED because typer/click/rich are core, so the app reports +ManagedReady with can_auto_repair=false and the backend dies on `import structlog`. + +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 + 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. +""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + + +def run(cmd: list[str], timeout: int = 120) -> tuple[int, str]: + try: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return p.returncode, (p.stdout or "") + (p.stderr or "") + except (subprocess.TimeoutExpired, OSError) as e: + return 127, f"{type(e).__name__}: {e}" + + +def has_subcommand(bin_path: str, args: list[str]) -> bool: + """Whether the CLI understands a subcommand at all. Older builds do not have the + newer verify commands, and 'absent' must not be confused with 'reported failure'.""" + rc, _ = run([bin_path, *args, "--help"], timeout=60) + return rc == 0 + + +def free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("bin", help="path to the unsloth CLI") + ap.add_argument("--port", type=int, default=0, help="0 picks a free port") + ap.add_argument("--out", default="probe", help="directory for probe artefacts") + ap.add_argument("--boot-timeout", type=int, default=120) + a = ap.parse_args(argv) + + binp = a.bin + if not Path(binp).exists(): + print(f"::error::unsloth bin not found: {binp}") + return 2 + out = Path(a.out) + out.mkdir(parents=True, exist_ok=True) + port = a.port or free_port() + facts: dict[str, object] = {} + + def say(k: str, v: object) -> None: + facts[k] = v + print(f"[probe] {k:28} = {v}") + + # ── the two probes Tauri preflight actually runs ───────────────────────── + rc, log = run([binp, "-h"], timeout=180) + (out / "cli-h.log").write_text(log, encoding="utf-8", errors="replace") + say("cli_h_ok", rc == 0) + + rc, caps_raw = run([binp, "studio", "desktop-capabilities", "--json"], timeout=180) + (out / "desktop-capabilities.json").write_text(caps_raw, encoding="utf-8", errors="replace") + say("capabilities_ok", rc == 0) + + # studio_install_ok is added by the install-manifest work; absent on older trees, + # which is different from present-and-false. + install_ok: object = "absent" + try: + # The CLI may print a banner before the JSON, so start at the first brace. + brace = caps_raw.find("{") + if brace >= 0: + v = json.loads(caps_raw[brace:]).get("studio_install_ok") + install_ok = "absent" if v is None else bool(v) + except (json.JSONDecodeError, AttributeError): + pass + say("capabilities.studio_install_ok", install_ok) + + # ── the deeper probes the fix PRs add ──────────────────────────────────── + for label, args in (("verify_install", ["studio", "verify-install"]), + ("desktop_runtime_check", ["studio", "desktop-runtime-check"])): + if not has_subcommand(binp, args): + say(label, "absent") + continue + rc, log = run([binp, *args], timeout=300) + (out / f"{label}.log").write_text(log, encoding="utf-8", errors="replace") + say(label, "ok" if rc == 0 else "failed") + + # The in-progress marker #7490 writes before spawning the installer. + home = Path(os.environ.get("UNSLOTH_STUDIO_HOME") or (Path.home() / ".unsloth" / "studio")) + say("install_in_progress_marker", (home / ".desktop-install-in-progress").exists()) + + # ── ground truth: does the backend actually boot? ──────────────────────── + # Own the whole process tree: the CLI spawns uvicorn/python children, and + # terminating only the parent leaves them holding the port, so the next leg's + # probe would hang. Same reason the interrupt driver kills the group. + popen_kw: dict = {} + if os.name == "posix": + popen_kw["start_new_session"] = True + else: + popen_kw["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + proc = subprocess.Popen( + [binp, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **popen_kw, + ) + backend_ok = False + deadline = time.time() + a.boot_timeout + while time.time() < deadline: + if proc.poll() is not None: + break + for path in ("/api/health", "/healthz"): + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=2) as r: + if r.status == 200: + backend_ok = True + break + except (urllib.error.URLError, OSError, TimeoutError): + pass + if backend_ok: + break + time.sleep(1) + def reap() -> None: + if os.name == "posix": + import signal + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(os.getpgid(proc.pid), sig) + except (ProcessLookupError, PermissionError, OSError): + pass + try: + proc.wait(timeout=10) + return + except subprocess.TimeoutExpired: + continue + else: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + reap() + try: + blog = proc.communicate(timeout=30)[0] or "" + except subprocess.TimeoutExpired: + proc.kill() + blog = proc.communicate()[0] or "" + (out / "backend.log").write_text(blog, encoding="utf-8", errors="replace") + say("backend_ok", backend_ok) + + missing = "" + for line in blog.splitlines(): + if "ModuleNotFoundError" in line: + missing = line.strip() + if missing: + say("backend_error", missing) + + # ── verdict ────────────────────────────────────────────────────────────── + 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 + or facts.get("install_in_progress_marker") is True + or not facts.get("cli_h_ok") + or not facts.get("capabilities_ok")): + verdict = "REPAIRABLE" + else: + verdict = "FALSE_READY" + + facts["verdict"] = verdict + (out / "verdict.json").write_text(json.dumps(facts, indent=2), encoding="utf-8") + print(f"[probe] VERDICT = {verdict}") + + if verdict == "FALSE_READY": + print("::error::Interrupted install reports READY but the backend cannot boot" + f" ({missing or 'import failure'}). Preflight sees -h ok + desktop-capabilities" + " ok, so the app shows ManagedReady with can_auto_repair=false and the user" + " is stuck.") + return 1 + if verdict == "REPAIRABLE": + print("[probe] broken install is detectable -> the desktop app can auto-repair") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/.github/workflows/interrupted-install-ci.yml b/.github/workflows/interrupted-install-ci.yml new file mode 100644 index 0000000000..e63bab87ee --- /dev/null +++ b/.github/workflows/interrupted-install-ci.yml @@ -0,0 +1,206 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Proves an INTERRUPTED install can never masquerade as a healthy one. +# +# Reported failure: a user quits the desktop app while it is installing. The app kills +# the installer process group (main.rs cleanup_child_processes -> install.rs:798-807), +# which lands mid "studio deps" -- the step that installs +# studio/backend/requirements/studio.txt, where structlog is declared. On relaunch, +# preflight probes `unsloth -h` and `studio desktop-capabilities --json`; both succeed +# because the CLI's own deps (typer/click/rich) are core, so the app reports +# ManagedReady with can_auto_repair=false. The backend then dies on +# `import structlog` and the user is permanently stuck on "Server stopped +# unexpectedly". +# +# Nothing in CI covered this: no job has ever interrupted an install. This workflow +# kills the installer at each interesting phase and asserts the result is either +# genuinely healthy or explicitly repairable -- never silently ready. + +name: Interrupted install recovery + +on: + pull_request: + paths: + - 'install.sh' + - 'install.ps1' + - 'studio/setup.sh' + - 'studio/setup.ps1' + - 'studio/install_python_stack.py' + - 'studio/src-tauri/src/install.rs' + - 'studio/src-tauri/src/preflight.rs' + - 'studio/src-tauri/src/preflight/**' + - 'unsloth_cli/commands/studio.py' + - '.github/scripts/interrupt*-install*' + - '.github/scripts/interrupted-install-probe.sh' + - '.github/workflows/interrupted-install-ci.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home + UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1' + +jobs: + # ── macOS + Linux: kill at each phase ───────────────────────────────────── + interrupt: + name: ${{ matrix.os }} kill@${{ matrix.label }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + # The exact reported case: killed during the step that installs structlog. + - {os: macos-14, label: studio-deps, marker: 'studio deps', experimental: false} + # Coarse phases, earliest to latest -- each leaves a different partial venv. + - {os: macos-14, label: venv, marker: '\[TAURI:STEP\] Creating virtual environment', experimental: false} + - {os: macos-14, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch', experimental: false} + - {os: macos-14, label: unsloth, marker: '\[TAURI:STEP\] Installing Unsloth', experimental: false} + - {os: macos-14, label: setup, marker: '\[TAURI:STEP\] Running Unsloth setup', experimental: false} + # Other dependency-pass steps around the named one. + - {os: macos-14, label: pip-bootstrap, marker: 'pip bootstrap', experimental: false} + - {os: macos-14, label: base-packages, marker: 'base packages', experimental: false} + - {os: macos-14, label: unsloth-extras, marker: 'unsloth extras', experimental: true} + - {os: macos-14, label: data-designer, marker: 'data designer deps', experimental: true} + # Linux: same teardown path, different package manager and process semantics. + - {os: ubuntu-latest, label: studio-deps, marker: 'studio deps', experimental: false} + - {os: ubuntu-latest, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch', experimental: false} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Linux system deps + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq --no-install-recommends cmake git build-essential libcurl4-openssl-dev + + - name: Install, interrupted at "${{ matrix.label }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + KILL_AT_SECONDS: '1500' + run: | + # --local is load-bearing, not a convenience. Without it install.sh:3996 + # resolves `unsloth>=2026.7.5` from PyPI, so the venv gets the PUBLISHED CLI + # and none of the branch's unsloth_cli changes are present. Every probe of + # `studio verify-install` / `studio desktop-runtime-check` then reports + # "absent" no matter what the branch does, which makes the whole lane + # incapable of observing the fix it exists to test. --local overlays the + # checkout editable (install.sh:3990) before `studio setup` runs the dep + # pass, so a kill at "studio deps" leaves the branch's CLI installed. + bash .github/scripts/interrupt-install.sh \ + '${{ matrix.marker }}' logs/install.log -- --tauri --local + + - name: What state is the install in? + id: probe + run: | + BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/unsloth" + [ -x "$BIN" ] || BIN="$UNSLOTH_STUDIO_HOME/bin/unsloth" + if [ ! -x "$BIN" ]; then + # No CLI at all is a SAFE outcome: preflight reports NotInstalled and the + # app offers a normal install. Nothing to assert beyond that. + echo "verdict=NO_CLI" >> "$GITHUB_OUTPUT" + echo "[probe] no unsloth CLI installed -> preflight reports NotInstalled (safe)" + exit 0 + fi + rc=0 + python3 .github/scripts/interrupted_install_probe.py "$BIN" --out probe || rc=$? + v="$(python3 -c "import json;print(json.load(open('probe/verdict.json'))['verdict'])")" + echo "verdict=$v" >> "$GITHUB_OUTPUT" + exit "$rc" + + - name: A re-run must repair, not short-circuit + # Only meaningful when the install is broken but present. The bug's second half + # is that `install.sh` sees a "current" version and no-ops over a broken venv. + if: always() && steps.probe.outputs.verdict != 'NO_CLI' && steps.probe.outputs.verdict != 'HEALTHY' + run: | + set -o pipefail + rc=0 + bash install.sh --tauri --local < /dev/null 2>&1 | tee logs/repair.log || rc=$? + echo "repair exit: $rc" + if grep -qiE "up to date|already current" logs/repair.log \ + && ! grep -qiE "forcing dependency pass|incomplete|repair" logs/repair.log; then + echo "::error::re-run reported the venv up to date without repairing it" + exit 1 + fi + BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/unsloth" + [ -x "$BIN" ] || BIN="$UNSLOTH_STUDIO_HOME/bin/unsloth" + python3 .github/scripts/interrupted_install_probe.py "$BIN" --out probe-after || true + v="$(python3 -c "import json;print(json.load(open('probe-after/verdict.json'))['verdict'])")" + if [ "$v" != "HEALTHY" ]; then + echo "::error::after a full re-run the backend still does not boot (verdict=$v)" + exit 1 + fi + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: interrupted-${{ matrix.os }}-${{ matrix.label }} + path: | + logs/ + probe/ + probe-after/ + retention-days: 7 + if-no-files-found: warn + + # ── Windows: no process groups, so the kill path differs ────────────────── + interrupt-windows: + name: windows kill@${{ matrix.label }} + runs-on: windows-latest + timeout-minutes: 60 + continue-on-error: true + strategy: + fail-fast: false + matrix: + include: + - {label: studio-deps, marker: 'studio deps'} + - {label: torch, marker: 'Installing PyTorch'} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install, interrupted at "${{ matrix.label }}" + shell: pwsh + run: | + pwsh -NoProfile -File .github/scripts/interrupt-install.ps1 ` + -Marker '${{ matrix.marker }}' -LogPath logs/install.log ` + -InstallArgs '-SkipTorch --local' -KillAtSeconds 1500 + + - name: What state is the install in? + shell: pwsh + run: | + $bin = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\unsloth.exe' + if (-not (Test-Path $bin)) { + Write-Host '[probe] no unsloth CLI -> preflight reports NotInstalled (safe)' + exit 0 + } + # The SAME probe the other platforms run. This step used to be a bespoke + # inline version that only checked `-h` and `desktop-capabilities`, so it + # could not observe studio_install_ok / verify-install / + # desktop-runtime-check -- it would have failed the very PRs that add them, + # no matter how well they worked. + python .github/scripts/interrupted_install_probe.py $bin --out probe + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: interrupted-windows-${{ matrix.label }} + path: | + logs/ + probe/ + retention-days: 7 + if-no-files-found: warn