fix: keep the server running after you log out
Nightly Build / build (push) Successful in 7m47s

A `systemctl --user` unit runs under the per-user manager, which systemd
starts at first login and stops when the user's last session ends — so
closing the SSH session that started Skald killed it, and it never came
up at boot. No crash and nothing in the journal: the whole cgroup is
simply torn down. Both installers now enable lingering after installing
the unit, and update.sh carries the same helper so an installation
predating this fix is healed by an ordinary update. A failure to enable
it only ever warns, with the manual command — it must not abort an
install.

Two things found on the way there:

update.sh matched `case "$OS" in Linux) ... Darwin)`, but $OS had already
been normalized to lowercase at the top of the file, so stop_service and
start_service were both silent no-ops. None of the ordering the file
documents at its head was executing: the tarball went over the running
binary (ETXTBSY, aborting the update mid-way) and the safety-net restart
in cleanup() was a no-op too, leaving the box down.

Neither workflow published install.sh / install-nightly.sh to the web
root, so the scripts served by builds.skaldagent.net were hand-copied and
drifting from the repo — an installer fix would reach every existing box
through update.sh but never a new one. Nightly publishes the nightly
installer, release publishes the release one, both with the same atomic
temp-and-rename the tarballs use.

Also on the unit: dropped `After=docker.service`, which a user manager
silently ignores rather than honouring advisorily, and moved
`Restart=on-failure` to `always` — run.sh exits 0 on any graceful
shutdown, including one nobody asked for, which on-failure reads as a
clean stop. That is also what absorbs the boot race against Docker now
that lingering makes us start at boot.
This commit is contained in:
2026-08-06 11:00:13 +01:00
parent 40663373d4
commit bb5226a9a9
6 changed files with 192 additions and 8 deletions
+15
View File
@@ -64,3 +64,18 @@ jobs:
done done
echo "[nightly] Deployed:" echo "[nightly] Deployed:"
ls -lh "$DEST/" ls -lh "$DEST/"
- name: Publish the nightly installer
run: |
cd "${GITHUB_WORKSPACE:-.}"
# install-nightly.sh is served straight from the web root
# (curl -fsSL https://builds.skaldagent.net/install-nightly.sh | bash),
# so without this it stays whatever was copied there by hand and drifts
# from the repo — a fix to the installer would reach every existing box
# through update.sh but never a new one. Same atomic publish as the
# tarballs: a client mid-download never sees a half-written script.
ROOT=/var/www/builds.skaldagent.net
cp install-nightly.sh "$ROOT/.install-nightly.sh.tmp"
chmod 644 "$ROOT/.install-nightly.sh.tmp"
mv -f "$ROOT/.install-nightly.sh.tmp" "$ROOT/install-nightly.sh"
echo "[nightly] Published install-nightly.sh"
+15
View File
@@ -104,3 +104,18 @@ jobs:
printf '%s\n' "$VERSION" > "$DEST/.LATEST.tmp" printf '%s\n' "$VERSION" > "$DEST/.LATEST.tmp"
mv -f "$DEST/.LATEST.tmp" "$DEST/LATEST" mv -f "$DEST/.LATEST.tmp" "$DEST/LATEST"
echo "[release] Updated releases/LATEST → $VERSION" echo "[release] Updated releases/LATEST → $VERSION"
- name: Publish the release installer
run: |
cd "${GITHUB_WORKSPACE:-.}"
# install.sh is served straight from the web root
# (curl -fsSL https://builds.skaldagent.net/install.sh | bash), so
# without this it stays whatever was copied there by hand and drifts
# from the repo — a fix to the installer would reach every existing box
# through update.sh but never a new one. Published here rather than on
# every push so the served installer always matches a real release.
ROOT=/var/www/builds.skaldagent.net
cp install.sh "$ROOT/.install.sh.tmp"
chmod 644 "$ROOT/.install.sh.tmp"
mv -f "$ROOT/.install.sh.tmp" "$ROOT/install.sh"
echo "[release] Published install.sh"
+16
View File
@@ -98,6 +98,22 @@ systemd service → ExecStart=run.sh
**Fix**: removed `Requires=docker.service` from the user unit template in both install scripts. Kept `After=docker.service` (advisory, doesn't block if the unit isn't found). **Fix**: removed `Requires=docker.service` from the user unit template in both install scripts. Kept `After=docker.service` (advisory, doesn't block if the unit isn't found).
**Follow-up**: `After=docker.service` was dropped too. It never did anything — a _user_ manager has no view of system units, so the ordering was silently ignored rather than merely advisory, and keeping it suggested a guarantee that was not there. What actually handles the boot race is `Restart` (see below): the server fails fast when the Docker daemon is unreachable, and systemd brings it back a few seconds later.
## Bug fix: the server dies when you log out ✅
**Problem**: `systemctl --user start skald-circle` worked, but closing the SSH session killed the server — and it never came up at boot. Not an application bug: a `--user` unit runs under the per-user manager (`user@UID.service`), which systemd starts at first login and **stops when the user's last session ends**, tearing down every user service in the cgroup. No crash, no error in the journal — the whole cgroup is simply killed.
**Fix**: both installers now run `loginctl enable-linger $USER` after installing the unit (helper `enable_linger`, tried unprivileged first, then `sudo -n`, then interactive `sudo`, and only warns if all three fail — a missing linger must never abort an install). `update.sh` carries the same helper so an installation predating this fix is healed by an ordinary update.
**Also**: `Restart=on-failure``Restart=always`. `run.sh` exits 0 on _any_ graceful shutdown, including one nobody asked for (a stray SIGTERM to the server), which `on-failure` reads as a clean stop and leaves the box down. An explicit `systemctl --user stop` is unaffected — systemd never restarts after a requested stop. With lingering on, this is also what absorbs the boot race against Docker.
## Bug fix: update.sh never stopped or restarted the service ✅
**Problem**: `stop_service` and `start_service` matched `case "$OS" in Linux) … Darwin)`, but `$OS` had already been normalized to `linux`/`darwin` at the top of the script. Every branch fell through: both functions were no-ops. So the updater extracted the tarball **over the running binary** (`ETXTBSY` on Linux, aborting the update mid-way) and, when extraction did succeed, left the old build running in memory with the safety-net trap firing a restart that was itself a no-op. The careful stop → wait-for-exit → extract ordering the file documents at the top had not been executing at all.
**Fix**: matched the normalized lowercase values, with a comment at the seam saying why the capitalization is load-bearing.
## Bug fix: skald-setup non interattivo con curl | bash ✅ ## Bug fix: skald-setup non interattivo con curl | bash ✅
**Problem**: `skald-setup` controlla `isatty(0)`, ma con `curl ... | bash` stdin è un pipe, quindi saltava senza chiedere username/password. L'installer arrivava fino in fondo ma senza aver creato l'admin. **Problem**: `skald-setup` controlla `isatty(0)`, ma con `curl ... | bash` stdin è un pipe, quindi saltava senza chiedere username/password. L'installer arrivava fino in fondo ma senza aver creato l'admin.
+50 -2
View File
@@ -127,6 +127,43 @@ stop_existing_service() {
fi fi
} }
# ── systemd user lingering ────────────────────────────────────────────────────
# A `systemctl --user` unit runs under the per-user manager (user@UID.service),
# which systemd starts at first login and STOPS when the user's last session
# ends — taking every user service down with it. So without lingering the server
# dies the moment you close the SSH session that started it, and never comes up
# at boot. Enabling it is the whole difference between "runs while I'm logged
# in" and "is a daemon".
enable_linger() {
local target="${USER:-$(id -un)}"
if ! command -v loginctl >/dev/null 2>&1; then
warn "loginctl not found — cannot enable lingering."
echo " The server will stop when you log out of this machine."
return 0
fi
case "$(loginctl show-user "$target" --property=Linger 2>/dev/null || true)" in
*=yes) info "✔ Lingering already enabled for ${target}"; return 0 ;;
esac
# Enabling linger for yourself is normally allowed without elevation; fall
# back to sudo, non-interactive first so `curl | bash` never blocks on a
# password prompt it has no terminal to answer.
if loginctl enable-linger "$target" 2>/dev/null \
|| sudo -n loginctl enable-linger "$target" 2>/dev/null \
|| { [ "$IS_INTERACTIVE" = true ] && sudo loginctl enable-linger "$target"; }; then
info "✔ Lingering enabled — the server keeps running after you log out"
else
warn "Could not enable lingering for ${target}."
echo " Without it, the server stops as soon as your last session ends"
echo " and does not start at boot. Run this once, as an administrator:"
echo ""
echo " sudo loginctl enable-linger ${target}"
echo ""
fi
}
# ── Docker install helper ───────────────────────────────────────────────────── # ── Docker install helper ─────────────────────────────────────────────────────
install_docker() { install_docker() {
if [ "$OS" = "linux" ]; then if [ "$OS" = "linux" ]; then
@@ -349,13 +386,21 @@ if [ "$OS" = "linux" ] && [ -z "${NOSYSTEMD:-}" ]; then
[Unit] [Unit]
Description=Skald Circle (${DISPLAY_VERSION}) Description=Skald Circle (${DISPLAY_VERSION})
Documentation=https://skaldagent.net Documentation=https://skaldagent.net
After=network.target docker.service # No After=docker.service here: this is a *user* unit, and docker.service is a
# system unit the user manager knows nothing about — the dependency would be
# silently ignored. Docker may therefore still be starting when we do; the
# server fails fast when the daemon is unreachable and Restart brings it back a
# few seconds later, so boot ordering settles itself.
[Service] [Service]
Type=simple Type=simple
ExecStart=${INSTALL_DIR}/run.sh ExecStart=${INSTALL_DIR}/run.sh
WorkingDirectory=${INSTALL_DIR} WorkingDirectory=${INSTALL_DIR}
Restart=on-failure # always, not on-failure: run.sh exits 0 on any graceful shutdown, including one
# nobody asked for (a stray SIGTERM to the server), which on-failure would treat
# as a clean stop and leave the box down. An explicit "systemctl --user stop"
# is unaffected — systemd never restarts after a requested stop.
Restart=always
RestartSec=5 RestartSec=5
Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald
Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup
@@ -368,6 +413,9 @@ SERVICE
systemctl --user enable --now skald-circle.service systemctl --user enable --now skald-circle.service
info "✔ Service installed and started" info "✔ Service installed and started"
enable_linger
echo "" echo ""
echo " Status: systemctl --user status skald-circle" echo " Status: systemctl --user status skald-circle"
echo " Logs: journalctl --user -u skald-circle -f" echo " Logs: journalctl --user -u skald-circle -f"
+50 -2
View File
@@ -130,6 +130,43 @@ stop_existing_service() {
fi fi
} }
# ── systemd user lingering ────────────────────────────────────────────────────
# A `systemctl --user` unit runs under the per-user manager (user@UID.service),
# which systemd starts at first login and STOPS when the user's last session
# ends — taking every user service down with it. So without lingering the server
# dies the moment you close the SSH session that started it, and never comes up
# at boot. Enabling it is the whole difference between "runs while I'm logged
# in" and "is a daemon".
enable_linger() {
local target="${USER:-$(id -un)}"
if ! command -v loginctl >/dev/null 2>&1; then
warn "loginctl not found — cannot enable lingering."
echo " The server will stop when you log out of this machine."
return 0
fi
case "$(loginctl show-user "$target" --property=Linger 2>/dev/null || true)" in
*=yes) info "✔ Lingering already enabled for ${target}"; return 0 ;;
esac
# Enabling linger for yourself is normally allowed without elevation; fall
# back to sudo, non-interactive first so `curl | bash` never blocks on a
# password prompt it has no terminal to answer.
if loginctl enable-linger "$target" 2>/dev/null \
|| sudo -n loginctl enable-linger "$target" 2>/dev/null \
|| { [ "$IS_INTERACTIVE" = true ] && sudo loginctl enable-linger "$target"; }; then
info "✔ Lingering enabled — the server keeps running after you log out"
else
warn "Could not enable lingering for ${target}."
echo " Without it, the server stops as soon as your last session ends"
echo " and does not start at boot. Run this once, as an administrator:"
echo ""
echo " sudo loginctl enable-linger ${target}"
echo ""
fi
}
# ── Docker install helper ───────────────────────────────────────────────────── # ── Docker install helper ─────────────────────────────────────────────────────
install_docker() { install_docker() {
if [ "$OS" = "linux" ]; then if [ "$OS" = "linux" ]; then
@@ -354,13 +391,21 @@ if [ "$OS" = "linux" ] && [ -z "${NOSYSTEMD:-}" ]; then
[Unit] [Unit]
Description=Skald Circle (release ${VERSION}) Description=Skald Circle (release ${VERSION})
Documentation=https://skaldagent.net Documentation=https://skaldagent.net
After=network.target docker.service # No After=docker.service here: this is a *user* unit, and docker.service is a
# system unit the user manager knows nothing about — the dependency would be
# silently ignored. Docker may therefore still be starting when we do; the
# server fails fast when the daemon is unreachable and Restart brings it back a
# few seconds later, so boot ordering settles itself.
[Service] [Service]
Type=simple Type=simple
ExecStart=${INSTALL_DIR}/run.sh ExecStart=${INSTALL_DIR}/run.sh
WorkingDirectory=${INSTALL_DIR} WorkingDirectory=${INSTALL_DIR}
Restart=on-failure # always, not on-failure: run.sh exits 0 on any graceful shutdown, including one
# nobody asked for (a stray SIGTERM to the server), which on-failure would treat
# as a clean stop and leave the box down. An explicit "systemctl --user stop"
# is unaffected — systemd never restarts after a requested stop.
Restart=always
RestartSec=5 RestartSec=5
Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald
Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup
@@ -373,6 +418,9 @@ SERVICE
systemctl --user enable --now skald-circle.service systemctl --user enable --now skald-circle.service
info "✔ Service installed and started" info "✔ Service installed and started"
enable_linger
echo "" echo ""
echo " Status: systemctl --user status skald-circle" echo " Status: systemctl --user status skald-circle"
echo " Logs: journalctl --user -u skald-circle -f" echo " Logs: journalctl --user -u skald-circle -f"
+46 -4
View File
@@ -43,6 +43,13 @@ fi
CHANNEL="$(tr -d '[:space:]' < "$CHANNEL_FILE")" CHANNEL="$(tr -d '[:space:]' < "$CHANNEL_FILE")"
# ── Detect interactive stdin ──────────────────────────────────────────────────
if [ -t 0 ]; then
IS_INTERACTIVE=true
else
IS_INTERACTIVE=false
fi
# ── Colours (if terminal) ───────────────────────────────────────────────────── # ── Colours (if terminal) ─────────────────────────────────────────────────────
if [ -t 1 ]; then if [ -t 1 ]; then
RED='\033[0;31m' RED='\033[0;31m'
@@ -96,9 +103,15 @@ STOPPED=0 # set once the service has been stopped
STARTED=0 # set once it has been (re)started STARTED=0 # set once it has been (re)started
# ── Stop service ────────────────────────────────────────────────────────────── # ── Stop service ──────────────────────────────────────────────────────────────
# NOTE: match the *normalized* OS values set above (linux/darwin), not uname's
# capitalized output. Getting this wrong turns both stop_service and
# start_service into silent no-ops, and then none of the ordering this file
# documents at the top actually happens: the tarball is extracted over the
# running binary (ETXTBSY on Linux, aborting the update mid-way), and the
# safety-net restart in cleanup() is a no-op too, so the box stays down.
stop_service() { stop_service() {
case "$OS" in case "$OS" in
Linux) linux)
if command -v systemctl >/dev/null 2>&1; then if command -v systemctl >/dev/null 2>&1; then
if systemctl --user is-active skald-circle.service >/dev/null 2>&1; then if systemctl --user is-active skald-circle.service >/dev/null 2>&1; then
info "⏹️ Stopping service …" info "⏹️ Stopping service …"
@@ -106,7 +119,7 @@ stop_service() {
fi fi
fi fi
;; ;;
Darwin) darwin)
if command -v launchctl >/dev/null 2>&1; then if command -v launchctl >/dev/null 2>&1; then
if launchctl list com.skald.circle >/dev/null 2>&1; then if launchctl list com.skald.circle >/dev/null 2>&1; then
info "⏹️ Stopping agent …" info "⏹️ Stopping agent …"
@@ -145,13 +158,13 @@ wait_until_stopped() {
# ── Start service ───────────────────────────────────────────────────────────── # ── Start service ─────────────────────────────────────────────────────────────
start_service() { start_service() {
case "$OS" in case "$OS" in
Linux) linux)
if command -v systemctl >/dev/null 2>&1; then if command -v systemctl >/dev/null 2>&1; then
info "▶ Starting service …" info "▶ Starting service …"
systemctl --user start skald-circle.service systemctl --user start skald-circle.service
fi fi
;; ;;
Darwin) darwin)
if command -v launchctl >/dev/null 2>&1; then if command -v launchctl >/dev/null 2>&1; then
info "▶ Starting agent …" info "▶ Starting agent …"
launchctl load "$HOME/Library/LaunchAgents/com.skald.circle.plist" 2>/dev/null || true launchctl load "$HOME/Library/LaunchAgents/com.skald.circle.plist" 2>/dev/null || true
@@ -160,6 +173,34 @@ start_service() {
esac esac
} }
# ── systemd user lingering ────────────────────────────────────────────────────
# Same helper the installers run, repeated here so an install predating it gets
# healed by an ordinary update: a `systemctl --user` unit lives under the
# per-user manager, which systemd stops when the user's last session ends —
# so without lingering the server dies at logout and never starts at boot.
# Idempotent, and a failure is only ever a warning: the update itself is fine.
ensure_linger() {
[ "$OS" = "linux" ] || return 0
local target="${USER:-$(id -un)}"
command -v loginctl >/dev/null 2>&1 || return 0
case "$(loginctl show-user "$target" --property=Linger 2>/dev/null || true)" in
*=yes) return 0 ;;
esac
if loginctl enable-linger "$target" 2>/dev/null \
|| sudo -n loginctl enable-linger "$target" 2>/dev/null \
|| { [ "$IS_INTERACTIVE" = true ] && sudo loginctl enable-linger "$target"; }; then
info "✔ Lingering enabled — the server now survives logout and starts at boot"
else
warn "Lingering is not enabled for ${target}."
echo " The server stops when your last session ends. Run this once:"
echo " sudo loginctl enable-linger ${target}"
fi
}
# ── Cleanup + safety net ────────────────────────────────────────────────────── # ── Cleanup + safety net ──────────────────────────────────────────────────────
# Runs on every exit. Removes temp files and, if the update died after the # Runs on every exit. Removes temp files and, if the update died after the
# service was stopped but before it came back up, makes a best-effort restart so # service was stopped but before it came back up, makes a best-effort restart so
@@ -279,6 +320,7 @@ main() {
fi fi
# ── Restart ──────────────────────────────────────────────────────────────── # ── Restart ────────────────────────────────────────────────────────────────
ensure_linger
start_service start_service
STARTED=1 STARTED=1