Tighten the interrupted-install workflow and driver comments

This commit is contained in:
Daniel Han 2026-07-29 04:52:17 +00:00
commit cf5d7affad
4 changed files with 131 additions and 176 deletions

View file

@ -4,9 +4,8 @@
# Windows counterpart of interrupt-install.sh: run install.ps1 and kill it partway
# through, reproducing a user quitting the desktop app mid-install.
#
# Windows has no process groups, which is why the app carries windows_job.rs. This script
# kills the whole process TREE for the same reason: killing only the leader leaves
# uv/python children to finish the dependency pass, proving nothing.
# 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' `
@ -25,10 +24,9 @@ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Nu
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 the marker #7490 relies on is
# absent for a reason unrelated to #7490 -- exactly what the Windows legs reported. Both
# locations because the Rust side hardcodes ~/.unsloth/studio while CI overrides
# UNSLOTH_STUDIO_HOME. Never cleared: being killed is the whole point.
# (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 {
@ -37,13 +35,12 @@ foreach ($dir in @($env:UNSLOTH_STUDIO_HOME, (Join-Path $HOME '.unsloth\studio')
} 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: that is the
# only host a real desktop install ever uses, and 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 itself stays under pwsh; only the installer child and the repair re-run
# change.
# 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',
@ -87,18 +84,16 @@ function Test-MarkedStepOver {
$killed = $false
$reason = ''
# Half-second slices: a step lasting under a second is over by the time a 1s poll notices
# its line, and the beat below then cannot help.
# Half-second slices: a sub-second step is over before a 1s poll sees its line.
for ($i = 0; $i -lt ($KillAtSeconds * 2); $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 beat as the POSIX driver, waited in slices and cut short once a later
# [TAURI:STEP] line appears, so a fast step finishing inside the beat does not send
# the signal into the step after it. Skipped when the step is ALREADY over: the
# cut-short cannot help once the next step's line is in the log before the first
# sample, and beating on would push the signal deeper into the following step.
# Same beat as the POSIX driver, in slices and cut short once a later [TAURI:STEP]
# line appears, so a fast step does not send the signal into the step after it.
# Skipped when the step is ALREADY over: the cut-short cannot help once the next
# line is logged, and beating on would push the signal deeper into the next step.
if (-not (Test-MarkedStepOver)) {
$stepsAtMarker = Get-StepCount $LogPath
for ($j = 0; $j -lt ($KillAfterMarkerSeconds * 5); $j++) {
@ -123,12 +118,11 @@ 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 the root install.ps1
# uses then, or the sweep would only ever see uv.
# 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 }
@ -152,10 +146,9 @@ 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"
}
# Which step the signal actually landed in. A sub-second step can be over before any log
# poll notices its line, and the leg then silently kills the NEXT step while its matrix
# label still claims the marked one. Sub-step markers ('studio deps') print no
# [TAURI:STEP] line of their own, so the test skips them rather than warning every leg.
# Where the signal actually landed. A sub-second step can end before any poll sees its
# line, and the leg then kills the NEXT step while its label claims otherwise. Sub-step
# markers print no [TAURI:STEP] line, so the test skips them instead of always warning.
$lastStep = Get-LastStep $LogPath
Write-Host "[interrupt] step at kill: $lastStep"
$mismatch = Test-MarkedStepOver

View file

@ -8,8 +8,7 @@
# would otherwise finish the dep pass and the leg would prove nothing.
#
# Usage: bash .github/scripts/interrupt-install.sh "<marker>" "<logfile>" [-- install args]
# <marker> log regex to wait for before killing, e.g. "studio deps". "" kills at the
# deadline.
# <marker> 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
@ -23,10 +22,9 @@ 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, being killed is the point.
# 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
@ -62,12 +60,10 @@ for i in $(seq 1 $(( KILL_AT_SECONDS * 2 ))); do
if [ -n "$MARKER" ] && grep -qE "$MARKER" "$LOG" 2>/dev/null; then
# A beat into the step so the kill lands mid-work, cut short the moment a later
# [TAURI:STEP] line appears: the venv takes ~0.1s, so a flat 3s sleep put the venv
# leg's signal in "Installing PyTorch", a duplicate of the torch leg. Sub-step
# markers ("studio deps") print no step line, so they keep the whole beat.
# Skipped entirely when the step is ALREADY over: cutting the beat short cannot help
# once the next step's line is in the log before the first sample, which is the normal
# case for a 0.1s step, and beating on would only push the signal deeper into the step
# after it. Kill now and let the mismatch check below report where it landed.
# leg's signal in "Installing PyTorch", a duplicate of the torch leg. Sub-step markers
# ("studio deps") print no step line and keep the whole beat. Skipped when the step is
# ALREADY over: the cut-short cannot help once the next line is logged and beating on
# only pushes the signal deeper, so kill now and let the mismatch check below report.
if ! marked_step_over; then
_steps_at_marker="$(grep -cE '^\[TAURI:STEP\]' "$LOG" 2>/dev/null || true)"
for _ in $(seq 1 $(( ${KILL_AFTER_MARKER_SECONDS:-3} * 5 ))); do

View file

@ -95,9 +95,8 @@ def main(argv: list[str]) -> int:
# ── 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 here would call a slow torn venv HEALTHY
# and skip the re-run assertion. run() reports a timeout as a non-zero rc, landing in
# the same REPAIRABLE arm as 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()
@ -122,12 +121,11 @@ def main(argv: list[str]) -> int:
# 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"
# are split apart 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 a JSON string
# "false" as True and reported HEALTHY over a torn install, skipping the repair
# assertion. Only a literal JSON true counts.
# 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)
@ -147,19 +145,17 @@ def main(argv: list[str]) -> int:
# 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
# this workflow exists to make -- the regression `unsloth_cli/commands/studio.py`
# sits in the path filter to catch, so it must never be what silences it.
# 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) and never these two, so
# counting them would let a leg pass while the real app still reports ManagedReady
# over a torn install -- the false negative this workflow exists to catch.
# `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"]),
@ -172,16 +168,14 @@ def main(argv: list[str]) -> int:
say(label, "ok" if r[0] == 0 else "failed")
# The in-progress marker #7490 writes before spawning the installer. RECORDED ONLY:
# both interrupt drivers seed it and deliberately never clear it, so it is true on
# every leg by construction, and using it in the verdict below would make REPAIRABLE
# unconditional and FALSE_READY -- the one outcome this catches -- unreachable.
# 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 keep
# holding the port and hang the next leg's probe. Same reason the driver kills the
# group.
# 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
@ -189,14 +183,13 @@ def main(argv: list[str]) -> int:
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 on write BEFORE binding the port, and backend_ok
# -- what this verdict pivots on -- would be false for a perfectly good install.
# 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. run() catches that as OSError, but an unguarded spawn here would
# raise, so no verdict.json is written and both workflows die on the json.load rather
# than reporting. An unlaunchable CLI is a broken backend `-h` already flags.
# 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(
@ -232,8 +225,8 @@ def main(argv: list[str]) -> int:
import signal
# start_new_session made this child its own group leader. Read the pgid
# BEFORE the reap: once the leader is waited on, os.getpgid() raises and the
# escalation would target nothing.
# BEFORE the reap: once waited on, os.getpgid() raises and escalation targets
# nothing.
try:
pgid = os.getpgid(proc.pid)
except OSError:
@ -250,9 +243,9 @@ def main(argv: list[str]) -> int:
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 as soon as proc.wait() succeeded left
# that worker holding the port and the venv open while the repair step
# reinstalled underneath it. Signalling an empty group is a no-op.
# 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:
@ -260,9 +253,8 @@ def main(argv: list[str]) -> int:
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 a
# server holding the venv open and the repair reinstalls into locked files.
# taskkill /T takes the tree.
# 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)
@ -289,13 +281,10 @@ def main(argv: list[str]) -> int:
# 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, leaving the leg
# asserting nothing beyond a marker appearing.
#
# `-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 CLI that cannot print help
# HEALTHY whenever the backend booted.
# (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"):

View file

@ -4,16 +4,15 @@
# 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), landing 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".
# (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.
# and asserts the result is genuinely healthy or explicitly repairable, never silently ready.
name: Interrupted install recovery
@ -29,24 +28,21 @@ on:
- '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
# `unsloth` console script and the core deps the probe leans on: `-h` and
# `desktop-capabilities` only survive a torn install because typer/click/rich
# are declared here. Moving one to an extra changes what every interrupted
# venv looks like, and no other install workflow interrupts the installer.
# 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 two decisions the probe asserts
# on, live here rather than in commands/studio.py, so a change making
# install_state() accept a missing manifest would otherwise merge unrun.
# 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.
# 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/**'
# `*` never matches `/` and a literal `-install` follows, so
# `interrupt*-install*` would match the .sh / .ps1 but NOT the underscored
# probe. List all three explicitly rather than rely on a glob.
# `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'
@ -77,21 +73,18 @@ jobs:
# The exact reported case: killed during the step that installs structlog.
- {os: macos-14, label: studio-deps, marker: 'studio deps', experimental: false}
# Coarse phases, earliest to latest -- each leaves a different partial venv.
# venv is experimental because the step is not reliably interruptible: with a
# warm uv cache creating it takes ~0.1s (staging run 30419729244 logged
# 03:31:07.371 -> 07.478 between its step line and "Installing PyTorch"), less
# than the log poll, so the kill often lands in the next step. The landing check
# now FAILS that instead of warning, and this leg must not block the PR on a race
# no log poll can win. It still probes the earliest torn state whenever it lands.
# venv is experimental: with a warm uv cache the step takes ~0.1s (staging run
# 30419729244 logged 03:31:07.371 -> 07.478 between its step line and "Installing
# PyTorch"), under the log poll, so the kill often lands in the next step and the
# landing check FAILS it. It still probes the earliest torn state when it lands.
- {os: macos-14, label: venv, marker: '\[TAURI:STEP\] Creating virtual environment', experimental: true}
- {os: macos-14, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch', experimental: false}
- {os: macos-14, label: unsloth, marker: '\[TAURI:STEP\] Installing Unsloth', experimental: false}
- {os: macos-14, label: setup, marker: '\[TAURI:STEP\] Running Unsloth setup', experimental: false}
# Other dependency-pass steps around the named one.
- {os: macos-14, label: pip-bootstrap, marker: 'pip bootstrap', experimental: false}
# No base-packages cell: --local sets skip_base, so install_python_stack
# returns before any "base packages" label prints and the kill can never
# land. That leg ran to completion instead, proving nothing.
# 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', experimental: true}
- {os: macos-14, label: data-designer, marker: 'data designer deps', experimental: true}
# Linux: same teardown path, different package manager and process semantics.
@ -116,11 +109,10 @@ jobs:
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
# probe of `studio verify-install` / `desktop-runtime-check` reports "absent"
# whatever the branch does, and the lane cannot observe the fix it tests.
# --local overlays the checkout editable (install.sh:3990) before `studio
# setup` runs the dep pass, so a kill at "studio deps" leaves the branch's
# CLI installed.
# `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
@ -136,23 +128,20 @@ jobs:
tail -30 logs/install.log || true
exit 1
fi
# reason alone is not proof: the driver re-checks liveness after the marker
# delay, but the installer can still finish in the window between that check
# and the signal, leaving reason=marker-hit over a COMPLETED install. The probe
# then reads HEALTHY and the re-run assertion below is skipped, so the leg goes
# green having interrupted nothing. The exit status separates the two: SIGTERM
# takes install.sh's trap to 143 (install.sh:716) and SIGKILL to 137, while only
# an install that ran to completion exits 0.
# 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 the signal has to have landed in the step this leg is named after. A
# step whose line is no longer the last one was already over when the poll saw
# it, so the kill hit the NEXT step and the leg silently duplicates another one
# while its label claims otherwise. A warning here proves nothing, so it fails.
# ...and the signal must land in the step this leg is named after. A step whose
# line is no longer the last was already over when the poll saw it, so the kill
# hit the NEXT step and the leg duplicates another one under a false label.
# Sub-step markers ("studio deps") print no [TAURI:STEP] line and are exempt.
if [ "$interrupt_step_mismatch" = "true" ]; then
echo "::error::the signal landed after '${{ matrix.marker }}' finished, so this"
@ -168,8 +157,7 @@ jobs:
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 and the app offers
# a normal install.
# 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
@ -182,15 +170,12 @@ jobs:
- name: A re-run must repair, not short-circuit
# NO_CLI included: a kill at venv or torch lands before "Installing Unsloth"
# (install.sh:2125 / :3667 / :3961), so those legs always take NO_CLI and
# skipping the re-run left three non-experimental legs 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 needs the backend booting AND the install reported complete, so the
# data-designer leg (killed after "studio deps" but before the manifest is
# written last, install_python_stack.py:3255) arrives here rather than skipping
# the one assertion that matters for it.
# (install.sh:2125 / :3667 / :3961), so those legs always take NO_CLI and skipping
# the re-run left three non-experimental legs 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
@ -199,8 +184,7 @@ jobs:
echo "repair exit: $rc"
BIN="$HOME/.unsloth/studio/unsloth_studio/bin/unsloth"
[ -x "$BIN" ] || BIN="$HOME/.unsloth/studio/bin/unsloth"
# The probe writes no verdict.json when the bin is missing, so check here or
# the json.load below crashes instead of reporting.
# 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
@ -208,9 +192,8 @@ jobs:
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: the only match was the frontend
# build printing "up to date".
# 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
@ -236,22 +219,20 @@ jobs:
# ── 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 before invoking the installer as
# `--tauri [--local]` (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.
# 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.
# 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'}
@ -283,20 +264,18 @@ jobs:
Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
# Same guard as the POSIX leg: the driver re-checks HasExited after the marker
# delay, but the installer can still finish between that check and Stop-Tree,
# leaving reason=marker-hit over a COMPLETED install that then probes HEALTHY
# and skips the re-run assertion. Stop-Process -Force terminates with a non-zero
# code, so only an install that ran to completion reports 0.
# 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.
if ($vals['installer_exit'] -eq '0') {
Write-Host '::error::installer exited 0 -- it COMPLETED inside the kill window, so'
Write-Host '::error::nothing was interrupted and this leg asserts nothing.'
Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
# Same landing check as the POSIX leg: a step that was already over when the poll
# saw its line means the kill hit the NEXT step, so the leg duplicates another
# one while its label claims otherwise.
# Same landing check as the POSIX leg: a step already over when the poll saw its
# line means the kill hit the NEXT step, so the leg duplicates another one.
if ($vals['interrupt_step_mismatch'] -eq 'true') {
Write-Host "::error::the signal landed after '${{ matrix.marker }}' finished, so this"
Write-Host '::error::leg interrupted a later step than the one it is named for.'
@ -315,10 +294,10 @@ jobs:
Write-Host '[probe] no unsloth CLI -> preflight reports NotInstalled (safe)'
exit 0
}
# The SAME probe the other platforms run. The bespoke inline version this
# replaced only checked `-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.
# 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
@ -328,8 +307,7 @@ jobs:
- 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.
# 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: |
@ -347,9 +325,8 @@ jobs:
}
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: the only match was the frontend
# build printing "up to date".
# 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