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:
parent
3e7fc344bb
commit
5c65d06b0f
3 changed files with 59 additions and 26 deletions
10
.github/scripts/interrupt-install.ps1
vendored
10
.github/scripts/interrupt-install.ps1
vendored
|
|
@ -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 { }
|
||||
|
|
|
|||
53
.github/scripts/interrupted_install_probe.py
vendored
53
.github/scripts/interrupted_install_probe.py
vendored
|
|
@ -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")
|
||||
):
|
||||
|
|
|
|||
22
.github/workflows/interrupted-install-ci.yml
vendored
22
.github/workflows/interrupted-install-ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue