From cf5d7affad7f4d2767865add0e52d7a7a420feb7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 04:52:17 +0000 Subject: [PATCH] Tighten the interrupted-install workflow and driver comments --- .github/scripts/interrupt-install.ps1 | 55 +++---- .github/scripts/interrupt-install.sh | 20 +-- .github/scripts/interrupted_install_probe.py | 75 ++++----- .github/workflows/interrupted-install-ci.yml | 157 ++++++++----------- 4 files changed, 131 insertions(+), 176 deletions(-) diff --git a/.github/scripts/interrupt-install.ps1 b/.github/scripts/interrupt-install.ps1 index 1d8ac91ae1..05e3dc237c 100644 --- a/.github/scripts/interrupt-install.ps1 +++ b/.github/scripts/interrupt-install.ps1 @@ -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 diff --git a/.github/scripts/interrupt-install.sh b/.github/scripts/interrupt-install.sh index f175758540..5a9b052ce5 100755 --- a/.github/scripts/interrupt-install.sh +++ b/.github/scripts/interrupt-install.sh @@ -8,8 +8,7 @@ # would otherwise finish the dep pass and the leg would prove nothing. # # Usage: bash .github/scripts/interrupt-install.sh "" "" [-- install args] -# log regex to wait for before killing, e.g. "studio deps". "" kills at the -# deadline. +# 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 diff --git a/.github/scripts/interrupted_install_probe.py b/.github/scripts/interrupted_install_probe.py index 1926512d08..d302a38d2c 100644 --- a/.github/scripts/interrupted_install_probe.py +++ b/.github/scripts/interrupted_install_probe.py @@ -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 (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 + # (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"): diff --git a/.github/workflows/interrupted-install-ci.yml b/.github/workflows/interrupted-install-ci.yml index a09d44ebe7..50bfc75ddf 100644 --- a/.github/workflows/interrupted-install-ci.yml +++ b/.github/workflows/interrupted-install-ci.yml @@ -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