-
-
Notifications
You must be signed in to change notification settings - Fork 6.8k
440 lines (419 loc) · 24.6 KB
/
Copy pathinterrupted-install-ci.yml
File metadata and controls
440 lines (419 loc) · 24.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# 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) 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.
name: Interrupted install recovery
on:
pull_request:
paths:
- 'install.sh'
- 'install.ps1'
- 'studio/setup.sh'
- 'studio/setup.ps1'
- 'studio/install_python_stack.py'
- 'studio/src-tauri/src/install.rs'
- '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 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.
- 'pyproject.toml'
# studio_install_ok and verify-install, the decisions the probe asserts on, live here,
# so an install_state() accepting 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 every
# interrupted state.
- 'studio/backend/requirements/**'
# `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'
- '.github/workflows/interrupted-install-ci.yml'
- '.github/scripts/retry-with-apt-lock.sh'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
jobs:
# ── macOS + Linux: kill at each phase ─────────────────────────────────────
interrupt:
name: ${{ matrix.os }} kill@${{ matrix.label }}
runs-on: ${{ matrix.os }}
# Above the deadlines this leg actually configures, or a slow runner is cancelled
# mid-assertion and the leg proves nothing: 25 min to the marker
# (KILL_AT_SECONDS=1500), then two probe passes of up to 17 min each (two 60s
# --help checks, two 300s deep checks, a 300s backend wait) around a repair
# install. Legs land in 6-10 min in practice; this is the ceiling, not the target.
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
# Only a marker: the driver signals the moment that line appears, and no leg waits
# first. Every label prints BEFORE its work, so the kill is inside the phase at
# detection, and a delay only bets on how long the phase runs. The bet lost twice,
# both times turning a leg into a duplicate of the next one: a flat 3s wait moved
# 5 of the 12 legs of staging run 30419729244, and in 30426111484 it carried the
# macOS torch leg into "Installing Unsloth" because the PyTorch step, called
# minutes long here, finished in under three seconds.
#
# Every leg is a hard gate, with no continue-on-error cell: a leg allowed to fail
# is a warning wearing a red icon, and this workflow's whole claim is that a
# killed install cannot report itself healthy.
#
# The exact reported case: killed during the sub-step that installs structlog.
- {os: macos-15, label: studio-deps, marker: 'studio deps'}
# Coarse phases, earliest to latest -- each leaves a different partial venv.
# No venv cell: "Creating virtual environment" ran 0.107s in staging run
# 30419729244 (03:31:07.371 -> 07.478 to "Installing PyTorch"), shorter than any
# poll watching the log, so the kill landed in the NEXT phase every time it was
# tried (30423181897 and 30424366953 both) and the leg was the torch leg under a
# different label. Lost with it: a venv caught half-written, not reachable by
# interruption at this resolution, and the only loss -- the torch leg lands at the
# top of the PyTorch step, so it leaves a complete venv with nothing in it.
- {os: macos-15, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch'}
- {os: macos-15, label: unsloth, marker: '\[TAURI:STEP\] Installing Unsloth'}
- {os: macos-15, label: setup, marker: '\[TAURI:STEP\] Running Unsloth setup'}
# Other dependency-pass sub-steps around the named one. No pip-bootstrap cell, same
# reason as venv: "1/10 pip bootstrap" is over before a poll can see it, so in both
# 30419729244 and 30424366953 the signal landed in "2/10 unsloth extras", the next
# cell down. No base-packages cell either: --local sets skip_base, so
# install_python_stack returns before "base packages" prints and that leg ran to
# completion, proving nothing.
- {os: macos-15, label: unsloth-extras, marker: 'unsloth extras'}
- {os: macos-15, label: data-designer, marker: 'data designer deps'}
# Linux: same teardown path, different package manager and process semantics.
- {os: ubuntu-latest, label: studio-deps, marker: 'studio deps'}
- {os: ubuntu-latest, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch'}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Linux system deps
if: runner.os == 'Linux'
# Bounded and retried through the shared helper: an unbounded apt step does
# not fail, it spends the job's whole budget and is reported as "cancelled"
# with no reason and every later step skipped. update and install go as one
# unit, since retrying the install after a stalled update re-reads the same
# broken package list.
# Two long attempts, not three short ones. 150s killed apt mid-`update`
# against a mirror that was degraded rather than dead, and every attempt
# then hit the same wall -- three kills and no result. The bound exists to
# stop an infinite hang, not to race a slow mirror.
timeout-minutes: 15
env:
RETRY_ATTEMPTS: '2'
RETRY_ATTEMPT_TIMEOUT: '360'
run: |
bash .github/scripts/retry-with-apt-lock.sh sudo sh -c \
'apt-get install -y -qq --no-install-recommends cmake git build-essential libcurl4-openssl-dev || { apt-get update -qq && apt-get install -y -qq --no-install-recommends cmake git build-essential libcurl4-openssl-dev; }'
- name: Install, interrupted at "${{ matrix.label }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KILL_AT_SECONDS: '1500'
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 `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
- name: The kill must have landed where it was aimed
run: |
. logs/interrupt.env
echo "reason=$interrupt_reason killed=$interrupt_killed exit=$installer_exit"
if [ "$interrupt_reason" != "marker-hit" ]; then
echo "::error::installer never reached '${{ matrix.marker }}' (reason=$interrupt_reason)."
echo "::error::This leg proves nothing. Without this check it passes via the"
echo "::error::NO_CLI 'safe' path, which is how a --tauri/UNSLOTH_STUDIO_HOME"
echo "::error::conflict once made all 11 POSIX legs vacuously green."
tail -30 logs/install.log || true
exit 1
fi
# reason alone is not proof: the installer can finish between the driver's
# liveness re-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 it must have died from OUR signal, not on its own. The installer can fail
# naturally in the same window -- a dependency error exits 1 -- and that leg would
# test a broken installer while claiming to test an interrupted one. Recording
# whether `kill` returned 0 cannot separate them: the leader is still an unreaped
# member of its own group, so `kill -- -$PID` succeeds over an all-zombie group.
# The exit status does: every POSIX leg of staging run 30424366953 reported 143.
case "$installer_exit" in
143|137) ;;
*)
echo "::error::installer exited $installer_exit, which is neither SIGTERM"
echo "::error::(143, install.sh's trap at install.sh:716) nor SIGKILL (137)."
echo "::error::It died on its own, so this leg interrupted nothing."
tail -30 logs/install.log || true
exit 1
;;
esac
# ...and the signal must land in the phase this leg is NAMED for. Warning-only left
# staging run 30419729244 fully green with the venv leg's install.log
# byte-identical to the torch leg's, and both "studio deps" legs killed past
# structlog (their own artefacts report backend_ok=true), so the flagship cell
# never reproduced the bug it is named after.
if [ "$interrupt_phase_mismatch" = "true" ]; then
echo "::error::the kill landed in a LATER phase than '${{ matrix.marker }}', so this"
echo "::error::leg duplicates whichever leg owns that phase and its label lies."
tr '\r' '\n' < logs/install.log | grep -aE '^\[TAURI:STEP\]|\[[=-]+\] *[0-9]+/[0-9]+' || true
exit 1
fi
- name: What state is the install in?
id: probe
run: |
# --tauri refuses a custom UNSLOTH_STUDIO_HOME, so it installs here.
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, the app reinstalls.
echo "verdict=NO_CLI" >> "$GITHUB_OUTPUT"
echo "[probe] no unsloth CLI installed -> preflight reports NotInstalled (safe)"
exit 0
fi
rc=0
python3 .github/scripts/interrupted_install_probe.py "$BIN" --out probe || rc=$?
v="$(python3 -c "import json;print(json.load(open('probe/verdict.json'))['verdict'])")"
echo "verdict=$v" >> "$GITHUB_OUTPUT"
exit "$rc"
- name: A re-run must repair, not short-circuit
# NO_CLI included: a kill at torch lands before "Installing Unsloth"
# (install.sh:2125 / :3667 / :3961), so those legs always take NO_CLI and skipping
# the re-run left three of them 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
rc=0
bash install.sh --tauri --local < /dev/null 2>&1 | tee logs/repair.log || rc=$?
echo "repair exit: $rc"
# The installer's own verdict counts, not just the probe's: setup.sh runs fallible
# sidecar and GPU setup AFTER install_python_stack.py writes the manifest, so a
# repair can fail there and still leave a CLI that probes HEALTHY, while the
# desktop returns the repair error and never starts the backend
# (commands.rs:615-630). All 26 POSIX repairs in staging runs 30424366953,
# 30426699078 and 30427378808 exited 0, so only a real failure trips this.
if [ "$rc" != "0" ]; then
echo "::error::the repair installer exited $rc, so the re-run did NOT repair"
echo "::error::the install however healthy the venv looks afterwards."
tail -30 logs/repair.log || true
exit 1
fi
BIN="$HOME/.unsloth/studio/unsloth_studio/bin/unsloth"
[ -x "$BIN" ] || BIN="$HOME/.unsloth/studio/bin/unsloth"
# 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
exit 1
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, matching only the frontend's "up to date".
if [ "$v" = "HEALTHY" ]; then
echo "re-run repaired the install (verdict=HEALTHY)"
exit 0
fi
echo "::error::after a full re-run the backend still does not boot (verdict=$v)"
if grep -qiE "(venv|dependenc|python stack)[^|]*(up to date|already current)" logs/repair.log; then
echo "::error::and the re-run treated the venv as current instead of repairing it"
fi
exit 1
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: interrupted-${{ matrix.os }}-${{ matrix.label }}
path: |
logs/
probe/
probe-after/
retention-days: 7
if-no-files-found: warn
# ── 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 (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: 90
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.
- {label: studio-deps, marker: 'studio deps', installArgs: '--tauri --no-torch --local'}
- {label: torch, marker: 'Installing PyTorch', installArgs: '--tauri --local'}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install, interrupted at "${{ matrix.label }}"
shell: pwsh
run: |
pwsh -NoProfile -File .github/scripts/interrupt-install.ps1 `
-Marker '${{ matrix.marker }}' -LogPath logs/install.log `
-InstallArgs '${{ matrix.installArgs }}' -KillAtSeconds 1500
- name: The kill must have landed where it was aimed
shell: pwsh
run: |
$vals = @{}
foreach ($line in (Get-Content logs/interrupt.env)) {
$kv = $line -split '=', 2
if ($kv.Count -eq 2) { $vals[$kv[0]] = $kv[1] }
}
Write-Host "reason=$($vals['interrupt_reason']) killed=$($vals['interrupt_killed']) root_killed=$($vals['interrupt_root_killed']) exit=$($vals['installer_exit'])"
if ($vals['interrupt_reason'] -ne 'marker-hit') {
Write-Host "::error::installer never reached '${{ matrix.marker }}' (reason=$($vals['interrupt_reason']))."
Write-Host '::error::This leg proves nothing: without this check it passes via the'
Write-Host '::error::probe NO_CLI safe path, exactly as the POSIX legs once did.'
Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
# Same guard as the POSIX leg: the installer can finish between the driver's last
# 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. 'running' is rejected too: the
# driver reports that when the installer outlived Stop-Tree and WaitForExit, and a
# live installer writing into the venv under the probe is not an interrupted
# install either. Only a real non-zero code counts.
if ($vals['installer_exit'] -eq '0' -or $vals['installer_exit'] -notmatch '^-?\d+$') {
Write-Host "::error::installer exit=$($vals['installer_exit']) -- it completed or never"
Write-Host '::error::died inside the kill window, so this leg asserts nothing.'
Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
# ...and the signal has to have been DELIVERED. A non-zero code is weaker proof
# here than on POSIX, where only a signal produces 143/137: install.ps1 failing on
# its own also exits non-zero, so a natural failure landing between the driver's
# last HasExited check and Stop-Tree would otherwise read as a kill. Stop-Process
# throws on a process already gone, so the driver records false exactly when it
# found nothing left to interrupt.
if ($vals['interrupt_root_killed'] -ne 'true') {
Write-Host '::error::the driver never terminated the installer -- it was already'
Write-Host '::error::gone when Stop-Tree reached it, so it failed on its own and'
Write-Host '::error::this leg interrupted nothing.'
Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
# Same landing check as the POSIX leg: a phase already over when the poll saw the
# marker means the kill hit a LATER phase, so the leg duplicates another one.
if ($vals['interrupt_phase_mismatch'] -eq 'true') {
Write-Host "::error::the kill landed in a LATER phase than '${{ matrix.marker }}', so this"
Write-Host '::error::leg duplicates whichever leg owns that phase and its label lies.'
((Get-Content logs/install.log -Raw) -replace "`r", "`n") -split "`n" |
Where-Object { $_ -match '^\[TAURI:STEP\]' -or $_ -match '\[[=-]+\]\s*\d+/\d+\s' }
exit 1
}
- name: What state is the install in?
id: probe
shell: pwsh
run: |
# --tauri refuses a custom root, so this is where install.ps1:254-262 puts it.
$bin = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\unsloth.exe'
if (-not (Test-Path $bin)) {
"verdict=NO_CLI" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
Write-Host '[probe] no unsloth CLI -> preflight reports NotInstalled (safe)'
exit 0
}
# 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
"verdict=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
exit $rc
- 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.
if: always() && steps.probe.outputs.verdict != 'HEALTHY'
shell: pwsh
run: |
# powershell.exe with install.rs:325-339's flags, matching the interrupted run: the
# desktop repairs under Windows PowerShell 5.1, so a repair that only works under
# pwsh 7 would pass here and still strand the user.
powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden `
-ExecutionPolicy Bypass -File install.ps1 ${{ matrix.installArgs }} *>&1 |
Tee-Object -FilePath logs/repair.log
# Same gate as the POSIX leg, and this side ignored the status entirely.
# Tee-Object is a cmdlet, so $LASTEXITCODE still carries powershell.exe's code.
$rc = $LASTEXITCODE
Write-Host "repair exit: $rc"
if ($rc -ne 0) {
Write-Host "::error::the repair installer exited $rc, so the re-run did NOT repair"
Write-Host '::error::the install however healthy the venv looks afterwards.'
Get-Content logs/repair.log -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
$bin = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\unsloth.exe'
if (-not (Test-Path $bin)) {
Write-Host "::error::after a full re-run there is still no unsloth CLI at $bin"
Get-Content logs/repair.log -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
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, matching only the frontend's "up to date".
if ($v -eq 'HEALTHY') {
Write-Host 're-run repaired the install (verdict=HEALTHY)'
exit 0
}
Write-Host "::error::after a full re-run the backend still does not boot (verdict=$v)"
$log = Get-Content logs/repair.log -Raw -ErrorAction SilentlyContinue
if ($log -match '(?i)(venv|dependenc|python stack)[^|]*(up to date|already current)') {
Write-Host '::error::and the re-run treated the venv as current instead of repairing it'
}
exit 1
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: interrupted-windows-${{ matrix.label }}
path: |
logs/
probe/
probe-after/
retention-days: 7
if-no-files-found: warn