diff --git a/.github/workflows/cross-platform-parity-ci.yml b/.github/workflows/cross-platform-parity-ci.yml index 0aacf59639..a440e1e186 100644 --- a/.github/workflows/cross-platform-parity-ci.yml +++ b/.github/workflows/cross-platform-parity-ci.yml @@ -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 diff --git a/install.sh b/install.sh index 76a0887b5e..dbaa864ab1 100755 --- a/install.sh +++ b/install.sh @@ -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/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. diff --git a/pyproject.toml b/pyproject.toml index f98fece26e..bb7a2943d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index bf391ee9ec..e38deac8c7 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -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 diff --git a/studio/systemd/install_user_service.sh b/studio/systemd/install_user_service.sh new file mode 100755 index 0000000000..66f846bc61 --- /dev/null +++ b/studio/systemd/install_user_service.sh @@ -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" diff --git a/studio/systemd/unsloth-studio.service.in b/studio/systemd/unsloth-studio.service.in new file mode 100644 index 0000000000..b5b5d0c695 --- /dev/null +++ b/studio/systemd/unsloth-studio.service.in @@ -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 diff --git a/tests/sh/test_strixhalo_wsl_reroute.sh b/tests/sh/test_strixhalo_wsl_reroute.sh index 694100b4b6..5bf65042cd 100644 --- a/tests/sh/test_strixhalo_wsl_reroute.sh +++ b/tests/sh/test_strixhalo_wsl_reroute.sh @@ -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 diff --git a/tests/test_installer_interactive_prompts.py b/tests/test_installer_interactive_prompts.py index 8ff0cf023c..0fc499ee46 100644 --- a/tests/test_installer_interactive_prompts.py +++ b/tests/test_installer_interactive_prompts.py @@ -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 diff --git a/tests/test_installer_systemd.py b/tests/test_installer_systemd.py new file mode 100644 index 0000000000..6dc02a327e --- /dev/null +++ b/tests/test_installer_systemd.py @@ -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