unsloth/.github/scripts/interrupt-install.ps1

166 lines
8.1 KiB
PowerShell

# 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,
[int]$KillAfterMarkerSeconds = 3
)
$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"
function Stop-Tree([int]$RootId) {
# Depth-first, so a parent cannot respawn a child we already killed. CIM gives the
# parent link Windows does not expose via process groups.
$kids = @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$RootId" -ErrorAction SilentlyContinue)
foreach ($k in $kids) { Stop-Tree ([int]$k.ProcessId) }
try { Stop-Process -Id $RootId -Force -ErrorAction Stop; Write-Host "[interrupt] killed pid=$RootId" }
catch { }
}
function Get-StepCount([string]$Path) {
@(Select-String -Path $Path -Pattern '^\[TAURI:STEP\]' -ErrorAction SilentlyContinue).Count
}
function Get-LastStep([string]$Path) {
$steps = @(Select-String -Path $Path -Pattern '^\[TAURI:STEP\]' -ErrorAction SilentlyContinue)
if ($steps.Count) { return $steps[-1].Line }
return ''
}
# True when the marker names a [TAURI:STEP] line that is no longer the last one: the step
# ended before the poll noticed it. Sub-step markers ('studio deps') print no step line of
# their own, so they are never judged here.
function Test-MarkedStepOver {
if (-not $Marker) { return $false }
$steps = @(Select-String -Path $LogPath -Pattern '^\[TAURI:STEP\]' -ErrorAction SilentlyContinue)
if (-not ($steps | Where-Object { $_.Line -match $Marker })) { return $false }
return ((Get-LastStep $LogPath) -notmatch $Marker)
}
$killed = $false
$reason = ''
# Half-second slices: a sub-second step is over before a 1s poll sees its line.
for ($i = 0; $i -lt ($KillAtSeconds * 2); $i++) {
if ($proc.HasExited) { $reason = 'exited-before-marker'; break }
if ($Marker) {
$hit = Select-String -Path $LogPath -Pattern $Marker -SimpleMatch:$false -ErrorAction SilentlyContinue
if ($hit) {
# Same beat as the POSIX driver, in slices and cut short once a later [TAURI:STEP]
# line appears, so a fast step does not send the signal into the step after it.
# Skipped when the step is ALREADY over: the cut-short cannot help once the next
# line is logged, and beating on would push the signal deeper into the next step.
if (-not (Test-MarkedStepOver)) {
$stepsAtMarker = Get-StepCount $LogPath
for ($j = 0; $j -lt ($KillAfterMarkerSeconds * 5); $j++) {
Start-Sleep -Milliseconds 200
if ($proc.HasExited) { break }
if ((Get-StepCount $LogPath) -ne $stepsAtMarker) { break }
}
}
# The installer can finish inside the delay; recording marker-hit before it
# let a COMPLETED install satisfy the landing assertion and probe HEALTHY.
if ($proc.HasExited) { $reason = 'exited-during-marker-delay'; break }
$reason = 'marker-hit'
$killed = $true
break
}
}
Start-Sleep -Milliseconds 500
}
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"
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 sub-second step can end before any poll sees its
# line, and the leg then kills the NEXT step while its label claims otherwise. Sub-step
# markers print no [TAURI:STEP] line, so the test skips them instead of always warning.
$lastStep = Get-LastStep $LogPath
Write-Host "[interrupt] step at kill: $lastStep"
$mismatch = Test-MarkedStepOver
if ($mismatch) {
Write-Host "::warning::killed in '$lastStep', not the marked step -- that step 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"
"installer_exit=$rc"
"interrupt_step_mismatch=$(if ($mismatch) { 'true' } else { 'false' })"
) | Set-Content -Path (Join-Path (Split-Path -Parent $LogPath) 'interrupt.env') -Encoding utf8
exit 0