mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-19 08:54:21 +00:00
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:
parent
57ed76c039
commit
fcd16f7e11
9 changed files with 665 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
59
install.sh
59
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/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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
153
studio/systemd/install_user_service.sh
Executable file
153
studio/systemd/install_user_service.sh
Executable 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"
|
||||
19
studio/systemd/unsloth-studio.service.in
Normal file
19
studio/systemd/unsloth-studio.service.in
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
387
tests/test_installer_systemd.py
Normal file
387
tests/test_installer_systemd.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue