Compare commits

...
Sign in to create a new pull request.

30 commits

Author SHA1 Message Date
Daniel Han
31af36153b Merge remote-tracking branch 'origin/main' into r7552 2026-07-29 07:52:59 +00:00
Daniel Han
4493b14647 Kill the installer before its children, not after
The depth-first walk killed the child install.ps1 was waiting on and only
then the root, which races the leader's own reaction to that death. It is
not a theoretical race: in staging run 30424366953 install.ps1 had already
printed "unsloth studio setup failed (exit code -1)" by the time
Stop-Process reached it. A leader that wins the race makes Stop-Process
throw, and the new root-kill assertion would then fail a leg whose
interruption the driver really did deliver.

The tree is now snapshotted first, since a dead parent leaves nothing to
walk, then the root goes down ahead of its descendants. A dead leader cannot
react to a child and cannot respawn one either, which is what the
depth-first order was for.
2026-07-29 06:11:02 +00:00
Daniel Han
75e5f6a487 Signal at the marker, with no beat to overshoot the phase
Staging run 30426111484 failed the macOS torch leg on the landing check: the
3s beat carried the signal from "Installing PyTorch" into "Installing
Unsloth", because the PyTorch step, which this workflow called minutes long,
finished in under three seconds. The beat only ever existed to land
mid-work, and it cannot do that safely: every label prints before its work
starts, so detection is already inside the phase, and any wait is a bet on
how long that phase runs. It lost in 30419729244 and again here.

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

The Windows driver also polled every 500ms while its own comment claimed a
fifth of a second. That is 2.5 slices of overshoot the POSIX side does not
carry, and it is now 200ms like the POSIX loop.
2026-07-29 05:59:00 +00:00
Daniel Han
e5b40c11cf Drop the legs that cannot land, and prove the kill was delivered
Two cells never interrupted the phase they were named for. "Creating virtual
environment" runs 0.107s (staging 30419729244, 03:31:07.371 -> 07.478) and
"1/10 pip bootstrap" is over just as fast, both shorter than any poll that
watches the log, so in 30423181897 and 30424366953 the signal landed in
"Installing PyTorch" and "2/10 unsloth extras" every time. Each was another
leg wearing a false label, so they are gone rather than allowed to fail, and
continue-on-error goes with them: a leg permitted to fail asserts nothing.
The only coverage lost is a venv caught half-written, which interruption
cannot reach at this resolution; the torch leg's signal lands ~3s into a
multi-minute download, so it already leaves a complete venv with nothing
installed into it.

The landing check also accepted an installer that failed on its own. A
dependency error between the driver's last liveness check and the signal
exits non-zero, which the exit != 0 guard let through as a kill. POSIX now
requires 143 or 137, the only statuses a signal produces here and what all
ten POSIX legs of 30424366953 reported. Recording whether kill(2) returned 0
would not separate them, since the unreaped leader keeps its group alive.
Windows has no such status, so the driver records whether Stop-Process
actually terminated the installer: it throws on a process already gone, so
the flag is false exactly when there was nothing left to interrupt.
2026-07-29 05:47:09 +00:00
Daniel Han
566eabf72e Merge remote-tracking branch 'origin/main' into r7552 2026-07-29 05:38:38 +00:00
Daniel Han
005f7a2b1b Fail the Windows leg when the installer never exited
The driver writes installer_exit=running when the installer outlived Stop-Tree
and WaitForExit, and 'running' is not '0', so the landing assertion accepted it.
A live installer writing into the venv while the probe reads it is not an
interrupted install. Only a real integer exit code counts now, checked against
0, 143, 137, -1, running and the empty string.
2026-07-29 05:11:27 +00:00
Daniel Han
1da8e972c3 Land the kill in the phase each leg is named for
Splitting the log on \r shows that 5 of the 12 legs of staging run 30419729244
interrupted a later phase than their label claims, and the run was fully green.
The venv leg's install.log is byte-identical to the torch leg's. So is
pip-bootstrap's to unsloth-extras'. Worse, both "studio deps" legs, the cells
that reproduce the reported bug, were killed at "7/10 data designer deps" and
"12/14 local plugin": their own probe artefacts report backend_ok=true, so
structlog was installed and the flagship cell was passing on the manifest gate
alone.

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

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

Both detectors were replayed against the 12 real logs from 30419729244 and
agree with the artefacts on every leg. venv and pip-bootstrap become
experimental: their phases are shorter than any log poll can resolve.
2026-07-29 05:10:39 +00:00
Daniel Han
cf5d7affad Tighten the interrupted-install workflow and driver comments 2026-07-29 04:52:17 +00:00
Daniel Han
1892b130ce Fail the leg when the signal landed after the marked step
The cut-short added last round only helps when the marked step is still the
last [TAURI:STEP] line at the moment the poll notices it. Creating the venv
takes ~0.1s (staging run 30419729244: 03:31:07.371 -> 07.478), less than the
0.5s poll, so the next step's line is usually already in the log when the
marker matches, the step count never changes during the beat, and the full 3s
elapses inside "Installing PyTorch". Reproduced with a stub installer against
the driver at head: kill at 4.11s, step at kill "Installing PyTorch". The leg
then duplicates the torch leg while its matrix label claims the venv step, and
passed green on nothing but a :⚠️:.

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

The venv leg becomes experimental. Its step is shorter than any log poll can
resolve, so it must not block the PR on a race it cannot win, and it still
probes the earliest torn state whenever it does land.
2026-07-29 04:44:54 +00:00
Daniel Han
055100bff4 Merge remote-tracking branch 'origin/main' into r7552 2026-07-29 04:18:46 +00:00
Daniel Han
60d4bdcb06 Merge remote-tracking branch 'origin/main' into r7552 2026-07-29 04:16:56 +00:00
Daniel Han
05e417d966 Tighten the interrupt driver comments 2026-07-29 04:06:06 +00:00
Daniel Han
8035be12a3 Land the kill in the marked step, and reject non-boolean capabilities
Two holes found from the staging run's own logs.

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

studio_install_ok is Option<bool> (managed.rs:43), so serde rejects a
non-boolean and the whole payload fails to deserialize, which the desktop
reports as Stale. bool() read a JSON string "false" as True, so the probe
called a torn install ready. Only a literal JSON true counts now.
2026-07-29 04:01:24 +00:00
danielhanchen
52f6b66f52 Fail the leg when the installer completed inside the kill window 2026-07-29 02:46:48 +00:00
danielhanchen
3e4ba77436 Tighten the probe docstrings 2026-07-29 02:28:44 +00:00
danielhanchen
a9ab8aead0 Tighten the interrupted-install comments 2026-07-29 02:27:13 +00:00
Daniel Han
58471e5723 Merge remote-tracking branch 'origin/main' into r7552 2026-07-29 01:53:35 +00:00
danielhanchen
1a8ad4ae06 Judge the probes on the desktop's deadline, and interrupt the host it uses
Preflight gives each managed probe ten seconds and nothing more: managed.rs:337
wraps `unsloth -h` and managed.rs:390 wraps `studio desktop-capabilities --json`
in a tokio timeout, kills the child on expiry, and returns Stale as
"cli_unusable" or "desktop_capability_probe_failed". The probe allowed three
minutes, so a venv torn badly enough that its CLI only answers after half a
minute of retries was recorded HEALTHY here while the real app shows it as
repairable. That skips the re-run assertion the leg exists to make, which is the
same false-HEALTHY hole the studio_install_ok and -h gating already closed. Both
calls now use the desktop's ten seconds, and the elapsed time is recorded so a
leg that flips for timing reasons says so in the artefact.

On Windows the installer child now runs where the desktop runs it. install.rs
325-339 spawns the bundled install.ps1 as powershell.exe with -NoLogo -NoProfile
-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File, so Windows
PowerShell 5.1 is the only host a real desktop install ever uses. The interrupted
run and the repair re-run both used pwsh 7, and every other Windows job in
.github runs install.ps1 under pwsh too, so the installer's behaviour on 5.1 was
covered by nothing: .NET Framework instead of .NET, OEM console encoding instead
of UTF-8, and different native-command and OSArchitecture reporting are all real
sources of divergence. A workflow whose point is to reproduce what the app does
cannot run a different interpreter than the app does. The driver itself stays
under pwsh; only the installer child and the repair invocation change.
2026-07-28 23:49:18 +00:00
danielhanchen
1dfa31ad04 Judge an absent capability field and a dead -h the way preflight does
The probe left studio_install_ok=absent undecided and judged those installs on
whether the backend booted. preflight/managed.rs:445 tests studio_install_ok
!= Some(true), so an absent field is Stale exactly like a false one; a CLI too
old to carry it is already rejected one check earlier on
desktop_manageability_version. The gap mattered in both directions: a payload
that stopped carrying the field reported HEALTHY on every booting leg and
skipped the re-run assertion this workflow exists to make, and a torn venv with
a working -h was failed as FALSE_READY even though the app would have offered
repair. unsloth_cli/commands/studio.py is in this workflow's path filter
precisely to catch that class of change, so it must not be the thing that
silences it.

The verdict also consulted cli_h_ok only in the repairable arm, so a CLI that
cannot print help was called HEALTHY whenever the backend happened to boot.
probe_managed_bin runs -h first and returns Stale cli_unusable before it ever
reaches the capability probe (managed.rs:465-478), so that install goes to
repair in the real app and the leg must assert it here.
2026-07-28 23:18:48 +00:00
pre-commit-ci[bot]
18b7de14e1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-28 22:02:04 +00:00
danielhanchen
d87c998a84 Judge the install the way preflight does, and reap the whole probe group
Read desktop-capabilities the way the desktop reads it. preflight/managed.rs
pipes stdout and sends stderr to /dev/null (managed.rs:358), then hands the
whole stdout buffer to serde_json (managed.rs:414). The probe concatenated both
streams and scanned to the first brace, so a single diagnostic line on stderr
made json.loads raise on the trailing text, studio_install_ok stayed "absent",
and a broken backend was reported FALSE_READY over an install the real app
parses, sees as incomplete, and offers to repair. That fails a valid recovery
change for a reason that exists only in the probe. stdout and stderr are now
captured separately and stdout is parsed strictly; a payload that does not parse
counts as repair evidence, matching the Stale the desktop reports when the
capability probe returns nothing (managed.rs:521).

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

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

Trigger the workflow on pyproject.toml. Every leg installs the checkout with
--local, so that file decides the unsloth console script and the core
dependencies the probe leans on: -h and desktop-capabilities only survive a torn
install because typer/click/rich are declared there. No other install workflow
interrupts the installer, so such a change would otherwise merge without a
single leg running.
2026-07-28 22:01:16 +00:00
Daniel Han
5c65d06b0f Run the Windows legs as the desktop does, and judge repair by what preflight reads
The Windows matrix set a workspace-scoped UNSLOTH_STUDIO_HOME, which forces
install.ps1 down the shell-install path: install.ps1:189-215 rejects a custom
root under --tauri, so those legs ran with UNSLOTH_TAURI_MODE=0, the frontend
build on and no bundled-file overlay, while the desktop always spawns the
installer as --tauri with the variable scrubbed (install.rs:202 and :356). The
torch leg could not even reach its marker: "Installing PyTorch" is printed only
by Write-TauriLog (install.ps1:2440), so it was killed at the deadline. Both
legs now run --tauri --local at the default root, and the probe and re-run
resolve the CLI under %USERPROFILE%\.unsloth\studio.

The probe counted `studio verify-install` and `studio desktop-runtime-check`
failures as proof the app can repair, but preflight/managed.rs runs only `-h`
and `studio desktop-capabilities --json` (:357) and reads studio_install_ok from
that payload (:445); neither deeper command is invoked anywhere under
studio/src-tauri. A leg where capabilities regressed to ready while only those
standalone commands saw the damage would have passed green with the app stuck on
ManagedReady, which is the exact false negative this workflow exists to catch.
They are still run and recorded in verdict.json, just no longer repair evidence.

An interrupted install can leave the console script in place while its venv
interpreter is gone. The probes go through run(), which catches OSError, but the
backend spawn did not, so the probe aborted before writing verdict.json and both
workflows died on the json.load instead of reporting. That state is now recorded
as backend_spawn_error and lands on REPAIRABLE, which is what `-h` failing
already implies.

On win32 the CLI re-spawns the server as a child and waits on it
(unsloth_cli/commands/studio.py:1543), and CREATE_NEW_PROCESS_GROUP does not
make terminate() reach descendants, so the reap left a server holding the venv
open while the repair step reinstalled into files Windows had locked. Use
taskkill /F /T for the tree. The straggler sweep now falls back to the default
studio root, since under --tauri there is no UNSLOTH_STUDIO_HOME to match on.
2026-07-28 20:11:35 +00:00
Daniel Han
3e7fc344bb Make the NO_CLI legs assert repair, and fix the Windows straggler sweep
Two of the interrupted-install legs were passing without testing anything.

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

The Windows straggler sweep matched nothing at all. UNSLOTH_STUDIO_HOME arrives
as D:\a\r\r/.studio-home, since the workflow joins ${{ github.workspace }} with
a forward slash, while Process.Path is all backslashes, so the literal -like
missed even the venv's own python.exe. uv is never under the studio home in any
case: install.ps1 takes it from winget or astral.sh. Normalise the separators,
match uv by name (the runner is ephemeral and runs no other uv), and skip the
home comparison entirely when the variable is empty, which would otherwise turn
the pattern into "**" and kill every python on the runner.
2026-07-28 20:01:10 +00:00
danielhanchen
80fc65dfe3 Kill the group, and stop the probe blocking on a full pipe
The escalation was gated on the leader still being alive, so a leader that exits
promptly on SIGTERM while a uv or python descendant ignores it skipped the
SIGKILL entirely, and wait reaped only the leader. Proven with a descendant that
traps TERM: pre-fix its heartbeat keeps ticking while the probe would be running,
post-fix it stops. Signal the group unconditionally and drain it after the reap,
since an unreaped leader is still a member of its own group.

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

Also trigger on studio/backend/requirements/**, where structlog is declared.
2026-07-28 19:24:53 +00:00
danielhanchen
3100e90453 Fail the leg when the installer finished instead of being killed
The driver set reason=marker-hit before the post-marker sleep and never
rechecked, so a step whose work was already cached could run to completion
inside that beat and still be recorded as an interruption. The landing assertion
tests reason != marker-hit, so a fully completed install passed green having
interrupted nothing. Reproduced with a stub that exits during the delay:
reported marker-hit / killed=true / exit=0 next to "install finished fully".
Set the reason after the sleep, on both drivers.

Also trigger on _studio_deps.py and install_manifest.py, where the two decisions
the probe asserts on are actually implemented.
2026-07-28 15:14:06 +00:00
danielhanchen
987d999688 Drop the interrupt cell that could never be interrupted
install.sh --local sets skip_base, so install_python_stack returns before any
"base packages" label is printed. The kill had nothing to land on and the
installer ran to completion, reaching [TAURI:DONE] in 62s.
2026-07-28 13:57:24 +00:00
danielhanchen
fe846fb43d Make the interrupted-install legs able to fail
The probe treated a present .desktop-install-in-progress marker as proof of a
repairable state, but the drivers seed it unconditionally and never clear it, so
REPAIRABLE was unconditional and FALSE_READY unreachable. The Windows leg had no
kill-landed guard, blanket continue-on-error, and no repair re-run; -SkipTorch
was silently dropped, since install.ps1 parses only --no-torch.

Judge the re-run by whether the backend boots, on both platforms. The log grep
matched the frontend build printing "up to date" and failed a leg whose venv
was fine.
2026-07-28 13:46:26 +00:00
Daniel Han
958052e44f Make the POSIX legs actually run the installer, and fail if they do not
install.sh --tauri rejects a custom UNSLOTH_STUDIO_HOME outright (the desktop app
still uses the legacy ~/.unsloth/studio root), and this workflow set one at
workflow level for every job. So all 11 macOS and Linux legs exited about a second
in with

    ERROR: UNSLOTH_STUDIO_HOME is not supported with --tauri.

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

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

Also adds the check that makes this class of mistake loud: a leg asserts its kill
actually landed on the marker it was aimed at, using the interrupt_reason the
driver already records. A leg that never reached its kill point proves nothing,
and NO_CLI made that indistinguishable from a pass.
2026-07-28 12:13:29 +00:00
pre-commit-ci[bot]
fd571173dc [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-28 12:09:27 +00:00
Daniel Han
6d99729d58 CI: prove an interrupted install can never masquerade as a healthy one
Nothing in CI had ever interrupted an install, which is how the reported failure
shipped: quit the desktop app mid-install, the app SIGTERMs the installer process
group, and if that lands during 'studio deps' the venv loses structlog. Preflight
then probes 'unsloth -h' and 'studio desktop-capabilities', both of which succeed
because the CLI's own deps are core, so the app reported ManagedReady with
can_auto_repair=false while the backend died on import. A permanent dead end.

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

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

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

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

Verified: against a tree without the detection, windows kill@studio-deps reports
FALSE_READY, reproducing the user report exactly. With #7492 merged the same leg
reports REPAIRABLE, and all 13 legs pass.
2026-07-28 12:08:10 +00:00
4 changed files with 1089 additions and 0 deletions

211
.github/scripts/interrupt-install.ps1 vendored Normal file
View file

@ -0,0 +1,211 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# 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 (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' `
# -LogPath logs/install.log -InstallArgs '--tauri --no-torch --local'
[CmdletBinding()]
param(
[string]$Marker = '',
[string]$LogPath = 'logs/install.log',
[string]$InstallArgs = '',
[int]$KillAtSeconds = 900
)
$ErrorActionPreference = 'Continue'
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null
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 #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 {
New-Item -ItemType Directory -Force -Path $dir -ErrorAction Stop | Out-Null
Set-Content -Path (Join-Path $dir '.desktop-install-in-progress') -Value '' -ErrorAction Stop
} 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: 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',
'-ExecutionPolicy', 'Bypass',
'-File', 'install.ps1'
)
if ($InstallArgs) { $argList += $InstallArgs.Split(' ') }
$proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $argList `
-RedirectStandardOutput $LogPath -RedirectStandardError "$LogPath.err" `
-PassThru -NoNewWindow
Write-Host "[interrupt] installer pid=$($proc.Id) marker='$Marker' deadline=${KillAtSeconds}s"
# Proof that the signal was DELIVERED, not merely attempted. The installer can also fail
# on its own between the last HasExited check and Stop-Tree, and a natural failure carries
# a non-zero exit code just like a kill does, so the exit status alone cannot separate the
# two on Windows. Stop-Process throws on a process that has already gone, so this flag is
# false exactly when there was nothing left to interrupt.
$script:rootKilled = $false
function Get-Descendants([int]$RootId) {
# Depth-first, deepest first. CIM gives the parent link Windows does not expose via
# process groups.
$ids = @()
foreach ($k in @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$RootId" -ErrorAction SilentlyContinue)) {
$kid = [int]$k.ProcessId
$ids += Get-Descendants $kid
$ids += $kid
}
return $ids
}
function Stop-Tree([int]$RootId) {
# Snapshot the whole tree BEFORE killing anything. Once a parent is gone its children are
# orphaned and there is no ParentProcessId left to walk, so the walk has to happen first.
$descendants = @(Get-Descendants $RootId)
# Then the ROOT, ahead of its children. install.ps1 watches the child it is waiting on:
# in staging run 30424366953 it had already printed "unsloth studio setup failed (exit
# code -1)" by the time Stop-Process reached it. Killing children first therefore races
# the leader's own exit, and a leader that wins that race makes Stop-Process throw over
# an interruption the driver really did deliver, failing the leg for nothing. Dead first,
# it cannot react to anything, and it cannot respawn what we are about to kill either.
try {
Stop-Process -Id $RootId -Force -ErrorAction Stop
Write-Host "[interrupt] killed installer pid=$RootId"
if ($RootId -eq $proc.Id) { $script:rootKilled = $true }
}
catch { if ($RootId -eq $proc.Id) { Write-Host "[interrupt] installer pid=$RootId was already gone: $_" } }
foreach ($id in $descendants) {
try { Stop-Process -Id $id -Force -ErrorAction Stop; Write-Host "[interrupt] killed pid=$id" }
catch { }
}
}
# A leg can be aimed at either kind of phase the installer prints, and only one of them is
# a line. install.ps1 prints "[TAURI:STEP] <name>" lines, while the dependency pass rewrites
# ONE physical line with \r (install_python_stack.py:2499), so its sub-steps are
# CR-separated SEGMENTS. Splitting on \r is what makes a sub-step's END observable at all.
$SubRe = '\[[=-]+\]\s*\d+/\d+\s'
function Get-PhaseLines([string]$Path) {
$raw = Get-Content -Path $Path -Raw -ErrorAction SilentlyContinue
if (-not $raw) { return @() }
return @(($raw -replace "`r", "`n") -split "`n")
}
function Get-LastPhase([string]$Path) {
$p = @(Get-PhaseLines $Path | Where-Object { $_ -match '^\[TAURI:STEP\]' -or $_ -match $SubRe })
if ($p.Count) { return $p[-1] }
return ''
}
# True when the phase the marker named is no longer the running one. A sub-step marker is
# judged against the running sub-step, a step marker against the running step -- a step is
# not "over" because the sub-steps beneath it advanced.
function Test-MarkedPhaseOver {
if (-not $Marker) { return $false }
$lines = @(Get-PhaseLines $LogPath)
$subs = @($lines | Where-Object { $_ -match $SubRe })
if ($subs | Where-Object { $_ -match $Marker }) {
$last = Get-LastPhase $LogPath
return -not ($last -match $SubRe -and $last -match $Marker)
}
$steps = @($lines | Where-Object { $_ -match '^\[TAURI:STEP\]' })
if ($steps | Where-Object { $_ -match $Marker }) {
return ($steps[-1] -notmatch $Marker)
}
return $false
}
$killed = $false
$reason = ''
# Fifth-of-a-second slices, matching the POSIX driver: every phase label prints BEFORE its
# work starts, so this delay IS the whole distance between the label and the signal, and it
# is the only thing that can push the kill past the end of a short phase. It slept 500ms
# while the comment claimed a fifth, so it carried 2.5 slices of overshoot the POSIX side
# does not.
for ($i = 0; $i -lt ($KillAtSeconds * 5); $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 as the POSIX driver: signal at detection, never after a delay. The label
# prints before the work, so the kill is inside the phase the moment the line
# appears, and any wait is a bet on the phase outlasting it that staging runs
# 30419729244 and 30426111484 both lost.
#
# The installer can still exit on its own between the match and the signal, which
# would record marker-hit over an install that interrupted nothing.
if ($proc.HasExited) { $reason = 'exited-before-signal'; break }
$reason = 'marker-hit'
$killed = $true
break
}
}
Start-Sleep -Milliseconds 200
}
if (-not $killed -and -not $proc.HasExited) { if (-not $reason) { $reason = 'deadline' }; $killed = $true }
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 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 }
else { ($studioRoot -replace '/', '\').TrimEnd('\') }
foreach ($p in @(Get-Process -Name 'uv', 'python', 'pythonw' -ErrorAction SilentlyContinue)) {
$path = $null
try { $path = $p.Path } catch { }
$inHome = $homeNorm -and $path -and ($path -like "$homeNorm\*")
if ($p.ProcessName -eq 'uv' -or $inHome) {
try { Stop-Process -Id $p.Id -Force; Write-Host "[interrupt] swept $($p.ProcessName) pid=$($p.Id)" } catch { }
}
}
}
try { $proc.WaitForExit(30000) | Out-Null } catch { }
$rc = if ($proc.HasExited) { $proc.ExitCode } else { 'running' }
Write-Host "[interrupt] installer exit=$rc reason=$reason killed=$killed root_killed=$($script:rootKilled)"
Write-Host '[interrupt] last log lines:'
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"
}
# Where the signal actually landed. A phase that ended before the poll saw the marker sends
# the kill into a LATER phase, and the leg then duplicates whichever leg owns that phase
# while its own label claims otherwise.
$lastPhase = Get-LastPhase $LogPath
Write-Host "[interrupt] phase at kill: $lastPhase"
$mismatch = Test-MarkedPhaseOver
if ($mismatch) {
Write-Host "::warning::killed in '$lastPhase', not the marked phase -- that phase was already over"
}
# Lower-cased so the workflow can compare it the same way on every platform, and only
# simple values: the POSIX side sources this file.
@(
"interrupt_reason=$reason"
"interrupt_killed=$killed"
"interrupt_root_killed=$(if ($script:rootKilled) { 'true' } else { 'false' })"
"installer_exit=$rc"
"interrupt_phase_mismatch=$(if ($mismatch) { 'true' } else { 'false' })"
) | Set-Content -Path (Join-Path (Split-Path -Parent $LogPath) 'interrupt.env') -Encoding utf8
exit 0

164
.github/scripts/interrupt-install.sh vendored Executable file
View file

@ -0,0 +1,164 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Run install.sh and SIGTERM it partway through, reproducing a user quitting the desktop
# app mid-install: main.rs cleanup_child_processes() -> install::stop_install() kills the
# installer PROCESS GROUP (install.rs:798-807). Group, not leader: `uv`/`python` children
# 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 deadline.
# Env: KILL_AT_SECONDS deadline (default 900), KILL_GRACE grace before SIGKILL (default 10)
set -uo pipefail
MARKER="${1:-}"
LOG="${2:-logs/install.log}"
shift 2 || true
[ "${1:-}" = "--" ] && shift
KILL_AT_SECONDS="${KILL_AT_SECONDS:-900}"
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 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
: > "$_marker_dir/.desktop-install-in-progress" 2>/dev/null || true
done
# Job control puts the child in its own group, so $! is the pgid and `kill -- -$!`
# reaches every descendant, as the Rust side does.
set -m
bash install.sh "$@" > "$LOG" 2>&1 &
PID=$!
set +m
echo "[interrupt] installer pid/pgid=$PID marker='${MARKER}' deadline=${KILL_AT_SECONDS}s"
# A leg can be aimed at either kind of phase the installer prints, and only one of them is
# a line. install.sh prints "[TAURI:STEP] <name>" lines, while the dependency pass rewrites
# ONE physical line with \r (install_python_stack.py:2499), so its ten sub-steps are
# CR-separated SEGMENTS. Splitting on \r is what makes a sub-step's END observable at all:
# without it the "studio deps" leg of staging run 30419729244 killed at "7/10 data designer
# deps" with backend_ok=true, having installed the structlog it exists to remove.
SUB_RE='\[[=-]+\][[:space:]]*[0-9]+/[0-9]+[[:space:]]'
phase_lines() { tr '\r' '\n' < "$LOG" 2>/dev/null || true; }
# True when the phase the marker named is no longer the running one. A sub-step marker is
# judged against the running sub-step, a step marker against the running step -- a step is
# not "over" because the sub-steps beneath it advanced. A marker naming neither is not
# judged. Results go through variables, never a `| grep -q`, which can report SIGPIPE
# through pipefail on a long log.
marked_phase_over() {
[ -n "$MARKER" ] || return 1
local lines steps subs last
lines="$(phase_lines)"
steps="$(printf '%s\n' "$lines" | grep -aE '^\[TAURI:STEP\]')" || true
subs="$(printf '%s\n' "$lines" | grep -aE "$SUB_RE")" || true
if [ -n "$subs" ] && [[ $subs =~ $MARKER ]]; then
last="$(printf '%s\n' "$lines" | grep -aE "^\[TAURI:STEP\]|$SUB_RE" | tail -1)" || true
[[ $last =~ $SUB_RE && $last =~ $MARKER ]] && return 1
return 0
fi
if [ -n "$steps" ] && [[ $steps =~ $MARKER ]]; then
last="$(printf '%s\n' "$steps" | tail -1)"
[[ $last =~ $MARKER ]] && return 1
return 0
fi
return 1
}
killed=false
reason=""
# Fifth-of-a-second slices: every phase label prints BEFORE its work starts, so the poll
# delay is the whole distance between the label and the signal.
for i in $(seq 1 $(( KILL_AT_SECONDS * 5 ))); do
if ! kill -0 "$PID" 2>/dev/null; then
reason="exited-before-marker"
break
fi
if [ -n "$MARKER" ] && grep -qE "$MARKER" "$LOG" 2>/dev/null; then
# Signal at detection, never after a delay. Every label prints BEFORE its work starts,
# so the kill is inside the phase the moment the line appears, and any wait at all is a
# bet on how long that phase runs. The bet loses: a flat 3s wait put 5 of the 12 legs
# of staging run 30419729244 into a LATER phase, and in 30426111484 it carried the
# macOS torch leg from "Installing PyTorch" into "Installing Unsloth" -- a step the
# workflow called minutes long finished in under three seconds.
#
# Between the grep and the signal the installer can still exit on its own, which would
# record marker-hit over an install that interrupted nothing.
if ! kill -0 "$PID" 2>/dev/null; then
reason="exited-before-signal"
break
fi
reason="marker-hit"
killed=true
break
fi
sleep 0.2
done
if [ "$killed" != "true" ] && kill -0 "$PID" 2>/dev/null; then
reason="${reason:-deadline}"
killed=true
fi
if [ "$killed" = "true" ]; then
echo "[interrupt] SIGTERM to process group -$PID ($reason)"
kill -TERM -- -"$PID" 2>/dev/null || kill -TERM "$PID" 2>/dev/null || true
for _ in $(seq 1 "$KILL_GRACE"); do
kill -0 "$PID" 2>/dev/null || break
sleep 1
done
# Unconditional, and to the GROUP: the leader exits on SIGTERM while a uv or python
# descendant does not, and gating on `kill -0 "$PID"` left that child finishing the dep
# pass under the probe. Signalling an empty group is a no-op.
echo "[interrupt] SIGKILL to process group -$PID"
kill -KILL -- -"$PID" 2>/dev/null || kill -KILL "$PID" 2>/dev/null || true
fi
wait "$PID" 2>/dev/null
rc=$?
# Only after the reap: an unreaped leader is still a member of its own group, so this
# poll would report it alive forever. No installer may still run when the probe starts.
if [ "$killed" = "true" ]; then
for _ in $(seq 1 "$KILL_GRACE"); do
kill -0 -- -"$PID" 2>/dev/null || break
kill -KILL -- -"$PID" 2>/dev/null || true
sleep 1
done
if kill -0 -- -"$PID" 2>/dev/null; then
echo "::warning::processes from installer group -$PID outlived SIGKILL"
fi
fi
echo "[interrupt] installer exit=$rc reason=$reason killed=$killed"
echo "[interrupt] last log lines:"
tail -15 "$LOG" || true
# A leg that never reached the target step must be visible, not quietly green.
if [ -n "$MARKER" ] && ! grep -qE "$MARKER" "$LOG" 2>/dev/null; then
echo "::warning::marker '$MARKER' never appeared -- this leg killed at the deadline, not at the intended step"
fi
# Where the signal actually landed. A phase that ended before the poll saw the marker sends
# the kill into a LATER phase, and the leg then duplicates whichever leg owns that phase
# while its own label claims otherwise.
_last_phase="$(phase_lines | grep -aE "^\[TAURI:STEP\]|$SUB_RE" | tail -1)" || true
echo "[interrupt] phase at kill: $_last_phase"
mismatch=false
if marked_phase_over; then
mismatch=true
echo "::warning::killed in '$_last_phase', not the marked phase -- that phase was already over"
fi
# Only simple values: the workflow sources this file, so the phase text stays out of it.
{
echo "interrupt_reason=$reason"
echo "interrupt_killed=$killed"
echo "installer_exit=$rc"
echo "interrupt_phase_mismatch=$mismatch"
} > "$(dirname "$LOG")/interrupt.env"
exit 0

View file

@ -0,0 +1,313 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""After an install is interrupted, decide whether the desktop app WOULD report the
resulting venv as healthy -- reproducing the Tauri preflight probes so the regression
is testable without building the app.
The reported bug: quitting the app during the dependency pass SIGTERMs the installer
(install.rs stop_install). Landing in the "studio deps" step drops
studio/backend/requirements/studio.txt, where structlog is declared. Preflight then
probes `unsloth -h` (managed.rs:419) and `studio desktop-capabilities` (managed.rs:318);
both SUCCEED because typer/click/rich are core, so the app reports ManagedReady with
can_auto_repair=false and the backend dies on `import structlog`.
ONE implementation for all three platforms. The Windows leg was briefly a bespoke inline
PowerShell probe that ran only `-h` and `desktop-capabilities`, so it could not observe
`studio_install_ok`, `verify-install` or `desktop-runtime-check` and would have failed
the very PRs that add them. A probe that cannot see the fix is worse than no probe.
Verdicts:
HEALTHY the backend boots AND desktop-capabilities reports the install
complete -- preflight would report ManagedReady and be right
REPAIRABLE the backend is broken AND a probe the DESKTOP consumes reports it,
so the app can offer a repair
FALSE_READY the backend is broken and every probe says ready -> THE BUG
Exit: 0 for HEALTHY/REPAIRABLE/NO_CLI, 1 for FALSE_READY, 2 for a usage error.
"""
from __future__ import annotations
import argparse
import json
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
def run(cmd: list[str], timeout: int = 120) -> tuple[int, str, str]:
"""Returns (rc, stdout, stderr). Kept SEPARATE: preflight pipes stdout and sends
stderr to /dev/null (managed.rs:358), so anything folded into stdout here is text
the desktop never sees."""
try:
p = subprocess.run(cmd, capture_output = True, text = True, timeout = timeout)
return p.returncode, p.stdout or "", p.stderr or ""
except (subprocess.TimeoutExpired, OSError) as e:
return 127, "", f"{type(e).__name__}: {e}"
def merged(rc_out_err: tuple[int, str, str]) -> str:
"""Both streams, for artefact logs only -- never for parsing."""
return rc_out_err[1] + rc_out_err[2]
def has_subcommand(bin_path: str, args: list[str]) -> bool:
"""Whether the CLI understands a subcommand at all: older builds lack the newer
verify commands, and 'absent' must not be read as 'reported failure'."""
rc, _, _ = run([bin_path, *args, "--help"], timeout = 60)
return rc == 0
def free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description = __doc__)
ap.add_argument("bin", help = "path to the unsloth CLI")
ap.add_argument("--port", type = int, default = 0, help = "0 picks a free port")
ap.add_argument("--out", default = "probe", help = "directory for probe artefacts")
ap.add_argument("--boot-timeout", type = int, default = 120)
a = ap.parse_args(argv)
binp = a.bin
if not Path(binp).exists():
print(f"::error::unsloth bin not found: {binp}")
return 2
out = Path(a.out)
out.mkdir(parents = True, exist_ok = True)
port = a.port or free_port()
facts: dict[str, object] = {}
def say(k: str, v: object) -> None:
facts[k] = v
print(f"[probe] {k:28} = {v}")
# ── the two probes Tauri preflight actually runs ─────────────────────────
# The DESKTOP's deadline, not a generous CI one: preflight 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 would call a slow torn venv HEALTHY and skip
# the re-run assertion; run() reports a timeout as a non-zero rc, the same REPAIRABLE arm.
PREFLIGHT_TIMEOUT = 10
t0 = time.time()
r = run([binp, "-h"], timeout = PREFLIGHT_TIMEOUT)
(out / "cli-h.log").write_text(merged(r), encoding = "utf-8", errors = "replace")
say("cli_h_ok", r[0] == 0)
say("cli_h_seconds", round(time.time() - t0, 2))
t0 = time.time()
caps_rc, caps_out, caps_err = run(
[binp, "studio", "desktop-capabilities", "--json"], timeout = PREFLIGHT_TIMEOUT
)
(out / "desktop-capabilities.json").write_text(caps_out, encoding = "utf-8", errors = "replace")
(out / "desktop-capabilities.stderr.log").write_text(
caps_err, encoding = "utf-8", errors = "replace"
)
say("capabilities_ok", caps_rc == 0)
say("capabilities_seconds", round(time.time() - t0, 2))
# Parse EXACTLY as the desktop does: managed.rs:414 hands the whole stdout buffer to
# serde_json, which rejects leading or trailing non-JSON, and stderr was already
# discarded at managed.rs:358. Folding stderr in made one warning line enough to fail
# the parse and report FALSE_READY over an install the real app offers to repair.
# "absent" (studio_install_ok predates the install-manifest work) and "unparseable"
# split only for a readable artefact: the desktop reports Stale for both
# ("desktop_capability_probe_failed", managed.rs:521). The field is Option<bool>
# (managed.rs:43), so serde rejects a non-boolean and the WHOLE payload fails to
# deserialize -> Stale; bool() instead read the JSON string "false" as True and
# reported HEALTHY over a torn install. Only a literal JSON true counts.
install_ok: object = "absent"
try:
parsed = json.loads(caps_out)
if not isinstance(parsed, dict):
install_ok = "unparseable"
else:
v = parsed.get("studio_install_ok")
if v is None:
install_ok = "absent"
elif isinstance(v, bool):
install_ok = v
else:
install_ok = "non-boolean"
except json.JSONDecodeError:
install_ok = "unparseable"
say("capabilities.studio_install_ok", install_ok)
# The desktop's own conclusion: Ready only on rc 0 + 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 -- 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), 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"]),
):
if not has_subcommand(binp, args):
say(label, "absent")
continue
r = run([binp, *args], timeout = 300)
(out / f"{label}.log").write_text(merged(r), encoding = "utf-8", errors = "replace")
say(label, "ok" if r[0] == 0 else "failed")
# The in-progress marker #7490 writes before spawning the installer. RECORDED ONLY:
# both drivers seed it and never clear it, so it is true on every leg by construction,
# and using it in the verdict would make FALSE_READY, the one outcome, unreachable.
home = Path(os.environ.get("UNSLOTH_STUDIO_HOME") or (Path.home() / ".unsloth" / "studio"))
say("install_in_progress_marker", (home / ".desktop-install-in-progress").exists())
# ── ground truth: does the backend actually boot? ────────────────────────
# Own the whole process tree: the CLI spawns uvicorn/python children that would hold
# the port and hang the next leg's probe. Same reason the driver kills the group.
popen_kw: dict = {}
if os.name == "posix":
popen_kw["start_new_session"] = True
else:
popen_kw["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
# Straight to the artefact file, never a PIPE: nothing drains a pipe until after the
# polling loop, so a backend whose imports outrun the OS buffer (64 KiB on Linux and
# macOS, one page on Windows) blocks BEFORE binding the port and backend_ok, what 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. 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(
[binp, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)],
stdout = blog_fh,
stderr = subprocess.STDOUT,
text = True,
**popen_kw,
)
except OSError as e:
say("backend_spawn_error", f"{type(e).__name__}: {e}")
backend_ok = False
deadline = time.time() + a.boot_timeout
while proc is not None and time.time() < deadline:
if proc.poll() is not None:
break
for path in ("/api/health", "/healthz"):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout = 2) as r:
if r.status == 200:
backend_ok = True
break
except (urllib.error.URLError, OSError, TimeoutError):
pass
if backend_ok:
break
time.sleep(1)
def reap() -> None:
if proc is None:
return
if os.name == "posix":
import signal
# start_new_session made this child its own group leader. Read the pgid
# BEFORE the reap: once waited on, os.getpgid() raises and escalation targets
# nothing.
try:
pgid = os.getpgid(proc.pid)
except OSError:
pgid = proc.pid
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(pgid, sig)
except OSError:
pass
try:
proc.wait(timeout = 10)
break
except subprocess.TimeoutExpired:
continue
# Unconditional, and to the GROUP -- the same escalation
# interrupt-install.sh:94 makes. The leader exits promptly on SIGTERM while a
# uvicorn worker does not, so returning once proc.wait() succeeded left that
# worker holding the port and venv while the repair reinstalled underneath.
# Signalling an empty group is a no-op.
try:
os.killpg(pgid, signal.SIGKILL)
except OSError:
pass
else:
# On win32 the CLI re-spawns the server as a CHILD and waits on it
# (unsloth_cli/commands/studio.py:1543), and CREATE_NEW_PROCESS_GROUP does not
# make terminate() reach descendants, so killing the wrapper alone leaves the
# venv locked against the repair. taskkill /T takes the tree.
run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], timeout = 30)
try:
proc.wait(timeout = 10)
except subprocess.TimeoutExpired:
proc.terminate()
try:
proc.wait(timeout = 10)
except subprocess.TimeoutExpired:
proc.kill()
reap()
blog_fh.close()
blog = blog_path.read_text(encoding = "utf-8", errors = "replace")
say("backend_ok", backend_ok)
missing = ""
for line in blog.splitlines():
if "ModuleNotFoundError" in line:
missing = line.strip()
if missing:
say("backend_error", missing)
# ── verdict ──────────────────────────────────────────────────────────────
# A booting backend is not enough. The manifest is written LAST
# (install_python_stack.py:3255), so the data-designer leg boots while
# desktop-capabilities still says studio_install_ok=false and preflight reports Stale
# (managed.rs:445); calling that HEALTHY skipped the re-run step.
# `-h` gates it for the same reason: probe_managed_bin runs it FIRST and returns Stale
# "cli_unusable" without reaching the capability probe (managed.rs:465-478), so
# consulting cli_h_ok only in the repairable arm called a help-less CLI HEALTHY.
if backend_ok and caps_ready and facts.get("cli_h_ok"):
verdict = "HEALTHY"
elif not caps_ready or not facts.get("cli_h_ok"):
verdict = "REPAIRABLE"
else:
verdict = "FALSE_READY"
facts["verdict"] = verdict
(out / "verdict.json").write_text(json.dumps(facts, indent = 2), encoding = "utf-8")
print(f"[probe] VERDICT = {verdict}")
if verdict == "FALSE_READY":
print(
"::error::Interrupted install reports READY but the backend cannot boot"
f" ({missing or 'import failure'}). Preflight sees -h ok + desktop-capabilities"
" ok, so the app shows ManagedReady with can_auto_repair=false and the user"
" is stuck."
)
return 1
if verdict == "REPAIRABLE":
print("[probe] incomplete install is detectable -> the desktop app can auto-repair")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,401 @@
# 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. No other
# install workflow interrupts the installer.
- 'pyproject.toml'
# 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.
- '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'
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 }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
# Only a marker: the driver signals the moment that line appears, and no leg gets
# to wait first. Every label prints BEFORE its work starts, 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. There is no continue-on-error cell: a leg allowed to
# fail is a warning wearing a red icon, and this workflow's entire claim is that
# a killed install cannot report itself healthy.
#
# The exact reported case: killed during the sub-step that installs structlog.
- {os: macos-14, 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 that watches the log, so the kill landed in the NEXT phase every time it
# was tried (30423181897 and 30424366953 both). It was the torch leg with a
# different label. Lost with it: a venv caught half-written. That state is not
# reachable by interruption at this resolution, and it is the only thing lost --
# the torch leg lands at the top of the PyTorch step, so what it leaves behind is
# already a complete venv with nothing installed into it.
- {os: macos-14, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch'}
- {os: macos-14, label: unsloth, marker: '\[TAURI:STEP\] Installing Unsloth'}
- {os: macos-14, label: setup, marker: '\[TAURI:STEP\] Running Unsloth setup'}
# Other dependency-pass sub-steps around the named one. No pip-bootstrap cell for
# the 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", which is the next cell down.
# 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'}
- {os: macos-14, 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo 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
# 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 it must have died from OUR signal rather than on its own. The installer
# can also 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 does not separate them: the leader is
# still an unreaped member of its own group, so signalling the group succeeds
# even when every process in it is already a zombie. 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 probe 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"
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: 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.
- {label: studio-deps, marker: 'studio deps', installArgs: '--tauri --no-torch --local'}
- {label: torch, marker: 'Installing PyTorch', installArgs: '--tauri --local'}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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
# 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.
# 'running' too, not just 0: 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 that is 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
$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