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.
122 lines
5.3 KiB
Bash
Executable file
122 lines
5.3 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Update Unsloth Studio in place, inside a running container, without pulling a
|
|
# new image. Updates ONLY the Studio Python packages (the backend code and the
|
|
# pre-built frontend, which ships inside the unsloth wheel) and restarts the
|
|
# Studio service. The torch/CUDA stack is left untouched.
|
|
#
|
|
# docker exec <container> unsloth-studio-update # latest PyPI release
|
|
# docker exec <container> unsloth-studio-update --ref main # latest git main
|
|
# docker exec <container> unsloth-studio-update --with-deps # also update deps
|
|
# docker exec <container> unsloth-studio-update --no-restart # update, restart later
|
|
#
|
|
# Why not `unsloth studio update`: that command re-runs the full installer,
|
|
# which re-probes the host GPU to pick torch wheels. In a CPU-only container
|
|
# (run without --gpus) it finds no GPU and can downgrade torch to CPU/cu126,
|
|
# breaking CUDA. This helper only touches the Studio packages, so it is safe in
|
|
# both GPU and CPU containers.
|
|
#
|
|
# Persistence: the update is written to the container's writable layer, so it
|
|
# survives `docker restart`. To keep it across a full `docker rm` + `docker run`
|
|
# (and to keep your chats/users/models), run Studio with its home on a named
|
|
# volume: -v unsloth_studio_home:/opt/unsloth-studio
|
|
set -euo pipefail
|
|
|
|
STUDIO_HOME="${UNSLOTH_STUDIO_HOME:-/opt/unsloth-studio}"
|
|
REF=""
|
|
ZOO_REF=""
|
|
NO_DEPS="--no-deps"
|
|
RESTART=1
|
|
PACKAGES="unsloth unsloth_zoo"
|
|
|
|
usage() { sed -n '2,21p' "$0"; }
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--ref) REF="$2"; shift 2;;
|
|
--zoo-ref) ZOO_REF="$2"; shift 2;;
|
|
--with-deps) NO_DEPS=""; shift;;
|
|
--no-restart) RESTART=0; shift;;
|
|
--packages) PACKAGES="$2"; shift 2;;
|
|
-h|--help) usage; exit 0;;
|
|
*) echo "unsloth-studio-update: unknown argument: $1" >&2; usage; exit 2;;
|
|
esac
|
|
done
|
|
|
|
# Resolve the Studio venv python. Prefer the venv directly; fall back to
|
|
# following the launcher symlink ($STUDIO_HOME/bin/unsloth -> venv/bin/unsloth).
|
|
PY=""
|
|
for cand in \
|
|
"$STUDIO_HOME/unsloth_studio/bin/python" \
|
|
"$STUDIO_HOME/unsloth_studio/bin/python3"; do
|
|
[ -x "$cand" ] && { PY="$cand"; break; }
|
|
done
|
|
if [ -z "$PY" ] && [ -L "$STUDIO_HOME/bin/unsloth" ]; then
|
|
venv_bin="$(dirname "$(readlink -f "$STUDIO_HOME/bin/unsloth")")"
|
|
[ -x "$venv_bin/python" ] && PY="$venv_bin/python"
|
|
fi
|
|
[ -n "$PY" ] || { echo "unsloth-studio-update: could not find the Studio venv under $STUDIO_HOME" >&2; exit 1; }
|
|
|
|
version_of() { "$PY" -c "from importlib.metadata import version; print(version('unsloth'))" 2>/dev/null || echo "unknown"; }
|
|
|
|
echo "[studio-update] Studio venv: $PY"
|
|
echo "[studio-update] before: unsloth $(version_of)"
|
|
|
|
# Build the package specs. With --ref, install from git so you can track main
|
|
# (or any branch/tag/sha); otherwise take the latest PyPI release.
|
|
if [ -n "$REF" ]; then
|
|
SPECS="git+https://github.com/unslothai/unsloth.git@${REF}#egg=unsloth"
|
|
# unsloth-zoo does NOT track unsloth's tags (different cadence). Use --zoo-ref
|
|
# if given; else the unsloth ref only when the zoo repo has it, falling back to
|
|
# main.
|
|
_zoo_ref="$ZOO_REF"
|
|
if [ -z "$_zoo_ref" ]; then
|
|
if git ls-remote --exit-code https://github.com/unslothai/unsloth-zoo.git \
|
|
"$REF" >/dev/null 2>&1; then
|
|
_zoo_ref="$REF"
|
|
else
|
|
_zoo_ref="main"
|
|
echo "[studio-update] unsloth-zoo has no ref '${REF}'; using zoo main"
|
|
fi
|
|
fi
|
|
SPECS="$SPECS git+https://github.com/unslothai/unsloth-zoo.git@${_zoo_ref}#egg=unsloth_zoo"
|
|
echo "[studio-update] installing from git: unsloth @${REF}, unsloth-zoo @${_zoo_ref}"
|
|
else
|
|
SPECS="$PACKAGES"
|
|
echo "[studio-update] installing latest release of: $PACKAGES"
|
|
fi
|
|
|
|
# shellcheck disable=SC2086
|
|
"$PY" -m pip install -U $NO_DEPS $SPECS
|
|
|
|
echo "[studio-update] after: unsloth $(version_of)"
|
|
|
|
# Sanity: the backend must still import after the swap (a missing --no-deps
|
|
# transitive dep shows up here). Restarting into code that cannot import kills a
|
|
# process that is serving fine and leaves supervisord's studio program in FATAL
|
|
# after startretries, which it never leaves on its own. Keep the running service
|
|
# and fail instead, so the operator can add the dep or roll back with Studio up.
|
|
if ! "$PY" -c "import studio.backend.main" >/dev/null 2>&1; then
|
|
echo "[studio-update] ERROR: 'import studio.backend.main' failed after update." >&2
|
|
echo "[studio-update] A new dependency may be missing. Re-run with --with-deps:" >&2
|
|
echo "[studio-update] unsloth-studio-update --with-deps" >&2
|
|
echo "[studio-update] NOT restarting Studio: the running process keeps serving." >&2
|
|
echo "[studio-update] Once fixed: supervisorctl restart studio" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$RESTART" = "1" ]; then
|
|
SUPCTL="$(command -v supervisorctl || true)"
|
|
[ -n "$SUPCTL" ] || SUPCTL="/opt/unsloth-venv/bin/supervisorctl"
|
|
if [ -x "$SUPCTL" ] && "$SUPCTL" status studio >/dev/null 2>&1; then
|
|
echo "[studio-update] restarting the studio service"
|
|
"$SUPCTL" restart studio
|
|
else
|
|
echo "[studio-update] supervisor not managing 'studio' here; restart Studio yourself"
|
|
echo "[studio-update] (e.g. 'docker restart <container>')"
|
|
fi
|
|
else
|
|
echo "[studio-update] --no-restart: restart Studio to load the update"
|
|
echo "[studio-update] docker exec <container> supervisorctl restart studio"
|
|
fi
|
|
|
|
echo "[studio-update] done"
|