Docker notebook safety hardening and vLLM startup timeout fix

pip shim (docker/unsloth_pip_shim.py):
- Drop protected packages named via a VCS/URL #egg=NAME fragment so
  git+... #egg=torch no longer reinstalls into the baked venv.
- Filter constraint files (-c/--constraint) through the same protected
  package filter as requirement files, so a pinned torch/transformers in
  a constraint cannot downgrade the baked stack during resolution.
- Recursively filter nested -r/-c includes and absolutise their paths so
  the filtered /tmp copy still resolves them and no protected spec deep in
  the include tree slips past the keep list.
- Remove an unused subprocess import.

Notebook environment:
- Scope the transformers-request marker per kernel (UNSLOTH_NB_TF_MARKER
  keyed on the kernel connection-file id) so concurrent notebooks no
  longer read each other's pin.
- Install the IPython startup hook under IPYTHONDIR (set via ENV) so it
  loads for any uid, including docker run --user, not just root.
- unsloth_nb_content_sig.py: only treat a %%capture / %%bash cell as
  install boilerplate when it carries an install command, so substantive
  captured/bash cells are hashed and upstream changes are not skipped.
- unsloth_run.py: clean up the temp dir used to materialise a downloaded
  notebook.
- unsloth_sync_notebooks.sh: honor UNSLOTH_KEEP_DELETED_NOTEBOOKS across
  GitHub refreshes so a deleted notebook is not restored when upstream
  advances.

install_python_stack.py: the --local unsloth-zoo overlay now honors
UNSLOTH_ZOO_REF (default main), matching the install.sh overlay.

synthetic.py: preserve the timeout=None unbounded vLLM startup wait
instead of coercing it to 1200s.
This commit is contained in:
Daniel Han 2026-07-05 13:53:39 +00:00
commit 034fbc9785
8 changed files with 199 additions and 24 deletions

View file

@ -683,11 +683,20 @@ RUN set -eux \
&& ln -sf /opt/unsloth-nb/unsloth_run.py /usr/local/bin/unsloth-run \
&& ln -sf /opt/unsloth-nb/unsloth_sync_notebooks.sh /usr/local/bin/unsloth-sync-notebooks \
&& ln -sf /opt/unsloth-nb/unsloth_nb_content_sig.py /usr/local/bin/unsloth-nb-content-sig \
&& mkdir -p /root/.ipython/profile_default/startup \
&& cp /opt/unsloth-nb/unsloth_ipython_startup.py /root/.ipython/profile_default/startup/00-unsloth-nb.py \
&& mkdir -p /opt/unsloth-nb/ipython/profile_default/startup \
&& cp /opt/unsloth-nb/unsloth_ipython_startup.py /opt/unsloth-nb/ipython/profile_default/startup/00-unsloth-nb.py \
&& chmod -R a+rX /opt/unsloth-nb/ipython \
&& /opt/unsloth-venv/bin/python -c "import sys, glob; sys.path.insert(0, '$SP'); import unsloth_nb_compat; print('nb-compat OK; baked sidecars:', sorted(glob.glob('/opt/unsloth-venv/tf-sidecars/t_*')))"
# Shim dir AHEAD of the venv bin so `!pip`/`!uv` resolve to the shim, not the real tool.
ENV PATH=/opt/unsloth-nb/bin:${PATH}
# Load the notebook startup hook (sidecar activation + %pip/%uv magic re-point)
# for EVERY kernel, whatever uid runs it. IPYTHONDIR (inherited by any user via
# ENV) points IPython at this shared profile, so the hook still loads when the
# container is started with `--user <uid>` and $HOME is not /root -- unlike a
# /root/.ipython startup dir, which only a root kernel reads. Kernel-writable
# state (history.sqlite) still lands under each user's own path, so a read-only
# profile dir is fine.
ENV IPYTHONDIR=/opt/unsloth-nb/ipython
# Pre-clone unslothai/notebooks so JupyterLab opens with the notebooks already
# present (no git clone or wget needed). Baked here as a READ-ONLY template

View file

@ -13,6 +13,27 @@ try:
# `!pip install ...` / `!uv pip install ...` (which inherits this env) gets
# the safe-install behaviour. Unset everywhere else => shim is a passthrough.
os.environ["UNSLOTH_NB_SHIM"] = "1"
# Scope the transformers-request marker to THIS kernel so two notebooks
# running concurrently in the same container (each its own kernel process)
# do not read each other's pin. The pip/uv shim runs as a child of this
# kernel and inherits UNSLOTH_NB_TF_MARKER, so writer (shim) and reader
# (unsloth_nb_compat pre_run_cell hook, same process tree) agree on the
# path. Falls back to the shared default when unset (e.g. `unsloth-run`,
# which drives a single notebook per process).
if not os.environ.get("UNSLOTH_NB_TF_MARKER"):
# A kernel id that is stable for the kernel's lifetime and unique per
# kernel: the ipykernel connection file name, else the kernel PID.
_kid = ""
try:
from ipykernel import get_connection_file # type: ignore
_kid = os.path.splitext(os.path.basename(get_connection_file()))[0]
except Exception:
_kid = ""
_kid = _kid or ("pid-%d" % os.getpid())
os.environ["UNSLOTH_NB_TF_MARKER"] = "/tmp/unsloth_nb/requested_transformers." + _kid
import unsloth_nb_compat
unsloth_nb_compat.register_ipython()

View file

@ -48,15 +48,32 @@ def _text(cell):
return src.replace("\r\n", "\n").replace("\r", "\n")
# Package-manager command fragments that mark a cell as the generated install
# cell rather than substantive tutorial code.
_INSTALL_MARKERS = (
"pip install",
"pip3-autoremove",
"uv pip install",
"conda install",
"apt-get install",
"apt install",
)
def _is_install_code(cell):
if cell.get("cell_type") != "code":
return False
t = _text(cell)
low = t.lower()
if "pip install" in low or "pip3-autoremove" in low:
if any(m in low for m in _INSTALL_MARKERS):
return True
first = t.lstrip().split("\n", 1)[0].strip().lower()
return first.startswith("%%capture") or first.startswith("%%bash")
# A %%capture / %%bash cell is boilerplate ONLY when it also carries an
# install command. A bare %%capture (e.g. wrapping training to silence
# output) or a %%bash cell doing real tutorial setup is substantive: hashing
# it keeps the boot refresh from silently skipping an upstream fix to that
# cell (a false SAME). The install markers above already catch the generated
# install cell, which begins with %%capture.
return False
def _is_boilerplate_md(cell):

View file

@ -21,7 +21,7 @@ are not intercepted -- the driven `unsloth-run` handles those by parsing the
notebook directly.
"""
import os, re, sys, subprocess, tempfile
import os, re, sys, tempfile
REAL = {"pip": "/opt/unsloth-venv/bin/pip", "uv": "/opt/unsloth-venv/bin/uv"}
MARKER = os.environ.get("UNSLOTH_NB_TF_MARKER", "/tmp/unsloth_nb/requested_transformers")
@ -75,6 +75,11 @@ _VALUE_FLAGS = {
# requirements file pulls real requirements. An index-url / find-links /
# constraint / target value is an option, not something to install.
_REQ_FILE_FLAGS = {"-r", "--requirement"}
# Constraint files are not install targets, but pip applies their pins during
# resolution, so a `-c constraints.txt` that pins torch/transformers/etc. can
# still downgrade or reinstall a baked package when another target pulls it in.
# Filter protected packages out of them the same way as requirement files.
_CONSTRAINT_FILE_FLAGS = {"-c", "--constraint"}
def _canon(token):
@ -95,6 +100,15 @@ def _canon(token):
if _dref:
return _dref.group(1).lower().replace("_", "-") or None
if re.match(r"^[a-z]+\+", token) or "://" in token or token.startswith((".", "/")):
# A VCS / URL install can still name a protected package via the legacy
# `#egg=NAME` (or `&egg=NAME`) fragment, e.g.
# `git+https://github.com/unslothai/unsloth.git#egg=unsloth`. Pull that
# name out so _KEEP can drop it; otherwise the shim would exec the URL
# and reinstall a baked package into the venv. A non-protected egg name
# is returned too, but the caller keeps it as a normal target either way.
_egg = re.search(r"[#&]egg=([A-Za-z0-9][A-Za-z0-9._-]*)", token)
if _egg:
return _egg.group(1).lower().replace("_", "-") or None
return None # vcs / url / local path -> let it pass through
# strip extras and any version/marker tail
name = re.split(r"[<>=!~\[\s;@]", token, 1)[0].strip()
@ -107,7 +121,66 @@ def _version_pin(token):
return m.group(1) if m else None
def _filter_requirements_file(path):
def _parse_include(stripped):
"""If `stripped` is an `-r`/`--requirement`/`-c`/`--constraint` include,
return (flag, target_path, inline_comment_or_None); else (None, None, None)."""
body, sep, comment = stripped.partition(" #")
body = body.rstrip()
comment = ("#" + comment) if sep else None
for flag in ("-r", "--requirement", "-c", "--constraint"):
target = None
if body == flag or body.startswith(flag + " "):
target = body[len(flag):].strip()
elif body.startswith(flag + "="):
target = body[len(flag) + 1:].strip()
elif not flag.startswith("--") and body.startswith(flag) and len(body) > len(flag):
target = body[len(flag):].strip() # attached short form, e.g. `-rextras.txt`
else:
continue
return flag, (target or None), comment
return None, None, None
def _rewrite_include(line, stripped, src_dir, depth):
"""Rewrite a nested `-r`/`-c` include so pip still resolves it and its
protected specs are filtered too.
pip resolves a nested include against the directory of the file it is
READING; our filtered copy lives under /tmp, so a relative include would
look in /tmp and fail. Recursively filter the included file (dropping
protected packages there too, closing the multi-level bypass) and point the
parent at that filtered copy. URLs and unreadable/absolute-unfiltered files
fall back to an absolutised path so they still resolve. Returns
(new_line, changed, recorded, dropped)."""
flag, target, comment = _parse_include(stripped)
if not target:
return line, False, None, []
newline_char = "\n" if line.endswith("\n") else ""
def _emit(new_target):
rebuilt = flag + " " + new_target
if comment:
rebuilt += " " + comment
return rebuilt + newline_char
# A URL include cannot be filtered locally; leave it verbatim.
if "://" in target:
return line, False, None, []
abs_target = target if os.path.isabs(target) else os.path.join(src_dir, target)
# Recursively filter the included file. Guard against cyclic / deep includes.
if depth < 8:
f_path, f_rec, f_drp = _filter_requirements_file(abs_target, _depth = depth + 1)
if f_path != abs_target:
# The include was rewritten (protected specs dropped and/or its own
# nested includes absolutised); point at the filtered copy.
return _emit(f_path), True, f_rec, f_drp
# Nothing to filter inside; just make sure the path still resolves from /tmp.
if not os.path.isabs(target):
return _emit(abs_target), True, None, []
return line, False, None, []
def _filter_requirements_file(path, _depth = 0):
"""Strip baked/protected packages out of a `-r` requirements file.
Returns (path_to_use, recorded_transformers_version, dropped_specs). The same
@ -115,19 +188,35 @@ def _filter_requirements_file(path):
line, so a notebook `pip install -r reqs.txt` cannot overwrite the cu128 torch
/ vLLM / transformers stack with versions pinned inside the file. When nothing
is protected, or the file cannot be read/written, the original path is returned
unchanged. Comments, blank lines, option lines and nested `-r`/`-c` includes are
kept verbatim (nested includes are passed through, i.e. filtered one level).
unchanged. Comments, blank lines and option lines are kept verbatim; a nested
`-r`/`-c` include is recursively filtered too (protected specs dropped at every
level).
"""
try:
with open(path, encoding = "utf-8") as f:
lines = f.readlines()
except OSError:
return path, None, [] # remote URL / unreadable -> let the real tool handle it
src_dir = os.path.dirname(os.path.abspath(path))
out, dropped, recorded, changed = [], [], None, False
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith(("#", "-")):
out.append(line) # comment / blank / option / nested include -> keep
if not stripped or stripped.startswith("#"):
out.append(line) # comment / blank -> keep
continue
if stripped.startswith("-"):
# Option or nested include. Recursively filter a nested `-r`/`-c`
# include (so protected specs deep in the include tree cannot slip
# past _KEEP) and repoint it so it still resolves from /tmp.
new_line, rewrote, inc_rec, inc_drp = _rewrite_include(
line, stripped, src_dir, _depth
)
out.append(new_line)
if rewrote:
changed = True
if inc_rec and not recorded:
recorded = inc_rec
dropped.extend(inc_drp)
continue
spec = stripped.split(" #", 1)[0].strip() # drop any inline comment
name = _canon(spec)
@ -200,6 +289,14 @@ def main():
if _req_rec and not recorded:
recorded = _req_rec
dropped.extend(_req_drp)
elif prev_flag in _CONSTRAINT_FILE_FLAGS:
# Strip protected pins from the constraint file so it cannot
# downgrade the baked stack, but a constraint is not an install
# target and its transformers pin is not an install request, so
# do not set has_target / recorded here.
_c_path, _c_rec, _c_drp = _filter_requirements_file(tok)
keep_args.append(_c_path)
dropped.extend(_c_drp)
else:
keep_args.append(tok)
skip_next = False
@ -220,6 +317,10 @@ def main():
if _req_rec and not recorded:
recorded = _req_rec
dropped.extend(_req_drp)
elif _flag in _CONSTRAINT_FILE_FLAGS:
_c_path, _c_rec, _c_drp = _filter_requirements_file(_val)
keep_args.append(_flag + "=" + _c_path)
dropped.extend(_c_drp)
else:
keep_args.append(tok) # option with inline value, not a target
continue

View file

@ -15,7 +15,7 @@ Usage:
A raw github URL (raw.githubusercontent.com/.../nb/Foo.ipynb) is fetched first.
"""
import argparse, json, os, re, subprocess, sys, tempfile, urllib.request
import argparse, json, os, re, shutil, subprocess, sys, tempfile, urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
@ -69,10 +69,13 @@ def main():
sidecar = compat.sidecar_for(want) if (compat and want) else None
# Materialise the notebook locally for nbconvert.
tmp_dir = None
if args.notebook.startswith(("http://", "https://")) or args.out:
src_path = args.out or os.path.join(
tempfile.mkdtemp(), os.path.basename(args.notebook.split("?")[0])
)
if args.out:
src_path = args.out
else:
tmp_dir = tempfile.mkdtemp()
src_path = os.path.join(tmp_dir, os.path.basename(args.notebook.split("?")[0]))
with open(src_path, "w") as f:
json.dump(nb, f)
else:
@ -109,7 +112,13 @@ def main():
os.path.dirname(os.path.abspath(out_path)) or ".",
]
print("[unsloth-run] executing:", os.path.basename(src_path))
sys.exit(subprocess.call(cmd, env = env))
try:
rc = subprocess.call(cmd, env = env)
finally:
# Clean up the temp dir we materialised a downloaded notebook into.
if tmp_dir is not None:
shutil.rmtree(tmp_dir, ignore_errors = True)
sys.exit(rc)
if __name__ == "__main__":

View file

@ -183,6 +183,15 @@ while IFS= read -r -d '' f; do
unchanged=$((unchanged + 1))
continue
fi
elif [ -n "${LAST[$rel]:-}" ] && [ "${UNSLOTH_KEEP_DELETED_NOTEBOOKS:-0}" = "1" ]; then
# We previously wrote this notebook and the user has since DELETED it.
# With the opt-out set, honor the deletion instead of restoring it from
# the fresh clone when upstream advances (otherwise the deletion only
# held until the next remote refresh). Keep the record so it stays known
# as managed-but-deleted.
printf '%s %s\n' "${LAST[$rel]}" "$rel" >> "$TMPSTATE"
kept=$((kept + 1))
continue
fi
mkdir -p "$(dirname "$dst")" 2>/dev/null || true
if cp -a "$f" "$dst" 2>/dev/null; then

View file

@ -2045,6 +2045,12 @@ def install_python_stack() -> int:
package_name = os.environ.get("STUDIO_PACKAGE_NAME", "unsloth")
# --local overlays a local repo checkout after updating deps.
local_repo = os.environ.get("STUDIO_LOCAL_REPO", "")
# unsloth-zoo git ref for the --local overlay. Honor UNSLOTH_ZOO_REF (the
# Docker publish workflow / unsloth-studio-update resolve one ref and forward
# it) so the Studio venv can track the operator-requested zoo instead of
# always main. Unset -> main, byte-identical to the previous bare git URL.
zoo_ref = os.environ.get("UNSLOTH_ZOO_REF", "").strip() or "main"
zoo_git_spec = "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo@" + zoo_ref
base_total = 11 if IS_WINDOWS else 12 # +1 for the anyio repair check (step 8b)
if IS_MACOS:
base_total -= 1 # triton step is skipped on macOS
@ -2154,13 +2160,13 @@ def install_python_stack() -> int:
local_repo,
constrain = False,
)
_step(_LABEL, "overlaying unsloth-zoo from git main")
_step(_LABEL, f"overlaying unsloth-zoo from git {zoo_ref}")
pip_install(
"Overlaying unsloth-zoo from git main",
f"Overlaying unsloth-zoo from git {zoo_ref}",
"--no-cache-dir",
"--no-deps",
"--force-reinstall",
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo",
zoo_git_spec,
constrain = False,
)
elif local_repo:
@ -2185,13 +2191,13 @@ def install_python_stack() -> int:
local_repo,
constrain = False,
)
_step(_LABEL, "overlaying unsloth-zoo from git main")
_step(_LABEL, f"overlaying unsloth-zoo from git {zoo_ref}")
pip_install(
"Overlaying unsloth-zoo from git main",
f"Overlaying unsloth-zoo from git {zoo_ref}",
"--no-cache-dir",
"--no-deps",
"--force-reinstall",
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo",
zoo_git_spec,
constrain = False,
)
elif package_name != "unsloth":

View file

@ -295,8 +295,11 @@ class SyntheticDataKit:
# we don't print stderr to console but self.stderr_capture.tail(200) will print the last 200 lines
ready = False
deadline = time.monotonic() + (timeout or 1200)
while time.monotonic() < deadline:
# timeout = None (or 0) preserves the previous Event.wait(None) escape
# hatch: wait indefinitely for the readiness message (useful for large
# models or slow first-time downloads). Any positive value is a deadline.
deadline = (time.monotonic() + timeout) if timeout else None
while deadline is None or time.monotonic() < deadline:
if self.stdout_capture.wait_for_ready(timeout = 1) or self.stderr_capture.wait_for_ready(
timeout = 0
):