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

147 lines
6.1 KiB
Bash
Executable file

#!/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"
# 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.
marked_step_over() {
[ -n "$MARKER" ] || return 1
grep -E '^\[TAURI:STEP\]' "$LOG" 2>/dev/null | grep -qE "$MARKER" || return 1
grep -E '^\[TAURI:STEP\]' "$LOG" 2>/dev/null | tail -1 | grep -qE "$MARKER" && return 1
return 0
}
killed=false
reason=""
# Half-second slices: a sub-second step is over before a 1s poll sees its line.
for i in $(seq 1 $(( KILL_AT_SECONDS * 2 ))); 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
# A beat into the step so the kill lands mid-work, cut short the moment a later
# [TAURI:STEP] line appears: the venv takes ~0.1s, so a flat 3s sleep put the venv
# leg's signal in "Installing PyTorch", a duplicate of the torch leg. Sub-step markers
# ("studio deps") print no step line and keep the whole beat. Skipped when the step is
# ALREADY over: the cut-short cannot help once the next line is logged and beating on
# only pushes the signal deeper, so kill now and let the mismatch check below report.
if ! marked_step_over; then
_steps_at_marker="$(grep -cE '^\[TAURI:STEP\]' "$LOG" 2>/dev/null || true)"
for _ in $(seq 1 $(( ${KILL_AFTER_MARKER_SECONDS:-3} * 5 ))); do
sleep 0.2
kill -0 "$PID" 2>/dev/null || break
[ "$(grep -cE '^\[TAURI:STEP\]' "$LOG" 2>/dev/null || true)" = "$_steps_at_marker" ] || break
done
fi
# ...but a cached step can FINISH inside the beat. Recording marker-hit before it
# handed the landing assertion a COMPLETED install that interrupted nothing.
if ! kill -0 "$PID" 2>/dev/null; then
reason="exited-during-marker-delay"
break
fi
reason="marker-hit"
killed=true
break
fi
sleep 0.5
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 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.
_last_step="$(grep -E '^\[TAURI:STEP\]' "$LOG" 2>/dev/null | tail -1)"
echo "[interrupt] step at kill: $_last_step"
mismatch=false
if marked_step_over; then
mismatch=true
echo "::warning::killed in '$_last_step', not the marked step -- that step was already over"
fi
# Only simple values: the workflow sources this file, so the step text stays out of it.
{
echo "interrupt_reason=$reason"
echo "interrupt_killed=$killed"
echo "installer_exit=$rc"
echo "interrupt_step_mismatch=$mismatch"
} > "$(dirname "$LOG")/interrupt.env"
exit 0