diff --git a/.github/scripts/interrupt-install.ps1 b/.github/scripts/interrupt-install.ps1 new file mode 100644 index 0000000000..5bbff97d45 --- /dev/null +++ b/.github/scripts/interrupt-install.ps1 @@ -0,0 +1,211 @@ +# 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 (hence the app's windows_job.rs), so this kills the whole +# process TREE: killing only the leader leaves uv/python children to finish the dep pass. +# +# Usage: +# pwsh -File .github/scripts/interrupt-install.ps1 -Marker 'studio deps' ` +# -LogPath logs/install.log -InstallArgs '--tauri --no-torch --local' +[CmdletBinding()] +param( + [string]$Marker = '', + [string]$LogPath = 'logs/install.log', + [string]$InstallArgs = '', + [int]$KillAtSeconds = 900 +) + +$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 writes this before spawning the installer +# (install.rs). We kill install.ps1 directly, so without it #7490's marker is absent for an +# unrelated reason -- exactly what the Windows legs reported. Both locations: Rust +# hardcodes ~/.unsloth/studio, CI overrides UNSLOTH_STUDIO_HOME. Never cleared by design. +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}: $_" } +} + +# Its own host, so stdout can be redirected to the log while we poll. That host is WINDOWS +# PowerShell 5.1, not pwsh, with install.rs:325-339's exact flags: the only host a real +# desktop install ever uses, while every other Windows job in .github runs install.ps1 +# under pwsh 7, leaving 5.1 behaviour (.NET Framework, OEM/ANSI console encoding, different +# native-command and OSArchitecture reporting) covered by nothing. The driver stays under +# pwsh; only the installer child and the repair re-run change. +$argList = @( + '-NoLogo', '-NoProfile', '-NonInteractive', + '-WindowStyle', 'Hidden', + '-ExecutionPolicy', 'Bypass', + '-File', 'install.ps1' +) +if ($InstallArgs) { $argList += $InstallArgs.Split(' ') } +$proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $argList ` + -RedirectStandardOutput $LogPath -RedirectStandardError "$LogPath.err" ` + -PassThru -NoNewWindow +Write-Host "[interrupt] installer pid=$($proc.Id) marker='$Marker' deadline=${KillAtSeconds}s" + +# Proof that the signal was DELIVERED, not merely attempted. The installer can also fail +# on its own between the last HasExited check and Stop-Tree, and a natural failure carries +# a non-zero exit code just like a kill does, so the exit status alone cannot separate the +# two on Windows. Stop-Process throws on a process that has already gone, so this flag is +# false exactly when there was nothing left to interrupt. +$script:rootKilled = $false + +function Get-Descendants([int]$RootId) { + # Depth-first, deepest first. CIM gives the parent link Windows does not expose via + # process groups. + $ids = @() + foreach ($k in @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$RootId" -ErrorAction SilentlyContinue)) { + $kid = [int]$k.ProcessId + $ids += Get-Descendants $kid + $ids += $kid + } + return $ids +} + +function Stop-Tree([int]$RootId) { + # Snapshot the whole tree BEFORE killing anything. Once a parent is gone its children are + # orphaned and there is no ParentProcessId left to walk, so the walk has to happen first. + $descendants = @(Get-Descendants $RootId) + # Then the ROOT, ahead of its children. install.ps1 watches the child it is waiting on: + # in staging run 30424366953 it had already printed "unsloth studio setup failed (exit + # code -1)" by the time Stop-Process reached it. Killing children first therefore races + # the leader's own exit, and a leader that wins that race makes Stop-Process throw over + # an interruption the driver really did deliver, failing the leg for nothing. Dead first, + # it cannot react to anything, and it cannot respawn what we are about to kill either. + try { + Stop-Process -Id $RootId -Force -ErrorAction Stop + Write-Host "[interrupt] killed installer pid=$RootId" + if ($RootId -eq $proc.Id) { $script:rootKilled = $true } + } + catch { if ($RootId -eq $proc.Id) { Write-Host "[interrupt] installer pid=$RootId was already gone: $_" } } + foreach ($id in $descendants) { + try { Stop-Process -Id $id -Force -ErrorAction Stop; Write-Host "[interrupt] killed pid=$id" } + catch { } + } +} + +# A leg can be aimed at either kind of phase the installer prints, and only one of them is +# a line. install.ps1 prints "[TAURI:STEP] " lines, while the dependency pass rewrites +# ONE physical line with \r (install_python_stack.py:2499), so its sub-steps are +# CR-separated SEGMENTS. Splitting on \r is what makes a sub-step's END observable at all. +$SubRe = '\[[=-]+\]\s*\d+/\d+\s' + +function Get-PhaseLines([string]$Path) { + $raw = Get-Content -Path $Path -Raw -ErrorAction SilentlyContinue + if (-not $raw) { return @() } + return @(($raw -replace "`r", "`n") -split "`n") +} + +function Get-LastPhase([string]$Path) { + $p = @(Get-PhaseLines $Path | Where-Object { $_ -match '^\[TAURI:STEP\]' -or $_ -match $SubRe }) + if ($p.Count) { return $p[-1] } + return '' +} + +# True when the phase the marker named is no longer the running one. A sub-step marker is +# judged against the running sub-step, a step marker against the running step -- a step is +# not "over" because the sub-steps beneath it advanced. +function Test-MarkedPhaseOver { + if (-not $Marker) { return $false } + $lines = @(Get-PhaseLines $LogPath) + $subs = @($lines | Where-Object { $_ -match $SubRe }) + if ($subs | Where-Object { $_ -match $Marker }) { + $last = Get-LastPhase $LogPath + return -not ($last -match $SubRe -and $last -match $Marker) + } + $steps = @($lines | Where-Object { $_ -match '^\[TAURI:STEP\]' }) + if ($steps | Where-Object { $_ -match $Marker }) { + return ($steps[-1] -notmatch $Marker) + } + return $false +} + +$killed = $false +$reason = '' +# Fifth-of-a-second slices, matching the POSIX driver: every phase label prints BEFORE its +# work starts, so this delay IS the whole distance between the label and the signal, and it +# is the only thing that can push the kill past the end of a short phase. It slept 500ms +# while the comment claimed a fifth, so it carried 2.5 slices of overshoot the POSIX side +# does not. +for ($i = 0; $i -lt ($KillAtSeconds * 5); $i++) { + if ($proc.HasExited) { $reason = 'exited-before-marker'; break } + if ($Marker) { + $hit = Select-String -Path $LogPath -Pattern $Marker -SimpleMatch:$false -ErrorAction SilentlyContinue + if ($hit) { + # Same as the POSIX driver: signal at detection, never after a delay. The label + # prints before the work, so the kill is inside the phase the moment the line + # appears, and any wait is a bet on the phase outlasting it that staging runs + # 30419729244 and 30426111484 both lost. + # + # The installer can still exit on its own between the match and the signal, which + # would record marker-hit over an install that interrupted nothing. + if ($proc.HasExited) { $reason = 'exited-before-signal'; break } + $reason = 'marker-hit' + $killed = $true + break + } + } + Start-Sleep -Milliseconds 200 +} +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. The old sweep matched + # nothing: UNSLOTH_STUDIO_HOME arrives as `D:\a\r\r/.studio-home` (github.workspace joined + # with a forward slash) while Process.Path is all backslashes, so the literal -like missed + # even the venv's own python. Hence the separator normalisation, and uv by name (it lives + # outside the studio home, and the ephemeral runner has no other uv). Under --tauri there + # is no UNSLOTH_STUDIO_HOME, so fall back to install.ps1's root or the sweep only sees 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 { } + $inHome = $homeNorm -and $path -and ($path -like "$homeNorm\*") + if ($p.ProcessName -eq 'uv' -or $inHome) { + try { Stop-Process -Id $p.Id -Force; Write-Host "[interrupt] swept $($p.ProcessName) pid=$($p.Id)" } 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 root_killed=$($script:rootKilled)" +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" +} +# Where the signal actually landed. A phase that ended before the poll saw the marker sends +# the kill into a LATER phase, and the leg then duplicates whichever leg owns that phase +# while its own label claims otherwise. +$lastPhase = Get-LastPhase $LogPath +Write-Host "[interrupt] phase at kill: $lastPhase" +$mismatch = Test-MarkedPhaseOver +if ($mismatch) { + Write-Host "::warning::killed in '$lastPhase', not the marked phase -- that phase was already over" +} +# Lower-cased so the workflow can compare it the same way on every platform, and only +# simple values: the POSIX side sources this file. +@( + "interrupt_reason=$reason" + "interrupt_killed=$killed" + "interrupt_root_killed=$(if ($script:rootKilled) { 'true' } else { 'false' })" + "installer_exit=$rc" + "interrupt_phase_mismatch=$(if ($mismatch) { 'true' } else { 'false' })" +) | 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..7e73d12c27 --- /dev/null +++ b/.github/scripts/interrupt-install.sh @@ -0,0 +1,164 @@ +#!/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 a user quitting the desktop +# app mid-install: main.rs cleanup_child_processes() -> install::stop_install() kills the +# installer PROCESS GROUP (install.rs:798-807). Group, not leader: `uv`/`python` children +# would otherwise finish the dep pass and the leg would prove nothing. +# +# Usage: bash .github/scripts/interrupt-install.sh "" "" [-- install args] +# log regex to wait for before killing, e.g. "studio deps"; "" kills at deadline. +# Env: KILL_AT_SECONDS deadline (default 900), KILL_GRACE grace 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" + +# The desktop writes this before spawning the installer (install.rs); we kill the installer +# directly, so without it #7490's marker is absent for an unrelated reason. Both roots: Rust +# hardcodes ~/.unsloth/studio, CI overrides UNSLOTH_STUDIO_HOME. Never cleared by design. +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 group, so $! is the pgid and `kill -- -$!` +# reaches every descendant, as the Rust side does. +set -m +bash install.sh "$@" > "$LOG" 2>&1 & +PID=$! +set +m +echo "[interrupt] installer pid/pgid=$PID marker='${MARKER}' deadline=${KILL_AT_SECONDS}s" + +# A leg can be aimed at either kind of phase the installer prints, and only one of them is +# a line. install.sh prints "[TAURI:STEP] " lines, while the dependency pass rewrites +# ONE physical line with \r (install_python_stack.py:2499), so its ten sub-steps are +# CR-separated SEGMENTS. Splitting on \r is what makes a sub-step's END observable at all: +# without it the "studio deps" leg of staging run 30419729244 killed at "7/10 data designer +# deps" with backend_ok=true, having installed the structlog it exists to remove. +SUB_RE='\[[=-]+\][[:space:]]*[0-9]+/[0-9]+[[:space:]]' +phase_lines() { tr '\r' '\n' < "$LOG" 2>/dev/null || true; } + +# True when the phase the marker named is no longer the running one. A sub-step marker is +# judged against the running sub-step, a step marker against the running step -- a step is +# not "over" because the sub-steps beneath it advanced. A marker naming neither is not +# judged. Results go through variables, never a `| grep -q`, which can report SIGPIPE +# through pipefail on a long log. +marked_phase_over() { + [ -n "$MARKER" ] || return 1 + local lines steps subs last + lines="$(phase_lines)" + steps="$(printf '%s\n' "$lines" | grep -aE '^\[TAURI:STEP\]')" || true + subs="$(printf '%s\n' "$lines" | grep -aE "$SUB_RE")" || true + if [ -n "$subs" ] && [[ $subs =~ $MARKER ]]; then + last="$(printf '%s\n' "$lines" | grep -aE "^\[TAURI:STEP\]|$SUB_RE" | tail -1)" || true + [[ $last =~ $SUB_RE && $last =~ $MARKER ]] && return 1 + return 0 + fi + if [ -n "$steps" ] && [[ $steps =~ $MARKER ]]; then + last="$(printf '%s\n' "$steps" | tail -1)" + [[ $last =~ $MARKER ]] && return 1 + return 0 + fi + return 1 +} + +killed=false +reason="" +# Fifth-of-a-second slices: every phase label prints BEFORE its work starts, so the poll +# delay is the whole distance between the label and the signal. +for i in $(seq 1 $(( KILL_AT_SECONDS * 5 ))); 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 + # Signal at detection, never after a delay. Every label prints BEFORE its work starts, + # so the kill is inside the phase the moment the line appears, and any wait at all is a + # bet on how long that phase runs. The bet loses: a flat 3s wait put 5 of the 12 legs + # of staging run 30419729244 into a LATER phase, and in 30426111484 it carried the + # macOS torch leg from "Installing PyTorch" into "Installing Unsloth" -- a step the + # workflow called minutes long finished in under three seconds. + # + # Between the grep and the signal the installer can still exit on its own, which would + # record marker-hit over an install that interrupted nothing. + if ! kill -0 "$PID" 2>/dev/null; then + reason="exited-before-signal" + break + fi + reason="marker-hit" + killed=true + break + fi + sleep 0.2 +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 + # Unconditional, and to the GROUP: the leader exits on SIGTERM while a uv or python + # descendant does not, and gating on `kill -0 "$PID"` left that child finishing the dep + # pass under the probe. Signalling an empty group is a no-op. + echo "[interrupt] SIGKILL to process group -$PID" + kill -KILL -- -"$PID" 2>/dev/null || kill -KILL "$PID" 2>/dev/null || true +fi + +wait "$PID" 2>/dev/null +rc=$? + +# Only after the reap: an unreaped leader is still a member of its own group, so this +# poll would report it alive forever. No installer may still run when the probe starts. +if [ "$killed" = "true" ]; then + for _ in $(seq 1 "$KILL_GRACE"); do + kill -0 -- -"$PID" 2>/dev/null || break + kill -KILL -- -"$PID" 2>/dev/null || true + sleep 1 + done + if kill -0 -- -"$PID" 2>/dev/null; then + echo "::warning::processes from installer group -$PID outlived SIGKILL" + fi +fi +echo "[interrupt] installer exit=$rc reason=$reason killed=$killed" +echo "[interrupt] last log lines:" +tail -15 "$LOG" || true + +# A leg that never reached the target step must be visible, not quietly green. +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 +# Where the signal actually landed. A phase that ended before the poll saw the marker sends +# the kill into a LATER phase, and the leg then duplicates whichever leg owns that phase +# while its own label claims otherwise. +_last_phase="$(phase_lines | grep -aE "^\[TAURI:STEP\]|$SUB_RE" | tail -1)" || true +echo "[interrupt] phase at kill: $_last_phase" +mismatch=false +if marked_phase_over; then + mismatch=true + echo "::warning::killed in '$_last_phase', not the marked phase -- that phase was already over" +fi +# Only simple values: the workflow sources this file, so the phase text stays out of it. +{ + echo "interrupt_reason=$reason" + echo "interrupt_killed=$killed" + echo "installer_exit=$rc" + echo "interrupt_phase_mismatch=$mismatch" +} > "$(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..d302a38d2c --- /dev/null +++ b/.github/scripts/interrupted_install_probe.py @@ -0,0 +1,313 @@ +#!/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. + +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` (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`. + +ONE implementation for all three platforms. The Windows leg was briefly a bespoke inline +PowerShell probe that ran only `-h` and `desktop-capabilities`, so it could not observe +`studio_install_ok`, `verify-install` or `desktop-runtime-check` and would have failed +the very PRs that add them. A probe that cannot see the fix is worse than no probe. + +Verdicts: + HEALTHY the backend boots AND desktop-capabilities reports the install + complete -- preflight would report ManagedReady and be right + 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. +""" + +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, str]: + """Returns (rc, stdout, stderr). Kept SEPARATE: preflight pipes stdout and sends + stderr to /dev/null (managed.rs:358), so anything folded into stdout here is text + the desktop never sees.""" + 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 merged(rc_out_err: tuple[int, str, str]) -> str: + """Both streams, for artefact logs only -- never for parsing.""" + return rc_out_err[1] + rc_out_err[2] + + +def has_subcommand(bin_path: str, args: list[str]) -> bool: + """Whether the CLI understands a subcommand at all: older builds lack the newer + verify commands, and 'absent' must not be read as '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 ───────────────────────── + # The DESKTOP's deadline, not a generous CI one: preflight times each call out after + # 10s (managed.rs:337 for `-h`, :390 for desktop-capabilities) and reports Stale + # (managed.rs:471, :521). A longer timeout would call a slow torn venv HEALTHY and skip + # the re-run assertion; run() reports a timeout as a non-zero rc, the same REPAIRABLE arm. + PREFLIGHT_TIMEOUT = 10 + + t0 = time.time() + r = run([binp, "-h"], timeout = PREFLIGHT_TIMEOUT) + (out / "cli-h.log").write_text(merged(r), encoding = "utf-8", errors = "replace") + say("cli_h_ok", r[0] == 0) + say("cli_h_seconds", round(time.time() - t0, 2)) + + t0 = time.time() + caps_rc, caps_out, caps_err = run( + [binp, "studio", "desktop-capabilities", "--json"], timeout = PREFLIGHT_TIMEOUT + ) + (out / "desktop-capabilities.json").write_text(caps_out, encoding = "utf-8", errors = "replace") + (out / "desktop-capabilities.stderr.log").write_text( + caps_err, encoding = "utf-8", errors = "replace" + ) + say("capabilities_ok", caps_rc == 0) + say("capabilities_seconds", round(time.time() - t0, 2)) + + # Parse EXACTLY as the desktop does: managed.rs:414 hands the whole stdout buffer to + # serde_json, which rejects leading or trailing non-JSON, and stderr was already + # discarded at managed.rs:358. Folding stderr in made one warning line enough to fail + # the parse and report FALSE_READY over an install the real app offers to repair. + # "absent" (studio_install_ok predates the install-manifest work) and "unparseable" + # split only for a readable artefact: the desktop reports Stale for both + # ("desktop_capability_probe_failed", managed.rs:521). The field is Option + # (managed.rs:43), so serde rejects a non-boolean and the WHOLE payload fails to + # deserialize -> Stale; bool() instead read the JSON string "false" as True and + # reported HEALTHY over a torn install. Only a literal JSON true counts. + install_ok: object = "absent" + try: + parsed = json.loads(caps_out) + if not isinstance(parsed, dict): + install_ok = "unparseable" + else: + v = parsed.get("studio_install_ok") + if v is None: + install_ok = "absent" + elif isinstance(v, bool): + install_ok = v + else: + install_ok = "non-boolean" + except json.JSONDecodeError: + install_ok = "unparseable" + say("capabilities.studio_install_ok", install_ok) + + # The desktop's own conclusion: Ready only on rc 0 + a parsed payload + a true + # studio_install_ok. The predicate is `!= Some(true)` (managed.rs:445), so an ABSENT + # field is Stale exactly like a false one; a CLI too old to answer is already rejected + # one check earlier on desktop_manageability_version. Leaving "absent" undecided + # reported HEALTHY on every booting leg and skipped the repair assertion -- the very + # regression `unsloth_cli/commands/studio.py` sits in the path filter to catch. + caps_ready = caps_rc == 0 and install_ok is True + say("desktop_would_call_install_ok", caps_ready) + + # ── the deeper probes the fix PRs add ──────────────────────────────────── + # RECORDED, but NOT repair evidence: preflight runs only `-h` and + # `studio desktop-capabilities --json` (managed.rs:357, :445), so counting these would + # let a leg pass while the real app still reports ManagedReady over a torn install. + 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 + r = run([binp, *args], timeout = 300) + (out / f"{label}.log").write_text(merged(r), encoding = "utf-8", errors = "replace") + say(label, "ok" if r[0] == 0 else "failed") + + # The in-progress marker #7490 writes before spawning the installer. RECORDED ONLY: + # both drivers seed it and never clear it, so it is true on every leg by construction, + # and using it in the verdict would make FALSE_READY, the one outcome, unreachable. + 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 that would hold + # the port and hang the next leg's probe. Same reason the 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) + # Straight to the artefact file, never a PIPE: nothing drains a pipe until after the + # polling loop, so a backend whose imports outrun the OS buffer (64 KiB on Linux and + # macOS, one page on Windows) blocks BEFORE binding the port and backend_ok, what this + # verdict pivots on, would be false for a perfectly good install. + blog_path = out / "backend.log" + blog_fh = blog_path.open("w", encoding = "utf-8", errors = "replace") + # An interrupted install can leave the console script in place with its venv + # interpreter gone. An unguarded spawn would raise, so no verdict.json is written and + # both workflows die on json.load. An unlaunchable CLI is a broken backend `-h` flags. + 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 proc is not None and 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 proc is None: + return + if os.name == "posix": + import signal + + # start_new_session made this child its own group leader. Read the pgid + # BEFORE the reap: once waited on, os.getpgid() raises and escalation targets + # nothing. + try: + pgid = os.getpgid(proc.pid) + except OSError: + pgid = proc.pid + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(pgid, sig) + except OSError: + pass + try: + proc.wait(timeout = 10) + break + except subprocess.TimeoutExpired: + continue + # Unconditional, and to the GROUP -- the same escalation + # interrupt-install.sh:94 makes. The leader exits promptly on SIGTERM while a + # uvicorn worker does not, so returning once proc.wait() succeeded left that + # worker holding the port and venv while the repair reinstalled underneath. + # Signalling an empty group is a no-op. + try: + os.killpg(pgid, signal.SIGKILL) + except OSError: + pass + else: + # On win32 the CLI re-spawns the server as a CHILD and waits on it + # (unsloth_cli/commands/studio.py:1543), and CREATE_NEW_PROCESS_GROUP does not + # make terminate() reach descendants, so killing the wrapper alone leaves the + # venv locked against the repair. taskkill /T takes the tree. + run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], timeout = 30) + try: + proc.wait(timeout = 10) + except subprocess.TimeoutExpired: + proc.terminate() + try: + proc.wait(timeout = 10) + except subprocess.TimeoutExpired: + proc.kill() + + reap() + blog_fh.close() + blog = blog_path.read_text(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 ────────────────────────────────────────────────────────────── + # A booting backend is not enough. The manifest is written LAST + # (install_python_stack.py:3255), so the data-designer leg boots while + # desktop-capabilities still says studio_install_ok=false and preflight reports Stale + # (managed.rs:445); calling that HEALTHY skipped the re-run step. + # `-h` gates it for the same reason: probe_managed_bin runs it FIRST and returns Stale + # "cli_unusable" without reaching the capability probe (managed.rs:465-478), so + # consulting cli_h_ok only in the repairable arm called a help-less CLI HEALTHY. + if backend_ok and caps_ready and facts.get("cli_h_ok"): + verdict = "HEALTHY" + elif not caps_ready or not facts.get("cli_h_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] incomplete 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..ca0d6db1ff --- /dev/null +++ b/.github/workflows/interrupted-install-ci.yml @@ -0,0 +1,401 @@ +# 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: quitting the app mid-install kills the installer process group +# (main.rs cleanup_child_processes -> install.rs:798-807) mid "studio deps", the step +# installing 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 while the backend dies on `import structlog` +# and the user is stuck on "Server stopped unexpectedly". +# +# No CI job had ever interrupted an install. This one kills the installer at each phase +# and asserts the result is 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' + # Every leg installs the checkout with `--local`, so this file decides the console + # script and the core deps: `-h` and `desktop-capabilities` only survive a torn + # install because typer/click/rich are declared here, not in an extra. No other + # install workflow interrupts the installer. + - 'pyproject.toml' + # studio_install_ok and verify-install, the decisions the probe asserts on, live + # here, so an install_state() that accepts a missing manifest would merge unrun. + - 'unsloth_cli/_studio_deps.py' + - 'studio/install_manifest.py' + # The requirement files are the phases: studio.txt declares structlog, whose absence + # IS the reported false-ready bug, so moving a package between them changes what + # every interrupted state looks like. + - 'studio/backend/requirements/**' + # `interrupt*-install*` would match the .sh / .ps1 but NOT the underscored probe + # (`*` never matches `/`, and a literal `-install` follows), so list all three. + - '.github/scripts/interrupt-install.sh' + - '.github/scripts/interrupt-install.ps1' + - '.github/scripts/interrupted_install_probe.py' + - '.github/workflows/interrupted-install-ci.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + 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 + strategy: + fail-fast: false + matrix: + include: + # Only a marker: the driver signals the moment that line appears, and no leg gets + # to wait first. Every label prints BEFORE its work starts, so the kill is inside + # the phase at detection, and a delay only bets on how long the phase runs. The + # bet lost twice, both times turning a leg into a duplicate of the next one: a + # flat 3s wait moved 5 of the 12 legs of staging run 30419729244, and in + # 30426111484 it carried the macOS torch leg into "Installing Unsloth" because + # the PyTorch step, called minutes long here, finished in under three seconds. + # + # Every leg is a hard gate. There is no continue-on-error cell: a leg allowed to + # fail is a warning wearing a red icon, and this workflow's entire claim is that + # a killed install cannot report itself healthy. + # + # The exact reported case: killed during the sub-step that installs structlog. + - {os: macos-14, label: studio-deps, marker: 'studio deps'} + # Coarse phases, earliest to latest -- each leaves a different partial venv. + # No venv cell. "Creating virtual environment" ran 0.107s in staging run + # 30419729244 (03:31:07.371 -> 07.478 to "Installing PyTorch"), shorter than any + # poll that watches the log, so the kill landed in the NEXT phase every time it + # was tried (30423181897 and 30424366953 both). It was the torch leg with a + # different label. Lost with it: a venv caught half-written. That state is not + # reachable by interruption at this resolution, and it is the only thing lost -- + # the torch leg lands at the top of the PyTorch step, so what it leaves behind is + # already a complete venv with nothing installed into it. + - {os: macos-14, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch'} + - {os: macos-14, label: unsloth, marker: '\[TAURI:STEP\] Installing Unsloth'} + - {os: macos-14, label: setup, marker: '\[TAURI:STEP\] Running Unsloth setup'} + # Other dependency-pass sub-steps around the named one. No pip-bootstrap cell for + # the same reason as venv: "1/10 pip bootstrap" is over before a poll can see it, + # so in both 30419729244 and 30424366953 the signal landed in "2/10 unsloth + # extras", which is the next cell down. + # No base-packages cell: --local sets skip_base, so install_python_stack returns + # before "base packages" ever prints -- that leg ran to completion, proving nothing. + - {os: macos-14, label: unsloth-extras, marker: 'unsloth extras'} + - {os: macos-14, label: data-designer, marker: 'data designer deps'} + # Linux: same teardown path, different package manager and process semantics. + - {os: ubuntu-latest, label: studio-deps, marker: 'studio deps'} + - {os: ubuntu-latest, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch'} + + 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. Without it install.sh:3996 resolves + # `unsloth>=2026.7.5` from PyPI, so the venv gets the PUBLISHED CLI, every + # `verify-install` / `desktop-runtime-check` probe reports "absent" whatever the + # branch does, and the lane cannot observe the fix it tests. --local overlays the + # checkout editable (install.sh:3990) before 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: The kill must have landed where it was aimed + run: | + . logs/interrupt.env + echo "reason=$interrupt_reason killed=$interrupt_killed exit=$installer_exit" + if [ "$interrupt_reason" != "marker-hit" ]; then + echo "::error::installer never reached '${{ matrix.marker }}' (reason=$interrupt_reason)." + echo "::error::This leg proves nothing. Without this check it passes via the" + echo "::error::NO_CLI 'safe' path, which is how a --tauri/UNSLOTH_STUDIO_HOME" + echo "::error::conflict once made all 11 POSIX legs vacuously green." + tail -30 logs/install.log || true + exit 1 + fi + # reason alone is not proof: the installer can finish between the driver's + # post-delay liveness check and the signal, leaving reason=marker-hit over a + # COMPLETED install that probes HEALTHY and skips the re-run assertion below. The + # exit status separates them: SIGTERM takes install.sh's trap to 143 + # (install.sh:716) and SIGKILL to 137, while only a completed install exits 0. + if [ "$installer_exit" = "0" ]; then + echo "::error::installer exited 0 -- it COMPLETED inside the kill window, so" + echo "::error::nothing was interrupted and this leg asserts nothing." + tail -30 logs/install.log || true + exit 1 + fi + # ...and it must have died from OUR signal rather than on its own. The installer + # can also fail naturally in the same window -- a dependency error exits 1 -- and + # that leg would test a broken installer while claiming to test an interrupted + # one. Recording whether `kill` returned 0 does not separate them: the leader is + # still an unreaped member of its own group, so signalling the group succeeds + # even when every process in it is already a zombie. The exit status does: every + # POSIX leg of staging run 30424366953 reported 143. + case "$installer_exit" in + 143|137) ;; + *) + echo "::error::installer exited $installer_exit, which is neither SIGTERM" + echo "::error::(143, install.sh's trap at install.sh:716) nor SIGKILL (137)." + echo "::error::It died on its own, so this leg interrupted nothing." + tail -30 logs/install.log || true + exit 1 + ;; + esac + # ...and the signal must land in the phase this leg is NAMED for. Warning-only + # left staging run 30419729244 fully green with the venv leg's install.log + # byte-identical to the torch leg's, and both "studio deps" legs killed past + # structlog (their own probe artefacts report backend_ok=true), so the flagship + # cell never reproduced the bug it is named after. + if [ "$interrupt_phase_mismatch" = "true" ]; then + echo "::error::the kill landed in a LATER phase than '${{ matrix.marker }}', so this" + echo "::error::leg duplicates whichever leg owns that phase and its label lies." + tr '\r' '\n' < logs/install.log | grep -aE '^\[TAURI:STEP\]|\[[=-]+\] *[0-9]+/[0-9]+' || true + exit 1 + fi + + - name: What state is the install in? + id: probe + run: | + # --tauri refuses a custom UNSLOTH_STUDIO_HOME, so it installs here. + BIN="$HOME/.unsloth/studio/unsloth_studio/bin/unsloth" + [ -x "$BIN" ] || BIN="$HOME/.unsloth/studio/bin/unsloth" + if [ ! -x "$BIN" ]; then + # No CLI at all is SAFE: preflight reports NotInstalled, the app reinstalls. + 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 + # NO_CLI included: a kill at torch lands before "Installing Unsloth" + # (install.sh:2125 / :3667 / :3961), so those legs always take NO_CLI and skipping + # the re-run left three of them asserting only that a marker appeared. The bug's second half is `install.sh` seeing a "current" version and + # no-opping over a broken venv. HEALTHY also needs the install reported complete, + # so the data-designer leg (killed before the manifest is written last, + # install_python_stack.py:3255) arrives here instead of skipping that assertion. + if: always() && 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" + BIN="$HOME/.unsloth/studio/unsloth_studio/bin/unsloth" + [ -x "$BIN" ] || BIN="$HOME/.unsloth/studio/bin/unsloth" + # No verdict.json when the bin is missing, so check here or json.load crashes. + if [ ! -x "$BIN" ]; then + echo "::error::after a full re-run there is still no unsloth CLI at $BIN" + tail -30 logs/repair.log || true + exit 1 + fi + 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'])")" + # A booting backend IS the repair, whatever the log narrated: judging by log + # text failed a leg whose venv was fine, matching only the frontend's "up to date". + if [ "$v" = "HEALTHY" ]; then + echo "re-run repaired the install (verdict=HEALTHY)" + exit 0 + fi + echo "::error::after a full re-run the backend still does not boot (verdict=$v)" + if grep -qiE "(venv|dependenc|python stack)[^|]*(up to date|already current)" logs/repair.log; then + echo "::error::and the re-run treated the venv as current instead of repairing it" + fi + exit 1 + + - 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 }} + # No UNSLOTH_STUDIO_HOME here. The app scrubs it (install.rs:202, :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: UNSLOTH_TAURI_MODE=0, frontend build + # on, different root resolution, no bundled-file overlay. Worse, "Installing PyTorch" + # is only printed by Write-TauriLog (install.ps1:2440), so the torch leg's marker could + # never appear. The runner is ephemeral, so the default root is safe to install into. + runs-on: windows-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + # install.ps1:121 parses `--no-torch`; `-SkipTorch` matches no case there and is + # silently dropped. The torch leg must NOT skip torch or its marker never appears. + - {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 + 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 '${{ matrix.installArgs }}' -KillAtSeconds 1500 + + - name: The kill must have landed where it was aimed + shell: pwsh + run: | + $vals = @{} + foreach ($line in (Get-Content logs/interrupt.env)) { + $kv = $line -split '=', 2 + if ($kv.Count -eq 2) { $vals[$kv[0]] = $kv[1] } + } + Write-Host "reason=$($vals['interrupt_reason']) killed=$($vals['interrupt_killed']) root_killed=$($vals['interrupt_root_killed']) exit=$($vals['installer_exit'])" + if ($vals['interrupt_reason'] -ne 'marker-hit') { + Write-Host "::error::installer never reached '${{ matrix.marker }}' (reason=$($vals['interrupt_reason']))." + Write-Host '::error::This leg proves nothing: without this check it passes via the' + Write-Host '::error::probe NO_CLI safe path, exactly as the POSIX legs once did.' + Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue + exit 1 + } + # Same guard as the POSIX leg: the installer can finish between the driver's + # post-delay HasExited check and Stop-Tree, leaving reason=marker-hit over a + # COMPLETED install that probes HEALTHY and skips the re-run assertion. + # Stop-Process -Force is non-zero, so only a completed install reports 0. + # 'running' too, not just 0: the driver reports that when the installer outlived + # Stop-Tree and WaitForExit, and a live installer writing into the venv under the + # probe is not an interrupted install either. Only a real non-zero code counts. + if ($vals['installer_exit'] -eq '0' -or $vals['installer_exit'] -notmatch '^-?\d+$') { + Write-Host "::error::installer exit=$($vals['installer_exit']) -- it completed or never" + Write-Host '::error::died inside the kill window, so this leg asserts nothing.' + Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue + exit 1 + } + # ...and the signal has to have been DELIVERED. A non-zero code is weaker proof + # here than on POSIX, where only a signal produces 143/137: install.ps1 failing + # on its own also exits non-zero, so a natural failure landing between the + # driver's last HasExited check and Stop-Tree would otherwise read as a kill. + # Stop-Process throws on a process that is already gone, so the driver records + # false exactly when it found nothing left to interrupt. + if ($vals['interrupt_root_killed'] -ne 'true') { + Write-Host '::error::the driver never terminated the installer -- it was already' + Write-Host '::error::gone when Stop-Tree reached it, so it failed on its own and' + Write-Host '::error::this leg interrupted nothing.' + Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue + exit 1 + } + # Same landing check as the POSIX leg: a phase already over when the poll saw the + # marker means the kill hit a LATER phase, so the leg duplicates another one. + if ($vals['interrupt_phase_mismatch'] -eq 'true') { + Write-Host "::error::the kill landed in a LATER phase than '${{ matrix.marker }}', so this" + Write-Host '::error::leg duplicates whichever leg owns that phase and its label lies.' + ((Get-Content logs/install.log -Raw) -replace "`r", "`n") -split "`n" | + Where-Object { $_ -match '^\[TAURI:STEP\]' -or $_ -match '\[[=-]+\]\s*\d+/\d+\s' } + exit 1 + } + + - name: What state is the install in? + id: probe + shell: pwsh + run: | + # --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)' + exit 0 + } + # The SAME probe the other platforms run. The bespoke inline version it replaced + # checked only `-h` and `desktop-capabilities`, so it could not observe + # studio_install_ok / verify-install / desktop-runtime-check and would have + # failed the very PRs that add them. + python .github/scripts/interrupted_install_probe.py $bin --out probe + $rc = $LASTEXITCODE + $v = (Get-Content probe/verdict.json -Raw | ConvertFrom-Json).verdict + "verdict=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + exit $rc + + - name: A re-run must repair, not short-circuit + # Same assertion the POSIX legs make, NO_CLI included: without it a Windows leg + # proves only that the break was DETECTED, never that install.ps1's version fast + # path does not short-circuit over it, the half of the bug that strands the user. + if: always() && steps.probe.outputs.verdict != 'HEALTHY' + shell: pwsh + run: | + # powershell.exe with install.rs:325-339's flags, matching the interrupted run: + # the desktop repairs under Windows PowerShell 5.1, so a repair that only works + # under pwsh 7 would pass here and still strand the user. + powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden ` + -ExecutionPolicy Bypass -File install.ps1 ${{ matrix.installArgs }} *>&1 | + Tee-Object -FilePath logs/repair.log + $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 + exit 1 + } + python .github/scripts/interrupted_install_probe.py $bin --out probe-after + $v = (Get-Content probe-after/verdict.json -Raw | ConvertFrom-Json).verdict + # A booting backend IS the repair, whatever the log narrated: judging by log text + # failed a POSIX leg whose venv was fine, matching only the frontend's "up to date". + if ($v -eq 'HEALTHY') { + Write-Host 're-run repaired the install (verdict=HEALTHY)' + exit 0 + } + Write-Host "::error::after a full re-run the backend still does not boot (verdict=$v)" + $log = Get-Content logs/repair.log -Raw -ErrorAction SilentlyContinue + if ($log -match '(?i)(venv|dependenc|python stack)[^|]*(up to date|already current)') { + Write-Host '::error::and the re-run treated the venv as current instead of repairing it' + } + exit 1 + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: interrupted-windows-${{ matrix.label }} + path: | + logs/ + probe/ + probe-after/ + retention-days: 7 + if-no-files-found: warn