Run the Windows legs as the desktop does, and judge repair by what preflight reads

The Windows matrix set a workspace-scoped UNSLOTH_STUDIO_HOME, which forces
install.ps1 down the shell-install path: install.ps1:189-215 rejects a custom
root under --tauri, so those legs ran with UNSLOTH_TAURI_MODE=0, the frontend
build on and no bundled-file overlay, while the desktop always spawns the
installer as --tauri with the variable scrubbed (install.rs:202 and :356). The
torch leg could not even reach its marker: "Installing PyTorch" is printed only
by Write-TauriLog (install.ps1:2440), so it was killed at the deadline. Both
legs now run --tauri --local at the default root, and the probe and re-run
resolve the CLI under %USERPROFILE%\.unsloth\studio.

The probe counted `studio verify-install` and `studio desktop-runtime-check`
failures as proof the app can repair, but preflight/managed.rs runs only `-h`
and `studio desktop-capabilities --json` (:357) and reads studio_install_ok from
that payload (:445); neither deeper command is invoked anywhere under
studio/src-tauri. A leg where capabilities regressed to ready while only those
standalone commands saw the damage would have passed green with the app stuck on
ManagedReady, which is the exact false negative this workflow exists to catch.
They are still run and recorded in verdict.json, just no longer repair evidence.

An interrupted install can leave the console script in place while its venv
interpreter is gone. The probes go through run(), which catches OSError, but the
backend spawn did not, so the probe aborted before writing verdict.json and both
workflows died on the json.load instead of reporting. That state is now recorded
as backend_spawn_error and lands on REPAIRABLE, which is what `-h` failing
already implies.

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 the reap left a server holding the venv
open while the repair step reinstalled into files Windows had locked. Use
taskkill /F /T for the tree. The straggler sweep now falls back to the default
studio root, since under --tauri there is no UNSLOTH_STUDIO_HOME to match on.
This commit is contained in:
Daniel Han 2026-07-28 20:11:35 +00:00
commit 5c65d06b0f
3 changed files with 59 additions and 26 deletions

View file

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

View file

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

View file

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