unsloth/.github/scripts/interrupted_install_probe.py
Daniel Han b35bdcbea3
CI: prove an interrupted install can never masquerade as a healthy one (#7552)
* CI: prove an interrupted install can never masquerade as a healthy one

Nothing in CI had ever interrupted an install, which is how the reported failure
shipped: quit the desktop app mid-install, the app SIGTERMs the installer process
group, and if that lands during 'studio deps' the venv loses structlog. Preflight
then probes 'unsloth -h' and 'studio desktop-capabilities', both of which succeed
because the CLI's own deps are core, so the app reported ManagedReady with
can_auto_repair=false while the backend died on import. A permanent dead end.

This kills the installer at each interesting phase and asserts the result is
either genuinely healthy or explicitly repairable, never silently ready. 13 legs
across macos-14, ubuntu-latest and windows: each of the dependency-pass steps
plus the coarse phases (venv, torch, unsloth, setup).

The kill targets the process GROUP, matching install.rs. Killing only the leader
leaves uv and python children to finish the dependency pass, and the test would
quietly prove nothing. Windows has no process groups, so that leg walks the CIM
parent links instead, which is the same reason the app carries windows_job.rs.

One shared probe for all platforms. The Windows check used to be bespoke inline
PowerShell that only ran -h and desktop-capabilities, so it could not observe
studio_install_ok, verify-install or desktop-runtime-check: it would have
reported FALSE_READY for the very PRs that add them, no matter how well they
worked. The probe boots the backend as ground truth and owns the whole process
tree, since terminating only the parent leaves children holding the port.

install.sh runs with --local, which is load-bearing rather than a convenience:
without it the installer resolves unsloth from PyPI and the venv gets the
PUBLISHED CLI, so no branch-side change is present and every deeper probe reports
'absent' regardless of what the branch does.

Verified: against a tree without the detection, windows kill@studio-deps reports
FALSE_READY, reproducing the user report exactly. With #7492 merged the same leg
reports REPAIRABLE, and all 13 legs pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make the POSIX legs actually run the installer, and fail if they do not

install.sh --tauri rejects a custom UNSLOTH_STUDIO_HOME outright (the desktop app
still uses the legacy ~/.unsloth/studio root), and this workflow set one at
workflow level for every job. So all 11 macOS and Linux legs exited about a second
in with

    ERROR: UNSLOTH_STUDIO_HOME is not supported with --tauri.

produced no CLI, took the probe's NO_CLI 'safe' branch and reported success. They
were vacuously green. Only the two Windows legs were real, because install.ps1 has
no equivalent guard.

The override now applies to the Windows job only, and the POSIX legs read the
legacy root, which is where --tauri installs. The runner is ephemeral so the real
home is as disposable as the override.

Also adds the check that makes this class of mistake loud: a leg asserts its kill
actually landed on the marker it was aimed at, using the interrupt_reason the
driver already records. A leg that never reached its kill point proves nothing,
and NO_CLI made that indistinguishable from a pass.

* Make the interrupted-install legs able to fail

The probe treated a present .desktop-install-in-progress marker as proof of a
repairable state, but the drivers seed it unconditionally and never clear it, so
REPAIRABLE was unconditional and FALSE_READY unreachable. The Windows leg had no
kill-landed guard, blanket continue-on-error, and no repair re-run; -SkipTorch
was silently dropped, since install.ps1 parses only --no-torch.

Judge the re-run by whether the backend boots, on both platforms. The log grep
matched the frontend build printing "up to date" and failed a leg whose venv
was fine.

* Drop the interrupt cell that could never be interrupted

install.sh --local sets skip_base, so install_python_stack returns before any
"base packages" label is printed. The kill had nothing to land on and the
installer ran to completion, reaching [TAURI:DONE] in 62s.

* Fail the leg when the installer finished instead of being killed

The driver set reason=marker-hit before the post-marker sleep and never
rechecked, so a step whose work was already cached could run to completion
inside that beat and still be recorded as an interruption. The landing assertion
tests reason != marker-hit, so a fully completed install passed green having
interrupted nothing. Reproduced with a stub that exits during the delay:
reported marker-hit / killed=true / exit=0 next to "install finished fully".
Set the reason after the sleep, on both drivers.

Also trigger on _studio_deps.py and install_manifest.py, where the two decisions
the probe asserts on are actually implemented.

* Kill the group, and stop the probe blocking on a full pipe

The escalation was gated on the leader still being alive, so a leader that exits
promptly on SIGTERM while a uv or python descendant ignores it skipped the
SIGKILL entirely, and wait reaped only the leader. Proven with a descendant that
traps TERM: pre-fix its heartbeat keeps ticking while the probe would be running,
post-fix it stops. Signal the group unconditionally and drain it after the reap,
since an unreaped leader is still a member of its own group.

The probe started the backend on stdout=PIPE and read nothing until after the
poll loop, so a backend logging more than the pipe buffer during import blocked
before binding. Measured 65536 bytes here; a child emitting 200 KB never reaches
its bind line, which would make backend_ok false for a healthy install. Write
straight to the artefact file.

Also trigger on studio/backend/requirements/**, where structlog is declared.

* Make the NO_CLI legs assert repair, and fix the Windows straggler sweep

Two of the interrupted-install legs were passing without testing anything.

The re-run assertion skipped verdict=NO_CLI, but a kill at "venv" or "torch"
lands before install.sh ever prints "Installing Unsloth" (:2125, :3667, :3961),
so those legs can only ever produce NO_CLI. Three non-gating-exempt cells
(macos-14 kill@venv, macos-14 kill@torch, ubuntu-latest kill@torch) therefore
asserted nothing beyond a marker appearing in a log. NO_CLI is now included:
a re-run must produce a booting backend regardless of how little the first run
managed to install. Each re-run step grows an existence check first, because
the probe exits without writing verdict.json when the binary is absent and the
json.load would crash rather than report.

The Windows straggler sweep matched nothing at all. UNSLOTH_STUDIO_HOME arrives
as D:\a\r\r/.studio-home, since the workflow joins ${{ github.workspace }} with
a forward slash, while Process.Path is all backslashes, so the literal -like
missed even the venv's own python.exe. uv is never under the studio home in any
case: install.ps1 takes it from winget or astral.sh. Normalise the separators,
match uv by name (the runner is ephemeral and runs no other uv), and skip the
home comparison entirely when the variable is empty, which would otherwise turn
the pattern into "**" and kill every python on the runner.

* 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.

* Judge the install the way preflight does, and reap the whole probe group

Read desktop-capabilities the way the desktop reads it. preflight/managed.rs
pipes stdout and sends stderr to /dev/null (managed.rs:358), then hands the
whole stdout buffer to serde_json (managed.rs:414). The probe concatenated both
streams and scanned to the first brace, so a single diagnostic line on stderr
made json.loads raise on the trailing text, studio_install_ok stayed "absent",
and a broken backend was reported FALSE_READY over an install the real app
parses, sees as incomplete, and offers to repair. That fails a valid recovery
change for a reason that exists only in the probe. stdout and stderr are now
captured separately and stdout is parsed strictly; a payload that does not parse
counts as repair evidence, matching the Stale the desktop reports when the
capability probe returns nothing (managed.rs:521).

A booting backend alone is not a finished install. The manifest is written last
(install_python_stack.py:3255), so a kill after "studio deps" but before it, the
data-designer leg, leaves a venv whose backend boots while desktop-capabilities
still reports studio_install_ok=false and preflight reports Stale
(managed.rs:445). Calling that HEALTHY skipped the re-run step, so the leg
asserted nothing beyond a marker appearing and never exercised the version fast
path that is supposed to clear an incomplete install, which is the half of the
bug that strands the user. HEALTHY now requires both.

Escalate to the process group after reaping the probe's backend. reap() returned
as soon as proc.wait() succeeded, and the leader exits promptly on SIGTERM while
a uvicorn worker does not, so the SIGKILL iteration was skipped and that worker
kept the port and the venv open while the repair step reinstalled underneath it.
It also read os.getpgid(proc.pid) after the reap, which raises. The pgid is now
captured up front and SIGKILL always goes to the group, the same escalation
interrupt-install.sh:94 makes. A heartbeat experiment left the group alive with
the old sequence and empty with the new one.

Trigger the workflow on pyproject.toml. Every leg installs the checkout with
--local, so that file decides the unsloth console script and the core
dependencies the probe leans on: -h and desktop-capabilities only survive a torn
install because typer/click/rich are declared there. No other install workflow
interrupts the installer, so such a change would otherwise merge without a
single leg running.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Judge an absent capability field and a dead -h the way preflight does

The probe left studio_install_ok=absent undecided and judged those installs on
whether the backend booted. preflight/managed.rs:445 tests studio_install_ok
!= Some(true), so an absent field is Stale exactly like a false one; a CLI too
old to carry it is already rejected one check earlier on
desktop_manageability_version. The gap mattered in both directions: a payload
that stopped carrying the field reported HEALTHY on every booting leg and
skipped the re-run assertion this workflow exists to make, and a torn venv with
a working -h was failed as FALSE_READY even though the app would have offered
repair. unsloth_cli/commands/studio.py is in this workflow's path filter
precisely to catch that class of change, so it must not be the thing that
silences it.

The verdict also consulted cli_h_ok only in the repairable arm, so a CLI that
cannot print help was called HEALTHY whenever the backend happened to boot.
probe_managed_bin runs -h first and returns Stale cli_unusable before it ever
reaches the capability probe (managed.rs:465-478), so that install goes to
repair in the real app and the leg must assert it here.

* Judge the probes on the desktop's deadline, and interrupt the host it uses

Preflight gives each managed probe ten seconds and nothing more: managed.rs:337
wraps `unsloth -h` and managed.rs:390 wraps `studio desktop-capabilities --json`
in a tokio timeout, kills the child on expiry, and returns Stale as
"cli_unusable" or "desktop_capability_probe_failed". The probe allowed three
minutes, so a venv torn badly enough that its CLI only answers after half a
minute of retries was recorded HEALTHY here while the real app shows it as
repairable. That skips the re-run assertion the leg exists to make, which is the
same false-HEALTHY hole the studio_install_ok and -h gating already closed. Both
calls now use the desktop's ten seconds, and the elapsed time is recorded so a
leg that flips for timing reasons says so in the artefact.

On Windows the installer child now runs where the desktop runs it. install.rs
325-339 spawns the bundled install.ps1 as powershell.exe with -NoLogo -NoProfile
-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File, so Windows
PowerShell 5.1 is the only host a real desktop install ever uses. The interrupted
run and the repair re-run both used pwsh 7, and every other Windows job in
.github runs install.ps1 under pwsh too, so the installer's behaviour on 5.1 was
covered by nothing: .NET Framework instead of .NET, OEM console encoding instead
of UTF-8, and different native-command and OSArchitecture reporting are all real
sources of divergence. A workflow whose point is to reproduce what the app does
cannot run a different interpreter than the app does. The driver itself stays
under pwsh; only the installer child and the repair invocation change.

* Tighten the interrupted-install comments

* Tighten the probe docstrings

* Fail the leg when the installer completed inside the kill window

* Land the kill in the marked step, and reject non-boolean capabilities

Two holes found from the staging run's own logs.

The venv leg never interrupted the venv step. Creating the venv takes ~0.1s, so
by the time the 1s poll noticed its line the installer was already in
"Installing PyTorch", and the flat 3s beat sent the signal there: staging run
30419729244 shows both step lines in the tail and a kill 4s in. That made the
leg a duplicate of the torch leg while its label claimed otherwise. Both
drivers now poll in half-second slices, cut the beat short the moment a later
[TAURI:STEP] line appears, and print the step the signal actually landed in,
warning when it is not the marked one. Sub-step markers such as "studio deps"
print no step line, so they keep the whole beat and never warn.

studio_install_ok is Option<bool> (managed.rs:43), so serde rejects a
non-boolean and the whole payload fails to deserialize, which the desktop
reports as Stale. bool() read a JSON string "false" as True, so the probe
called a torn install ready. Only a literal JSON true counts now.

* Tighten the interrupt driver comments

* Fail the leg when the signal landed after the marked step

The cut-short added last round only helps when the marked step is still the
last [TAURI:STEP] line at the moment the poll notices it. Creating the venv
takes ~0.1s (staging run 30419729244: 03:31:07.371 -> 07.478), less than the
0.5s poll, so the next step's line is usually already in the log when the
marker matches, the step count never changes during the beat, and the full 3s
elapses inside "Installing PyTorch". Reproduced with a stub installer against
the driver at head: kill at 4.11s, step at kill "Installing PyTorch". The leg
then duplicates the torch leg while its matrix label claims the venv step, and
passed green on nothing but a :⚠️:.

Both drivers now skip the beat entirely when the marked step is already over,
so the kill goes out at once instead of deeper into the next step, and both
record interrupt_step_mismatch in interrupt.env. The landing assertion fails on
it: a warning that cannot fail the leg proves nothing. Sub-step markers
("studio deps", "pip bootstrap") print no step line of their own and stay
exempt, as before.

The venv leg becomes experimental. Its step is shorter than any log poll can
resolve, so it must not block the PR on a race it cannot win, and it still
probes the earliest torn state whenever it does land.

* Tighten the interrupted-install workflow and driver comments

* Land the kill in the phase each leg is named for

Splitting the log on \r shows that 5 of the 12 legs of staging run 30419729244
interrupted a later phase than their label claims, and the run was fully green.
The venv leg's install.log is byte-identical to the torch leg's. So is
pip-bootstrap's to unsloth-extras'. Worse, both "studio deps" legs, the cells
that reproduce the reported bug, were killed at "7/10 data designer deps" and
"12/14 local plugin": their own probe artefacts report backend_ok=true, so
structlog was installed and the flagship cell was passing on the manifest gate
alone.

Two causes. The dependency pass rewrites ONE physical line with \r
(install_python_stack.py:2499), so its sub-steps are CR-separated segments and
a line-based check could not see one end; the drivers exempted them and warned
about nothing. And the flat 3s beat between the marker and the signal is longer
than several phases, while every phase label prints BEFORE its work starts, so
the beat pushed the signal past the phase instead of into it.

Both drivers now split on \r, track the running phase at both levels, and judge
a sub-step marker against the running sub-step and a step marker against the
running step, so a step is not "over" because the sub-steps beneath it
advanced. The beat defaults to 0 and is set per leg, 3s only for torch,
unsloth and setup, which run for minutes. The mismatch is recorded in
interrupt.env and the landing assertion fails on it, in both languages.

Both detectors were replayed against the 12 real logs from 30419729244 and
agree with the artefacts on every leg. venv and pip-bootstrap become
experimental: their phases are shorter than any log poll can resolve.

* Fail the Windows leg when the installer never exited

The driver writes installer_exit=running when the installer outlived Stop-Tree
and WaitForExit, and 'running' is not '0', so the landing assertion accepted it.
A live installer writing into the venv while the probe reads it is not an
interrupted install. Only a real integer exit code counts now, checked against
0, 143, 137, -1, running and the empty string.

* Drop the legs that cannot land, and prove the kill was delivered

Two cells never interrupted the phase they were named for. "Creating virtual
environment" runs 0.107s (staging 30419729244, 03:31:07.371 -> 07.478) and
"1/10 pip bootstrap" is over just as fast, both shorter than any poll that
watches the log, so in 30423181897 and 30424366953 the signal landed in
"Installing PyTorch" and "2/10 unsloth extras" every time. Each was another
leg wearing a false label, so they are gone rather than allowed to fail, and
continue-on-error goes with them: a leg permitted to fail asserts nothing.
The only coverage lost is a venv caught half-written, which interruption
cannot reach at this resolution; the torch leg's signal lands ~3s into a
multi-minute download, so it already leaves a complete venv with nothing
installed into it.

The landing check also accepted an installer that failed on its own. A
dependency error between the driver's last liveness check and the signal
exits non-zero, which the exit != 0 guard let through as a kill. POSIX now
requires 143 or 137, the only statuses a signal produces here and what all
ten POSIX legs of 30424366953 reported. Recording whether kill(2) returned 0
would not separate them, since the unreaped leader keeps its group alive.
Windows has no such status, so the driver records whether Stop-Process
actually terminated the installer: it throws on a process already gone, so
the flag is false exactly when there was nothing left to interrupt.

* Signal at the marker, with no beat to overshoot the phase

Staging run 30426111484 failed the macOS torch leg on the landing check: the
3s beat carried the signal from "Installing PyTorch" into "Installing
Unsloth", because the PyTorch step, which this workflow called minutes long,
finished in under three seconds. The beat only ever existed to land
mid-work, and it cannot do that safely: every label prints before its work
starts, so detection is already inside the phase, and any wait is a bet on
how long that phase runs. It lost in 30419729244 and again here.

So the beat is gone rather than retuned, and with it the matrix knob and the
driver parameter on both platforms. The landing check stays and can still
fail, since a phase shorter than one poll is seen only after it ends.

The Windows driver also polled every 500ms while its own comment claimed a
fifth of a second. That is 2.5 slices of overshoot the POSIX side does not
carry, and it is now 200ms like the POSIX loop.

* Kill the installer before its children, not after

The depth-first walk killed the child install.ps1 was waiting on and only
then the root, which races the leader's own reaction to that death. It is
not a theoretical race: in staging run 30424366953 install.ps1 had already
printed "unsloth studio setup failed (exit code -1)" by the time
Stop-Process reached it. A leader that wins the race makes Stop-Process
throw, and the new root-kill assertion would then fail a leg whose
interruption the driver really did deliver.

The tree is now snapshotted first, since a dead parent leaves nothing to
walk, then the root goes down ahead of its descendants. A dead leader cannot
react to a child and cannot respawn one either, which is what the
depth-first order was for.

* CI: give the probe the desktop's startup grace and fail a nonzero repair

The probe allowed the backend 120s to answer /api/health while the desktop
waits 5 minutes (BACKEND_STARTUP_GRACE_PERIOD, commands.rs:9), so a slow but
healthy install could be reported FALSE_READY. A broken backend exits at once
and the poll breaks on it, so the longer deadline only bounds a live backend.

The re-run step also accepted a HEALTHY probe over an installer that exited
nonzero. setup.sh does fallible sidecar and GPU setup after the manifest is
written, and the desktop returns the repair error without starting the backend
(commands.rs:615-630). The Windows leg ignored powershell.exe's status entirely.

* Tighten the interrupted-install comments

Comments only: shorter wording for the same rationale, no code touched.

* CI: raise the interrupted-install job timeout above its own deadlines

A leg configures up to 25 minutes to the marker plus two probe passes of up to
17 minutes each around a repair install, so the 60 minute limit could cancel a
slow runner mid-assertion. Legs land in 6 to 10 minutes in practice.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-07-29 06:27:33 -07:00

313 lines
14 KiB
Python

#!/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), and landing in "studio deps" drops
studio/backend/requirements/studio.txt, where structlog is declared. Preflight 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 while the backend dies on `import structlog`.
ONE implementation for all three platforms. The bespoke inline PowerShell probe it
replaced 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]:
"""(rc, stdout, stderr), kept SEPARATE: preflight pipes stdout and sends stderr to
/dev/null (managed.rs:358), so anything folded in 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 knows the subcommand: older builds lack the verify commands, and
'absent' must not 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")
# The desktop's own grace, BACKEND_STARTUP_GRACE_PERIOD = 5 min (commands.rs:9): less
# fails a leg the app would wait out. It cannot mask the bug -- a missing import kills
# the backend and the loop breaks on proc.poll(), so this only bounds a LIVE backend.
ap.add_argument("--boot-timeout", type = int, default = 300)
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 allows each call 10s
# (managed.rs:337 for `-h`, :390 for desktop-capabilities) then reports Stale
# (managed.rs:471, :521). Longer 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) 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<bool>
# (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 plus 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 rejected one check earlier on
# desktop_manageability_version. Leaving "absent" undecided reported HEALTHY on every
# booting leg and skipped the repair assertion -- the 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, not repair evidence: preflight runs only `-h` and
# `studio desktop-capabilities --json` (managed.rs:357, :445), so counting these would
# pass a leg 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 failing 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
# the 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 with its venv interpreter gone.
# An unguarded spawn raises, so no verdict.json is written and both workflows die on
# json.load. An unlaunchable CLI is a broken backend that `-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 hits 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
# 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:]))