unsloth/docker/unsloth_llama_update.sh
Daniel Han 837b09122e docker: close five failure paths the review found
Notebook sync, in-place publish. entrypoint.sh runs sync_notebooks and then
execs the container command, so the detached refresh child is still copying
while JupyterLab serves the same tree. cp -a writes through the destination
inode, so a reader can catch half-written JSON and a save made after the
recorded-hash check is destroyed and then recorded as pristine. Publish through
a same-dir dot-prefixed temp plus an atomic rename, and re-read the hash once
the staging copy is complete (the earlier check sits before middle_unchanged, a
python subprocess, so the window was most of the loop). A single-file bind mount
cannot be renamed over, so that path falls back to the previous copy.

Notebook sync, first boot. A pre-existing file whose bytes already match the
baked template fell through to cp -a, which is --preserve=all: as root that
stamps root:root, the baked mode and the build mtime onto a bind-mounted host
file and locks its owner out of editing it. Record it as managed instead. The
hash is identical, so the state file is byte-for-byte what the copy wrote.

unsloth-studio-update. The post-update import check only warned, then the
default restart replaced a process that was serving fine with one known not to
import. supervisord retries startretries times, lands in FATAL and never leaves
it on its own, so the container serves nothing until someone execs in. Keep the
running service and exit non-zero with the remedy.

unsloth-llama-update --check. resolve_latest swallows every failure into an
empty string, which fell into the "up to date" branch and exited 0, so the
command reported a state it could not observe. Report UNKNOWN and fail.

unsloth-llama-update rollback. The in-place restore iterates the backup's
entries, so a file the new release introduced survives it and the restored tree
is mixed-version; ggml dlopens every libggml-*.so next to the binaries. Clear
the install dir before restoring, gated on the drain having completed, because
before that an entry there can still be the only copy of an old file.

docker-publish ref freeze. git ls-remote exits 0 whether or not a ref matched,
so a non-zero exit means the remote was never reached. That exit was lost twice
over: first element of a pipeline, and a run step with no explicit shell runs
under bash -e without pipefail. The step exited 0 and published ref=main, which
the amd64, arm64 and Studio builds each resolve again, so one multi-arch tag
could carry different revisions. Fail the prepare job instead, keeping the
passthrough for the reachable-but-no-match case it was written for.

Jupyter output select. lastPointerOutput was only replaced by another
pointer-down, but J/K/arrow cell navigation fires none, so Ctrl/Cmd+A on a later
cell selected the previously clicked output and suppressed notebook:select-all;
after a re-run the node is detached and the chord did nothing at all. Revalidate
the remembered output (still in the document, still in the active cell) before
using it as the fallback.

Tests: four static guards in test_docker_nb_sync_race.py, a new behavioural
test_docker_update_helpers.py driving both helpers with stub pip, supervisorctl
and mv, a new test_docker_publish_ref_freeze.py that executes each resolver step
under bash -e with a failing ls-remote, and a source check in
validate_studio_features.py. Each fails against the code before this change; the
interrupted-drain case also fails against the unconditional form of the rollback
fix.
2026-07-27 14:02:16 +00:00

222 lines
9.3 KiB
Bash
Executable file

#!/usr/bin/env bash
# Update the baked llama.cpp prebuilt in place, inside a running container,
# without pulling a new image. Downloads the newest portable llama.cpp bundle
# (the same target-pinned, sha256-verified bundle the image is built with) and
# atomically swaps it into $UNSLOTH_LLAMA_CPP_PATH, so the next GGUF export /
# model load uses it.
#
# docker exec <container> unsloth-llama-update # latest release
# docker exec <container> unsloth-llama-update --tag b9773-mix-1f1aaa4
# docker exec <container> unsloth-llama-update --check # report only, no download
#
# This reuses the build-time fetcher, which resolves the latest release via the
# GitHub /releases/latest redirect (no API token, not rate-limited) and installs
# the portable CUDA bundle that runs on CPU and every supported GPU. That makes
# it work the same in a CPU-only or a --gpus container, unlike the host-probing
# installer behind the in-app banner.
#
# Persistence: unmounted, the swap lands in the container's writable layer
# (survives docker restart). To keep it across a full recreate, mount the dir
# on a named volume (-v unsloth_llama:/opt/unsloth/llama.cpp); the updater
# detects the mount and swaps the bundle contents inside the volume.
set -euo pipefail
INSTALL_DIR="${UNSLOTH_LLAMA_CPP_PATH:-/opt/unsloth/llama.cpp}"
STUDIO_HOME="${UNSLOTH_STUDIO_HOME:-/opt/unsloth-studio}"
FETCHER="${UNSLOTH_LLAMA_FETCHER:-/usr/local/lib/unsloth/fetch_llama_prebuilt.py}"
REPO="unslothai/llama.cpp"
TAG="latest"
CHECK_ONLY=0
usage() { sed -n '2,21p' "$0"; }
while [ $# -gt 0 ]; do
case "$1" in
--tag) TAG="$2"; shift 2;;
--install-dir) INSTALL_DIR="$2"; shift 2;;
--check) CHECK_ONLY=1; shift;;
-h|--help) usage; exit 0;;
*) echo "unsloth-llama-update: unknown argument: $1" >&2; usage; exit 2;;
esac
done
[ -f "$FETCHER" ] || { echo "unsloth-llama-update: fetcher not found at $FETCHER" >&2; exit 1; }
# Any python works (the fetcher is stdlib-only); prefer the Studio venv, then base.
PY=""
for cand in \
"$STUDIO_HOME/unsloth_studio/bin/python" \
/opt/unsloth-venv/bin/python \
python3 python; do
command -v "$cand" >/dev/null 2>&1 && { PY="$cand"; break; }
[ -x "$cand" ] && { PY="$cand"; break; }
done
[ -n "$PY" ] || { echo "unsloth-llama-update: no python found" >&2; exit 1; }
# amd64 -> linux-x64-cuda12 portable; arm64 -> linux-arm64-cuda13 portable.
case "$(uname -m)" in
x86_64|amd64) ARCH="amd64";;
aarch64|arm64) ARCH="arm64";;
*) echo "unsloth-llama-update: unsupported arch $(uname -m)" >&2; exit 1;;
esac
installed_tag() {
"$PY" - "$INSTALL_DIR" <<'PY' 2>/dev/null || echo "unknown"
import json, os, sys
p = os.path.join(sys.argv[1], "UNSLOTH_PREBUILT_INFO.json")
try:
d = json.load(open(p)); print(d.get("tag") or d.get("release_tag") or d.get("upstream_tag") or "unknown")
except Exception:
print("unknown")
PY
}
resolve_latest() {
"$PY" - "$FETCHER" "$REPO" <<'PY' 2>/dev/null || echo ""
import importlib.util, sys
spec = importlib.util.spec_from_file_location("flp", sys.argv[1])
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
print(m.resolve_latest_tag(sys.argv[2]))
PY
}
CUR="$(installed_tag)"
echo "[llama-update] install dir: $INSTALL_DIR"
echo "[llama-update] installed: $CUR"
if [ "$CHECK_ONLY" = "1" ]; then
LATEST="$(resolve_latest)"
echo "[llama-update] latest: ${LATEST:-unknown}"
# resolve_latest swallows every failure into "" (line 75), so an empty value
# means the lookup did not happen -- no network, proxy, GitHub down. Printing
# "up to date" there is the one answer --check must never give: it reports a
# state it could not observe. Say unknown and exit non-zero instead.
if [ -z "$LATEST" ]; then
echo "[llama-update] could not reach the release feed; update status UNKNOWN" >&2
echo "[llama-update] (retry once the container has network access)" >&2
exit 1
fi
if [ "$LATEST" != "$CUR" ]; then
echo "[llama-update] an update is available (run without --check to apply)"
else
echo "[llama-update] up to date"
fi
exit 0
fi
# Fetch into a sibling temp dir (same filesystem as INSTALL_DIR, so the swap is
# an atomic rename), then swap. On any failure the existing install is untouched.
parent="$(dirname "$INSTALL_DIR")"
# A named volume mounted AT the install dir can't be renamed (EBUSY), so the
# whole-dir swap below would fail; detect the mount and swap the CONTENTS inside
# the tree. UNSLOTH_LLAMA_UPDATE_IN_PLACE=1/0 overrides autodetection.
IN_PLACE="${UNSLOTH_LLAMA_UPDATE_IN_PLACE:-}"
if [ -z "$IN_PLACE" ]; then
IN_PLACE=0
if command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$INSTALL_DIR" 2>/dev/null; then
IN_PLACE=1
elif [ "$(stat -c %d "$INSTALL_DIR" 2>/dev/null)" != "$(stat -c %d "$parent" 2>/dev/null)" ]; then
IN_PLACE=1 # filesystem boundary at the dir = a volume without mountpoint(1)
fi
fi
if [ "$IN_PLACE" = "1" ]; then
# Keep every move inside the mounted filesystem: work + backup live UNDER
# the install dir so each swap step is a same-fs rename within the volume.
work="$(mktemp -d "$INSTALL_DIR/.llamaupd.XXXXXX")"
backup="$INSTALL_DIR/.old.$$"
else
work="$(mktemp -d "$parent/.llamaupd.XXXXXX")"
backup="${INSTALL_DIR}.old.$$"
fi
swap_done=0
drained=0
# The exit handler must never delete $backup while it's the ONLY copy: restore the
# old tree first, remove it only after the new tree is active. Signal traps run
# the EXIT trap on HUP/INT/TERM too.
cleanup() {
if [ "$swap_done" -ne 1 ]; then
if [ "$IN_PLACE" = "1" ]; then
# Contents-swap restore. Every old entry lives in exactly one of
# $backup / $INSTALL_DIR, so a same-named entry in the install dir is a
# half-moved NEW one: drop it, then move the old one back.
if [ -d "$backup" ]; then
_restore_fail=0
# The per-name loop below only sees entries the OLD tree had, so a
# file the new release introduced survives it and the "restored"
# dir ends up mixed-version -- ggml dlopens every libggml-*.so it
# finds next to the binaries. Once the drain finished, every
# remaining entry is a half-moved NEW one, so clear them all.
# Gated on "drained": before the drain completes an entry here can
# still be the ONLY copy of an old one, and deleting it loses data.
if [ "$drained" = "1" ]; then
find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 \
! -path "$work" ! -path "$backup" \
-exec rm -rf {} + 2>/dev/null || true
fi
for _e in "$backup"/* "$backup"/.[!.]* "$backup"/..?*; do
{ [ -e "$_e" ] || [ -L "$_e" ]; } || continue
_b="$(basename "$_e")"
if [ -e "$INSTALL_DIR/$_b" ] || [ -L "$INSTALL_DIR/$_b" ]; then
rm -rf "${INSTALL_DIR:?}/$_b" 2>/dev/null || true
fi
mv "$_e" "$INSTALL_DIR/" 2>/dev/null || _restore_fail=1
done
if [ "$_restore_fail" -eq 0 ]; then
rmdir "$backup" 2>/dev/null || true
else
echo "[llama-update] CRITICAL: restore failed; previous install preserved at $backup" >&2
fi
fi
elif [ ! -e "$INSTALL_DIR" ] && [ -e "$backup" ]; then
if ! mv "$backup" "$INSTALL_DIR" 2>/dev/null; then
echo "[llama-update] CRITICAL: restore failed; previous install preserved at $backup" >&2
fi
fi
fi
rm -rf "$work" 2>/dev/null || true
if [ "$swap_done" = "1" ]; then
rm -rf "$backup" 2>/dev/null || true
fi
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
new="$work/llama.cpp"
echo "[llama-update] fetching llama.cpp '$TAG' ($ARCH portable) ..."
"$PY" "$FETCHER" "$TAG" "$ARCH" "$new"
# Preserve the Studio ownership marker so setup.sh keeps recognising the dir.
[ -e "$INSTALL_DIR/.unsloth-studio-owned" ] && touch "$new/.unsloth-studio-owned"
echo "[llama-update] swapping into place ..."
if [ "$IN_PLACE" = "1" ]; then
# The install dir is a mount point: swap its CONTENTS (all same-fs renames
# inside the volume). The trap's contents-restore covers any mid-swap abort.
mkdir "$backup"
find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 \
! -path "$work" ! -path "$backup" -exec mv -t "$backup" {} +
# Every old entry now lives in $backup, so from here the trap may clear the
# install dir before restoring. set -e means a failed drain never gets here.
drained=1
if find "$new" -mindepth 1 -maxdepth 1 -exec mv -t "$INSTALL_DIR" {} +; then
swap_done=1
else
echo "[llama-update] swap failed; restoring previous install" >&2
exit 1
fi
else
mv "$INSTALL_DIR" "$backup"
if mv "$new" "$INSTALL_DIR"; then
swap_done=1
else
echo "[llama-update] swap failed; restoring previous install" >&2
mv "$backup" "$INSTALL_DIR"
exit 1
fi
fi
echo "[llama-update] installed now: $(installed_tag)"
echo "[llama-update] done (reload your model / re-run export to use it)"