Studio: opt-in systemd user service for Linux auto-start (#9308)

* Studio: optional systemd user service for Linux auto-start (#9258)

Add a managed user-level systemd unit (Restart=on-failure) and an opt-in
install.sh prompt on Linux. Includes uninstall cleanup, packaging for the
wheel, and regression tests.

Fixes unslothai/unsloth#9258

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(studio): default systemd unit to 127.0.0.1 bind

Match `unsloth studio` so opting into the user service does not quietly
expose the server on every interface. Keep all-interfaces bind as an
explicit opt-in via UNSLOTH_SYSTEMD_HOST=0.0.0.0 or --host.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(studio): register systemd installer in parity prompt guard

Approve the opt-in systemd prompt and scan install_user_service.sh so
cross-platform parity CI covers the helper install.sh invokes. Also
document the loopback default in the post-install systemd hints.

* fix(studio): skip systemd prompt without user session (#9308)

Probe systemd user availability before the opt-in question so non-systemd
Linux installs still reach the usual autostart prompt unchanged. Explicit
UNSLOTH_INSTALL_SYSTEMD still attempts setup and reports unavailability.

Add regression tests for the guard and autostart ordering.

* Add the AGPL header to the systemd installer test

* systemd service: opt-in via UNSLOTH_INSTALL_SYSTEMD only, no installer prompt

* Drop a spacer comment line

* Forward UNSLOTH_SYSTEMD_HOST/PORT through the Strix Halo WSL reroute

* Allow the systemd opt-in on WSL, where OS reads wsl

* systemd opt-in: isolate the helper lookup from cwd, reject unit-breaking values, keep user-written units, sturdier uninstall

* systemd opt-in: follow the user manager's HOME for the unit and the uninstaller

* Run the systemd installer tests in parity CI; find a redirected-HOME unit without a bus

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
This commit is contained in:
Sourav Rajvi 2026-10-02 14:49:53 +05:30 • committed by GitHub
parent 57ed76c039
commit fcd16f7e11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 665 additions and 1 deletions

View file

@ -42,6 +42,8 @@ on:
- 'tests/test_installer_skip_autostart.py'
- 'tests/test_installer_system32_guard.py'
- 'tests/test_installer_interactive_prompts.py'
- 'tests/test_installer_systemd.py'
- 'studio/systemd/unsloth-studio.service.in'
- 'tests/test_installer_profile_hardening.py'
- 'install*.sh'
- 'install*.ps1'
@ -67,6 +69,7 @@ on:
- 'setup*.ps1'
- 'setup*.bat'
- 'studio/install_*.py'
- 'studio/systemd/install_user_service.sh'
- 'studio/backend/requirements/single-env/patch_metadata.py'
- 'tests/python/test_cross_platform_parity.py'
# This is the only lane that executes it, and the Windows smoke workflow it moved
@ -163,6 +166,8 @@ on:
- 'tests/test_installer_skip_autostart.py'
- 'tests/test_installer_system32_guard.py'
- 'tests/test_installer_interactive_prompts.py'
- 'tests/test_installer_systemd.py'
- 'studio/systemd/unsloth-studio.service.in'
- 'tests/test_installer_profile_hardening.py'
- 'install*.sh'
- 'install*.ps1'
@ -188,6 +193,7 @@ on:
- 'setup*.ps1'
- 'setup*.bat'
- 'studio/install_*.py'
- 'studio/systemd/install_user_service.sh'
- 'studio/backend/requirements/single-env/patch_metadata.py'
- 'tests/python/test_cross_platform_parity.py'
# This is the only lane that executes it, and the Windows smoke workflow it moved
@ -345,6 +351,7 @@ jobs:
tests/test_installer_skip_autostart.py
tests/test_installer_system32_guard.py
tests/test_installer_interactive_prompts.py
tests/test_installer_systemd.py
tests/test_installer_profile_hardening.py
tests/studio/install/test_keep_install_backcompat_9979.py
-q -rs

View file

@ -1,5 +1,5 @@
#!/bin/sh
# Unsloth Studio Installer. Usage, supported options and the web one-liner live in the README under "Unsloth Studio (web UI)" and are deliberately not repeated here: this file ships inside the Linux desktop bundle, where a header rehearsing download-and-run command lines is the first thing a generic script classifier reads. A piped install takes options as environment variables after the pipe (UNSLOTH_NO_TORCH, UNSLOTH_SKIP_AUTOSTART, UNSLOTH_ISOLATE_UV_CACHE, UNSLOTH_INSTALL_NO_ROLLBACK, UNSLOTH_PYTHON, UNSLOTH_STUDIO_HOME), because a bare `--no-torch` after the pipe would be read as an option to sh itself; a local run takes the equivalent flags (--no-torch, --isolated-uv-cache, --no-rollback, --python, --local). Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > $HOME/.unsloth/studio
# Unsloth Studio Installer. Usage, supported options and the web one-liner live in the README under "Unsloth Studio (web UI)" and are deliberately not repeated here: this file ships inside the Linux desktop bundle, where a header rehearsing download-and-run command lines is the first thing a generic script classifier reads. A piped install takes options as environment variables after the pipe (UNSLOTH_NO_TORCH, UNSLOTH_SKIP_AUTOSTART, UNSLOTH_INSTALL_SYSTEMD, UNSLOTH_SYSTEMD_HOST, UNSLOTH_SYSTEMD_PORT, UNSLOTH_ISOLATE_UV_CACHE, UNSLOTH_INSTALL_NO_ROLLBACK, UNSLOTH_PYTHON, UNSLOTH_STUDIO_HOME), because a bare `--no-torch` after the pipe would be read as an option to sh itself; a local run takes the equivalent flags (--no-torch, --isolated-uv-cache, --no-rollback, --python, --local). Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > $HOME/.unsloth/studio
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
set -e
@ -43,6 +43,8 @@ _NO_ROLLBACK=false
# Set by the discard itself, so the disk-full remedy can describe what happened rather than what was requested.
_VENV_DISCARDED=false
_VENV_DISCARD_LEFTOVER=""
_INSTALL_SYSTEMD=false
_SYSTEMD_STARTED=false
_VERBOSE=false
_SHORTCUTS_ONLY=false
_next_is_package=false
@ -84,6 +86,7 @@ case "${UNSLOTH_NO_TORCH:-}" in 1|true|TRUE|yes|YES|on|ON) _NO_TORCH_FLAG=true ;
case "${UNSLOTH_SKIP_AUTOSTART:-}" in 1|true|TRUE|yes|YES|on|ON) _SKIP_AUTOSTART=true ;; esac
case "${UNSLOTH_ISOLATE_UV_CACHE:-}" in 1|true|TRUE|yes|YES|on|ON) _ISOLATE_UV_CACHE=true ;; esac
case "${UNSLOTH_INSTALL_NO_ROLLBACK:-}" in 1|true|TRUE|yes|YES|on|ON) _NO_ROLLBACK=true ;; esac
case "${UNSLOTH_INSTALL_SYSTEMD:-}" in 1|true|TRUE|yes|YES|on|ON) _INSTALL_SYSTEMD=true ;; esac
[ -z "$_USER_PYTHON" ] && [ -n "${UNSLOTH_PYTHON:-}" ] && _USER_PYTHON="$UNSLOTH_PYTHON"
if [ "$_VERBOSE" = true ]; then
@ -1458,6 +1461,49 @@ _can_read_tty() {
( : </dev/tty ) >/dev/null 2>&1
}
# Checkout copy only for a trusted --local run: a piped install's _REPO_ROOT is the caller's cwd,
# and -I keeps that cwd off sys.path for the installed-package lookup.
_resolve_systemd_install_script() {
if [ "$_REPO_IS_CHECKOUT" = "1" ] && [ -f "$_REPO_ROOT/studio/systemd/install_user_service.sh" ]; then
printf '%s\n' "$_REPO_ROOT/studio/systemd/install_user_service.sh"
return 0
fi
[ -x "$VENV_DIR/bin/python" ] || return 0
"$VENV_DIR/bin/python" -I -c \
"import importlib.resources as r; print(r.files('studio') / 'systemd' / 'install_user_service.sh')" \
2>/dev/null || true
}
# Only with UNSLOTH_INSTALL_SYSTEMD set; without it the install is unchanged.
_install_systemd_user_service() {
case "$OS" in
linux|wsl) ;;
*) step "systemd" "UNSLOTH_INSTALL_SYSTEMD is Linux only; skipped" "$C_WARN"; return 0 ;;
esac
_sd_script=$(_resolve_systemd_install_script)
if [ -z "$_sd_script" ] || [ ! -f "$_sd_script" ]; then
step "systemd" "service helper not found in this install; skipped" "$C_WARN"
return 0
fi
set -- --unsloth-exe "$VENV_DIR/bin/unsloth" \
--host "${UNSLOTH_SYSTEMD_HOST:-127.0.0.1}" --port "${UNSLOTH_SYSTEMD_PORT:-8888}" --enable --start
# The user manager runs with the passwd HOME, so any non-default home has to be spelled out.
[ "$_STUDIO_HOME_REDIRECT" != "default" ] && set -- "$@" --studio-home "$STUDIO_HOME"
if bash "$_sd_script" "$@" >/dev/null; then
_SYSTEMD_STARTED=true
step "systemd" "user service enabled (unsloth-studio.service)"
substep "status: systemctl --user status unsloth-studio.service"
substep "logs: journalctl --user -u unsloth-studio.service -f"
substep "stop: systemctl --user stop unsloth-studio.service"
substep "binds 127.0.0.1 by default; set UNSLOTH_SYSTEMD_HOST=0.0.0.0 before install for LAN"
substep "for boot without a login session, run once: loginctl enable-linger \"\$USER\""
else
step "systemd" "user service install failed; see the error above" "$C_WARN"
fi
}
# ── Helper: install packages via apt, escalating to sudo only if needed ──
# Usage: _smart_apt_install pkg1 pkg2 pkg3 ...
_smart_apt_install() {
@ -3139,6 +3185,11 @@ _maybe_reroute_strixhalo_to_2404() {
[ -n "${UNSLOTH_TORCH_INDEX_FAMILY:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_TORCH_INDEX_FAMILY=$(_rr_q "$UNSLOTH_TORCH_INDEX_FAMILY")"
[ -n "${UNSLOTH_MIRROR_FALLBACK:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_MIRROR_FALLBACK=$(_rr_q "$UNSLOTH_MIRROR_FALLBACK")"
[ "$_SKIP_AUTOSTART" = true ] && _rr_exports="$_rr_exports; export UNSLOTH_SKIP_AUTOSTART=1"
if [ "$_INSTALL_SYSTEMD" = true ]; then
_rr_exports="$_rr_exports; export UNSLOTH_INSTALL_SYSTEMD=1"
[ -n "${UNSLOTH_SYSTEMD_HOST:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_SYSTEMD_HOST=$(_rr_q "$UNSLOTH_SYSTEMD_HOST")"
[ -n "${UNSLOTH_SYSTEMD_PORT:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_SYSTEMD_PORT=$(_rr_q "$UNSLOTH_SYSTEMD_PORT")"
fi
_rr_args=""
[ "$PACKAGE_NAME" != "unsloth" ] && _rr_args="$_rr_args --package $(_rr_q "$PACKAGE_NAME")"
[ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")"
@ -8676,6 +8727,12 @@ printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!"
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
echo ""
if [ "$_INSTALL_SYSTEMD" = true ]; then
_install_systemd_user_service
if [ "$_SYSTEMD_STARTED" = true ]; then
_SKIP_AUTOSTART=true
fi
fi
if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then
echo ""
# No readable answer (closed/EOF tty) defaults to no; Enter is still yes. Prompt only when something can answer: `test -r` passes on the unopenable /dev/tty found in containers, leaving a dangling question in the log.

View file

@ -80,6 +80,7 @@ studio = [
"backend/core/data_recipe/oxc-validator/*.json",
"backend/core/data_recipe/oxc-validator/*.mjs",
"backend/core/inference/bundled_skills/**/*.md",
"systemd/*",
]
[tool.setuptools.packages.find]

View file

@ -721,6 +721,34 @@ _unsloth_uninstall_main() {
_is_wsl=0
[ "$_os" = "Linux" ] && grep -qi microsoft /proc/version 2>/dev/null && _is_wsl=1
# Before the kill sweep, or Restart=on-failure brings the server back mid-removal.
_remove_systemd_user_service() {
_sd_dir="$(_xdg_dir "${XDG_CONFIG_HOME:-}" "$HOME/.config")/systemd/user"
_sd_unit="$_sd_dir/unsloth-studio.service"
if [ ! -f "$_sd_unit" ] && command -v systemctl >/dev/null 2>&1; then
# HOME redirected: ask the manager where the unit it loaded lives.
_sd_frag=$(systemctl --user show -p FragmentPath --value unsloth-studio.service 2>/dev/null || true)
case "$_sd_frag" in */unsloth-studio.service) _sd_unit="$_sd_frag"; _sd_dir="${_sd_frag%/*}" ;; esac
fi
if [ ! -f "$_sd_unit" ] && command -v getent >/dev/null 2>&1; then
# No manager to ask: its HOME is the passwd one.
_sd_pw=$(getent passwd "$(id -un 2>/dev/null)" 2>/dev/null | cut -d: -f6)
case "$_sd_pw" in /*) _sd_dir="$_sd_pw/.config/systemd/user"; _sd_unit="$_sd_dir/unsloth-studio.service" ;; esac
fi
[ -f "$_sd_unit" ] || return 0
[ "$(head -n 1 "$_sd_unit" 2>/dev/null)" = "# unsloth-studio-managed-systemd" ] || return 0
_sd_stopped=0
if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
systemctl --user disable --now unsloth-studio.service 2>/dev/null && _sd_stopped=1
fi
_remove_path "$_sd_dir/default.target.wants/unsloth-studio.service"
_remove_path "$_sd_unit"
[ "$_sd_stopped" = 1 ] && { systemctl --user daemon-reload 2>/dev/null || true; }
echo "Removed systemd user service (unsloth-studio.service)."
[ "$_sd_stopped" = 1 ] || echo " could not reach the systemd user manager to stop it; it will not start again after the next login or reboot" >&2
}
_remove_systemd_user_service
echo "Stopping any running Unsloth Studio servers..."
_pkill_studio

View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# Writes (and with --enable/--start, enables) the optional systemd user unit for Unsloth Studio.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE="$SCRIPT_DIR/unsloth-studio.service.in"
UNIT_NAME="unsloth-studio.service"
_UNSLOTH_EXE=""
_STUDIO_HOME=""
_HOST="${UNSLOTH_SYSTEMD_HOST:-127.0.0.1}"
_PORT="${UNSLOTH_SYSTEMD_PORT:-8888}"
_DO_ENABLE=false
_DO_START=false
_usage() {
cat <<'EOF'
Usage: install_user_service.sh --unsloth-exe PATH [options]
Options:
--unsloth-exe PATH Absolute path to the unsloth CLI (required)
--studio-home PATH Sets UNSLOTH_STUDIO_HOME in the unit (custom installs)
--host HOST Bind address (default: 127.0.0.1; UNSLOTH_SYSTEMD_HOST overrides;
use 0.0.0.0 for LAN / all-interfaces)
--port PORT Listen port (default: 8888; UNSLOTH_SYSTEMD_PORT overrides)
--enable Write unit, daemon-reload, and systemctl --user enable
--start Also systemctl --user start (implies --enable)
-h, --help Show this help
EOF
}
# Inside a double-quoted unit value: backslash and quote escape, % is a specifier.
_unit_quote() {
local v="$1"
v="${v//\\/\\\\}"
v="${v//\"/\\\"}"
v="${v//%/%%}"
printf '"%s"' "$v"
}
while [ "$#" -gt 0 ]; do
case "$1" in
--unsloth-exe) _UNSLOTH_EXE="${2:-}"; shift 2 ;;
--studio-home) _STUDIO_HOME="${2:-}"; shift 2 ;;
--host) _HOST="${2:-}"; shift 2 ;;
--port) _PORT="${2:-}"; shift 2 ;;
--enable) _DO_ENABLE=true; shift ;;
--start) _DO_ENABLE=true; _DO_START=true; shift ;;
-h|--help) _usage; exit 0 ;;
*)
echo "Unknown argument: $1" >&2
_usage >&2
exit 2
;;
esac
done
if [ -z "$_UNSLOTH_EXE" ]; then
echo "ERROR: --unsloth-exe is required." >&2
exit 2
fi
if [ ! -x "$_UNSLOTH_EXE" ]; then
echo "ERROR: unsloth executable not found or not executable: $_UNSLOTH_EXE" >&2
exit 1
fi
case "$_PORT" in
''|*[!0-9]*|0) echo "ERROR: --port must be a positive integer." >&2; exit 2 ;;
esac
if [ -z "$_HOST" ]; then
echo "ERROR: --host must not be empty." >&2
exit 2
fi
for _v in "$_HOST" "$_STUDIO_HOME" "$_UNSLOTH_EXE"; do
case "$_v" in
*[[:cntrl:]]*) echo "ERROR: control characters are not allowed in unit values: $_v" >&2; exit 2 ;;
esac
done
case "$_UNSLOTH_EXE" in
*[\"\'\\]*) echo "ERROR: systemd cannot run an executable whose path contains quotes or backslashes: $_UNSLOTH_EXE" >&2; exit 2 ;;
esac
if [ ! -f "$TEMPLATE" ]; then
echo "ERROR: service template missing: $TEMPLATE" >&2
exit 1
fi
if [ "$(uname -s 2>/dev/null || true)" != "Linux" ]; then
echo "ERROR: systemd user service install is supported on Linux only." >&2
exit 1
fi
if [ "$_DO_ENABLE" = true ]; then
if ! command -v systemctl >/dev/null 2>&1; then
echo "ERROR: systemctl not found; systemd is required." >&2
exit 1
fi
if ! systemctl --user show-environment >/dev/null 2>&1; then
echo "ERROR: systemd user session is unavailable (is the user bus running?)." >&2
exit 1
fi
fi
_UNSLOTH_EXE="$(CDPATH= cd -P -- "$(dirname "$_UNSLOTH_EXE")" && pwd -P)/$(basename "$_UNSLOTH_EXE")"
if [ -n "$_STUDIO_HOME" ] && [ -d "$_STUDIO_HOME" ]; then
_STUDIO_HOME="$(CDPATH= cd -P -- "$_STUDIO_HOME" && pwd -P)"
fi
# No ExecStop: `unsloth studio stop` would also stop every other Studio on this home.
_exec_start="$(_unit_quote "$_UNSLOTH_EXE") studio -H $(_unit_quote "$_HOST") -p $_PORT"
_env_line=""
[ -n "$_STUDIO_HOME" ] && _env_line="Environment=$(_unit_quote "UNSLOTH_STUDIO_HOME=$_STUDIO_HOME")"
# Enabling: the unit goes where the user manager looks, which follows its own HOME, not ours.
_cfg_home="$HOME"
_cfg_xdg="${XDG_CONFIG_HOME:-}"
if [ "$_DO_ENABLE" = true ]; then
_mgr_env=$(systemctl --user show-environment 2>/dev/null || true)
_mgr_home=$(printf '%s\n' "$_mgr_env" | sed -n 's/^HOME=//p' | head -n 1)
case "$_mgr_home" in /*) _cfg_home="$_mgr_home"; _cfg_xdg=$(printf '%s\n' "$_mgr_env" | sed -n 's/^XDG_CONFIG_HOME=//p' | head -n 1) ;; esac
fi
case "$_cfg_xdg" in
/*) _unit_dir="$_cfg_xdg/systemd/user" ;;
*) _unit_dir="$_cfg_home/.config/systemd/user" ;;
esac
mkdir -p "$_unit_dir"
_unit_path="$_unit_dir/$UNIT_NAME"
# Never replace a unit the user wrote themselves.
if [ -e "$_unit_path" ] && [ "$(head -n 1 "$_unit_path" 2>/dev/null)" != "# unsloth-studio-managed-systemd" ]; then
echo "ERROR: $_unit_path exists and was not written by Unsloth; leaving it alone." >&2
exit 1
fi
_tmp="$(mktemp "$_unit_dir/.$UNIT_NAME.XXXXXX")"
trap 'rm -f "$_tmp"' EXIT
while IFS= read -r _line || [ -n "$_line" ]; do
case "$_line" in
@@ENVIRONMENT_LINES@@) [ -z "$_env_line" ] || printf '%s\n' "$_env_line" ;;
ExecStart=@@EXEC_START@@) printf 'ExecStart=%s\n' "$_exec_start" ;;
*) printf '%s\n' "$_line" ;;
esac
done < "$TEMPLATE" > "$_tmp"
chmod 0644 "$_tmp"
mv "$_tmp" "$_unit_path"
if [ "$_DO_ENABLE" != true ]; then
printf '%s\n' "$_unit_path"
exit 0
fi
systemctl --user daemon-reload
systemctl --user enable "$UNIT_NAME"
if [ "$_DO_START" = true ]; then
systemctl --user restart "$UNIT_NAME"
fi
printf '%s\n' "$_unit_path"

View file

@ -0,0 +1,19 @@
# unsloth-studio-managed-systemd
# Written by the Unsloth Studio installer; opting in again overwrites it.
[Unit]
Description=Unsloth Studio inference server
Documentation=https://github.com/unslothai/unsloth
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
@@ENVIRONMENT_LINES@@
ExecStart=@@EXEC_START@@
Restart=on-failure
RestartSec=10
KillMode=mixed
TimeoutStopSec=120
[Install]
WantedBy=default.target

View file

@ -317,6 +317,17 @@ _out=$(run_func "$_d" _SKIP_AUTOSTART=true UNSLOTH_SKIP_AUTOSTART= \
assert_contains "UNSLOTH_SKIP_AUTOSTART forwarded to reroute" "$_out" "skip=[1]"
rm -rf "$_d"
# 27b) The systemd opt-in and its bind settings reach the target distro too (the mock prints the
# command it was handed; the target distro does not inherit this shell's environment).
_d=$(make_fixture 1 strix 0 26.04 1)
_out=$(run_func "$_d" _INSTALL_SYSTEMD=true UNSLOTH_SYSTEMD_HOST=0.0.0.0 UNSLOTH_SYSTEMD_PORT=9000)
assert_contains "systemd opt-in forwarded to reroute" "$_out" "export UNSLOTH_INSTALL_SYSTEMD=1"
assert_contains "systemd host forwarded to reroute" "$_out" "export UNSLOTH_SYSTEMD_HOST='0.0.0.0'"
assert_contains "systemd port forwarded to reroute" "$_out" "export UNSLOTH_SYSTEMD_PORT='9000'"
_out=$(run_func "$_d" UNSLOTH_SYSTEMD_HOST=0.0.0.0)
assert_absent "no systemd opt-in -> nothing forwarded" "$_out" "SYSTEMD"
rm -rf "$_d"
# 28) Non-tauri mode: a child exit 2 is just a failure -> CPU fallback, not propagated.
_d=$(make_fixture 1 strix 0 26.04 1)
_rc=0

View file

@ -55,6 +55,7 @@ SCANNED_SCRIPTS = ENTRY_POINTS + (
"studio/nvidia_probe.py",
# install_python_stack runs this one with sys.executable.
"studio/backend/requirements/single-env/patch_metadata.py",
"studio/systemd/install_user_service.sh",
)
# Every question these scripts may ask, keyed by (script, normalised question) because line numbers move and wording

View file

@ -0,0 +1,387 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Optional systemd user service for Studio (#9258)."""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
INSTALL_SH = REPO_ROOT / "install.sh"
SYSTEMD_INSTALL_SH = REPO_ROOT / "studio" / "systemd" / "install_user_service.sh"
UNINSTALL_SH = REPO_ROOT / "scripts" / "uninstall.sh"
pytestmark = pytest.mark.skipif(
not sys.platform.startswith("linux") or shutil.which("bash") is None,
reason = "systemd user units are Linux only",
)
def _fake_bin(tmp_path: Path, name: str, body: str) -> Path:
bin_dir = tmp_path / "bin"
bin_dir.mkdir(exist_ok = True)
exe = bin_dir / name
exe.write_text(f"#!/bin/sh\n{body}\n")
exe.chmod(0o755)
return exe
def _write_unit(
tmp_path: Path,
*args: str,
env: dict | None = None,
) -> str:
exe = _fake_bin(tmp_path, "unsloth", "exit 0")
full_env = {k: v for k, v in os.environ.items() if not k.startswith("UNSLOTH_SYSTEMD_")}
full_env["XDG_CONFIG_HOME"] = str(tmp_path / "config")
full_env.update(env or {})
out = subprocess.run(
["bash", str(SYSTEMD_INSTALL_SH), "--unsloth-exe", str(exe), *args],
check = True,
capture_output = True,
text = True,
env = full_env,
).stdout.strip()
assert out == str(tmp_path / "config" / "systemd" / "user" / "unsloth-studio.service")
return Path(out).read_text()
def test_unit_defaults_to_loopback(tmp_path):
unit = _write_unit(tmp_path, "--port", "9090")
assert 'studio -H "127.0.0.1" -p 9090' in unit
assert "0.0.0.0" not in unit
assert "unsloth-studio-managed-systemd" in unit
assert "Restart=on-failure" in unit
assert "Environment=" not in unit
assert "@@" not in unit
@pytest.mark.parametrize(
("args", "env"),
[(("--host", "0.0.0.0"), {}), ((), {"UNSLOTH_SYSTEMD_HOST": "0.0.0.0"})],
)
def test_unit_lan_bind_is_opt_in(tmp_path, args, env):
assert 'studio -H "0.0.0.0" -p 8888' in _write_unit(tmp_path, *args, env = env)
def test_unit_has_no_execstop_that_stops_other_studios(tmp_path):
# `unsloth studio stop` stops every server on the home, including ones the user started by hand.
assert "ExecStop" not in _write_unit(tmp_path)
def test_unit_escapes_odd_paths(tmp_path):
home = tmp_path / 'my "studio" 100% $HOME'
home.mkdir()
unit = _write_unit(tmp_path, "--studio-home", str(home))
env_line = next(l for l in unit.splitlines() if l.startswith("Environment="))
escaped = str(home.resolve()).replace('"', '\\"').replace("%", "%%")
assert env_line == f'Environment="UNSLOTH_STUDIO_HOME={escaped}"'
def test_enable_start_drives_systemctl(tmp_path):
log = tmp_path / "systemctl.log"
_fake_bin(tmp_path, "systemctl", f'echo "$*" >> "{log}"')
_write_unit(
tmp_path,
"--start",
env = {"PATH": f"{tmp_path / 'bin'}{os.pathsep}{os.environ['PATH']}"},
)
assert log.read_text().splitlines() == [
"--user show-environment",
"--user show-environment",
"--user daemon-reload",
"--user enable unsloth-studio.service",
"--user restart unsloth-studio.service",
]
def test_enable_writes_where_the_user_manager_looks(tmp_path):
# HOME redirected for the installer: the manager still reads its own HOME's config.
mgr_home = tmp_path / "passwd_home"
_fake_bin(
tmp_path,
"systemctl",
f'[ "$2" = show-environment ] && printf "HOME={mgr_home}\\nLANG=C\\n"; exit 0',
)
exe = _fake_bin(tmp_path, "unsloth", "exit 0")
out = subprocess.run(
["bash", str(SYSTEMD_INSTALL_SH), "--unsloth-exe", str(exe), "--enable"],
check = True,
capture_output = True,
text = True,
env = {
**os.environ,
"HOME": str(tmp_path / "redirected"),
"XDG_CONFIG_HOME": str(tmp_path / "redirected" / ".config"),
"PATH": f"{tmp_path / 'bin'}{os.pathsep}{os.environ['PATH']}",
},
).stdout.strip()
assert out == str(mgr_home / ".config" / "systemd" / "user" / "unsloth-studio.service")
_GATE = re.compile(
r'^if \[ "\$_INSTALL_SYSTEMD" = true \]; then\n.*?^fi\n', re.DOTALL | re.MULTILINE
)
def _harness() -> str:
source = INSTALL_SH.read_text(encoding = "utf-8")
parts = ['step() { echo "STEP $*"; }\nsubstep() { :; }\n']
for name in ("_resolve_systemd_install_script", "_install_systemd_user_service"):
m = re.search(rf"^{name}\(\) \{{.*?^\}}", source, flags = re.DOTALL | re.MULTILINE)
assert m is not None, name
parts.append(m.group(0))
gate = _GATE.search(source)
assert gate is not None
parts.append(gate.group(0))
parts.append('echo "STARTED=$_SYSTEMD_STARTED SKIP_AUTOSTART=$_SKIP_AUTOSTART"\n')
return "\n".join(parts)
def _run_installer_tail(
tmp_path: Path,
cwd: Path | None = None,
real_python: bool = False,
**vars: str,
) -> str:
script = tmp_path / "helper.sh"
log = tmp_path / "helper.log"
script.write_text(f'echo "$*" > "{log}"\n')
venv = tmp_path / "venv"
(venv / "bin").mkdir(parents = True, exist_ok = True)
py = venv / "bin" / "python"
if real_python:
py.symlink_to(sys.executable)
else:
py.write_text(f'#!/bin/sh\necho "{script}"\n')
py.chmod(0o755)
preset = {
"OS": "linux",
"_INSTALL_SYSTEMD": "false",
"_SYSTEMD_STARTED": "false",
"_SKIP_AUTOSTART": "false",
"_REPO_IS_CHECKOUT": "0",
"_REPO_ROOT": str(tmp_path),
"VENV_DIR": str(venv),
"STUDIO_HOME": str(tmp_path / "home"),
"_STUDIO_HOME_REDIRECT": "default",
**vars,
}
assigns = "".join(f"{k}='{v}'\n" for k, v in preset.items())
env = {k: v for k, v in os.environ.items() if not k.startswith("UNSLOTH_SYSTEMD_")}
env["PATH"] = f"{tmp_path / 'bin'}{os.pathsep}{env['PATH']}"
out = subprocess.run(
["sh", "-ec", assigns + _harness()],
check = True,
capture_output = True,
text = True,
env = env,
stdin = subprocess.DEVNULL,
cwd = cwd,
).stdout
return out + (log.read_text() if log.exists() else "")
def test_flag_off_changes_nothing_even_with_a_user_bus(tmp_path):
_fake_bin(tmp_path, "systemctl", "exit 0")
out = _run_installer_tail(tmp_path)
assert out == "STARTED=false SKIP_AUTOSTART=false\n"
def test_opt_in_installs_with_studio_home_for_env_redirect(tmp_path):
out = _run_installer_tail(tmp_path, _INSTALL_SYSTEMD = "true", _STUDIO_HOME_REDIRECT = "env")
assert "STARTED=true SKIP_AUTOSTART=true" in out
assert f"--studio-home {tmp_path / 'home'}" in out
assert "--host 127.0.0.1 --port 8888" in out
def test_opt_in_runs_on_wsl(tmp_path):
# WSL2 distros can run systemd; the helper itself refuses when the user bus is missing.
out = _run_installer_tail(tmp_path, _INSTALL_SYSTEMD = "true", OS = "wsl")
assert "STARTED=true" in out
assert "--unsloth-exe" in out
def test_opt_in_off_linux_warns_and_keeps_launch_prompt(tmp_path):
out = _run_installer_tail(tmp_path, _INSTALL_SYSTEMD = "true", OS = "macos")
assert "Linux only" in out
assert "STARTED=false SKIP_AUTOSTART=false" in out
assert "--unsloth-exe" not in out
def test_piped_install_never_runs_a_helper_planted_in_cwd(tmp_path):
planted = tmp_path / "studio" / "systemd" / "install_user_service.sh"
planted.parent.mkdir(parents = True)
planted.write_text(f'touch "{tmp_path / "planted_ran"}"\n')
assert "STARTED=true" in _run_installer_tail(tmp_path, _INSTALL_SYSTEMD = "true")
assert not (tmp_path / "planted_ran").exists()
def test_package_lookup_ignores_a_studio_package_planted_in_cwd(tmp_path):
# Real interpreter: `python -c` puts the cwd first on sys.path; the lookup must not.
cwd = tmp_path / "cwd"
(cwd / "studio" / "systemd").mkdir(parents = True)
(cwd / "studio" / "__init__.py").write_text(f"open({str(tmp_path / 'imported')!r}, 'w')\n")
(cwd / "studio" / "systemd" / "install_user_service.sh").write_text(
f'touch "{tmp_path / "planted_ran"}"\n'
)
_run_installer_tail(tmp_path, cwd = cwd, real_python = True, _INSTALL_SYSTEMD = "true")
assert not (tmp_path / "imported").exists()
assert not (tmp_path / "planted_ran").exists()
@pytest.mark.parametrize("bad", ["home\nExecStartPre=/bin/true", 'a"b'])
def test_helper_rejects_values_that_break_the_unit(tmp_path, bad):
exe_dir = tmp_path / bad if '"' in bad else tmp_path
exe_dir.mkdir(exist_ok = True)
exe = exe_dir / "unsloth"
exe.write_text("#!/bin/sh\n")
exe.chmod(0o755)
args = ["--unsloth-exe", str(exe)]
if "\n" in bad:
args += ["--studio-home", str(tmp_path / bad)]
r = subprocess.run(
["bash", str(SYSTEMD_INSTALL_SH), *args],
capture_output = True,
text = True,
env = {**os.environ, "XDG_CONFIG_HOME": str(tmp_path / "config")},
)
assert r.returncode == 2, r.stderr
assert not (tmp_path / "config" / "systemd" / "user" / "unsloth-studio.service").exists()
def test_helper_never_replaces_a_unit_the_user_wrote(tmp_path):
unit = tmp_path / "config" / "systemd" / "user" / "unsloth-studio.service"
unit.parent.mkdir(parents = True)
unit.write_text("[Service]\nExecStart=/opt/mine\n")
with pytest.raises(subprocess.CalledProcessError):
_write_unit(tmp_path)
assert unit.read_text() == "[Service]\nExecStart=/opt/mine\n"
assert list(unit.parent.iterdir()) == [unit]
def test_helper_ignores_relative_xdg_config_home(tmp_path):
exe = _fake_bin(tmp_path, "unsloth", "exit 0")
out = subprocess.run(
["bash", str(SYSTEMD_INSTALL_SH), "--unsloth-exe", str(exe)],
check = True,
capture_output = True,
text = True,
cwd = tmp_path,
env = {**os.environ, "XDG_CONFIG_HOME": "rel", "HOME": str(tmp_path / "home")},
).stdout.strip()
assert out == str(tmp_path / "home" / ".config" / "systemd" / "user" / "unsloth-studio.service")
def _run_uninstall_removal(tmp_path: Path, unit_text: str | None, systemctl: str) -> str:
source = UNINSTALL_SH.read_text(encoding = "utf-8")
parts = []
for name in ("_set_marker", "_remove_path", "_xdg_dir"):
m = re.search(rf"^{name}\(\) \{{.*?^\}}", source, flags = re.DOTALL | re.MULTILINE)
parts.append(m.group(0))
m = re.search(
r"^ _remove_systemd_user_service\(\) \{.*?^ \}",
source,
flags = re.DOTALL | re.MULTILINE,
)
parts.append(m.group(0) + "\n_remove_systemd_user_service\n")
d = tmp_path / "home" / ".config" / "systemd" / "user"
(d / "default.target.wants").mkdir(parents = True)
if unit_text is not None:
(d / "unsloth-studio.service").write_text(unit_text)
(d / "default.target.wants" / "unsloth-studio.service").symlink_to(
d / "unsloth-studio.service"
)
log = tmp_path / "systemctl.log"
_fake_bin(tmp_path, "systemctl", f'echo "$*" >> "{log}"\n{systemctl}')
env = {
**os.environ,
"HOME": str(tmp_path / "home"),
"PATH": f"{tmp_path / 'bin'}{os.pathsep}{os.environ['PATH']}",
}
env.pop("XDG_CONFIG_HOME", None)
r = subprocess.run(
["sh", "-c", "\n".join(parts)], capture_output = True, text = True, env = env, check = True
)
return r.stdout + r.stderr + (log.read_text() if log.exists() else "")
def test_uninstall_leaves_units_it_did_not_write(tmp_path):
text = "[Service]\n# mentions unsloth-studio-managed-systemd in passing\nExecStart=/opt/mine\n"
assert _run_uninstall_removal(tmp_path, text, "exit 0") == ""
assert (tmp_path / "home" / ".config" / "systemd" / "user" / "unsloth-studio.service").exists()
def test_uninstall_without_user_bus_still_drops_the_enable_link(tmp_path):
out = _run_uninstall_removal(
tmp_path, "# unsloth-studio-managed-systemd\n[Service]\n", "exit 1"
)
d = tmp_path / "home" / ".config" / "systemd" / "user"
assert not (d / "unsloth-studio.service").exists()
assert not os.path.lexists(d / "default.target.wants" / "unsloth-studio.service")
assert "could not reach the systemd user manager" in out
assert "disable" not in out
def test_installer_passes_studio_home_when_home_is_redirected(tmp_path):
out = _run_installer_tail(tmp_path, _INSTALL_SYSTEMD = "true", _STUDIO_HOME_REDIRECT = "home")
assert f"--studio-home {tmp_path / 'home'}" in out
out = _run_installer_tail(tmp_path, _INSTALL_SYSTEMD = "true")
assert "--studio-home" not in out
def test_uninstall_finds_the_unit_through_the_manager(tmp_path):
other = tmp_path / "passwd_home" / ".config" / "systemd" / "user"
other.mkdir(parents = True)
(other / "unsloth-studio.service").write_text("# unsloth-studio-managed-systemd\n")
out = _run_uninstall_removal(
tmp_path,
None,
f'[ "$2" = show ] && echo "{other}/unsloth-studio.service"; exit 0',
)
assert "--user disable --now unsloth-studio.service" in out
assert not (other / "unsloth-studio.service").exists()
def test_uninstall_finds_the_unit_under_the_passwd_home_without_a_bus(tmp_path):
other = tmp_path / "passwd_home" / ".config" / "systemd" / "user"
other.mkdir(parents = True)
(other / "unsloth-studio.service").write_text("# unsloth-studio-managed-systemd\n")
_fake_bin(tmp_path, "getent", f'echo "me:x:1:1::{tmp_path / "passwd_home"}:/bin/sh"')
out = _run_uninstall_removal(tmp_path, None, "exit 1")
assert not (other / "unsloth-studio.service").exists()
assert "could not reach the systemd user manager" in out
def test_uninstall_with_user_bus_disables_then_removes(tmp_path):
out = _run_uninstall_removal(
tmp_path, "# unsloth-studio-managed-systemd\n[Service]\n", "exit 0"
)
assert "--user disable --now unsloth-studio.service" in out
assert "could not reach" not in out
assert not (
tmp_path / "home" / ".config" / "systemd" / "user" / "unsloth-studio.service"
).exists()
def test_installer_asks_no_systemd_question():
source = INSTALL_SH.read_text(encoding = "utf-8")
assert not re.search(r"printf[^\n]*systemd[^\n]*\?", source)
assert _GATE.search(source).start() < source.index("Start Unsloth Studio now? [Y/n]")
def test_uninstall_disables_service_before_kill_sweep():
source = UNINSTALL_SH.read_text(encoding = "utf-8")
assert source.index("\n _remove_systemd_user_service\n") < source.index(
"\n _pkill_studio\n"
)
assert "systemctl --user disable --now unsloth-studio.service" in source