Files
Skald-Circle/install-nightly.sh
Daniele 67fc1455c5
Nightly Build / build (push) Successful in 4m5s
fix(mcp): a failed handshake must not strand the child process
An MCP server whose `initialize` answer is an error — a broken or version-
mismatched connector — starts fine and then never exits. `McpServer::start`
returned `Err` correctly, but the `Child` lives in the read-loop task rather
than in the returned value, so `kill_on_drop` followed a task nothing ever
drops. Every retry therefore left a live process holding three pipes and a
pidfd, and the supervisor's retry ceiling is deliberately not permanent.

The end state was not a dead connector but a dead instance: the process hit
its 1024-descriptor limit, `accept()` began failing with EMFILE, and incoming
connections queued on a socket nobody could accept from — while the process,
the port and every other connector still looked healthy. Observed in
production at ~5h from the first bad handshake to unreachable, with 229
orphaned interpreters.

`stop_server`/`stop_all` had the same hole from the other side: they document
the dropped handle as killing the process, but the task holds its end of
stdin, so the child stayed blocked on a read that would never return.

Both close with one seam. `McpServer` now owns a oneshot sender whose receiver
the read-loop selects on; nothing ever sends, so the drop is the message. That
covers a deliberate stop, the last `Arc` going away, and a `?` in `start()`
unwinding past the local binding before it was ever returned — including the
caller's `timeout`, which drops the same future. The loop then kills and, as
importantly, reaps: an unreaped child trades the orphan for a zombie holding
the same pipes.

Both leaks are covered by tests that fail without the fix.

Also raise LimitNOFILE to 65536: the installers write it, and update.sh heals
an existing unit additively, leaving an admin's own value alone. The leak is
the bug, but 1024 for a process sharing descriptors between the listener,
every user's SQLite handles and three pipes per connector is thin regardless.
2026-08-24 17:34:44 +01:00

561 lines
23 KiB
Bash
Executable File

#!/usr/bin/env sh
# install-nightly.sh — install the latest nightly build of Skald Circle
#
# Usage:
# curl -fsSL https://builds.skaldagent.net/install-nightly.sh | bash
#
# Supports Linux (systemd) and macOS ARM64 (launchd).
# Default install dir: ~/.local/share/skald-circle (override with SKALD_DIR).
#
# If Docker is missing, the installer can optionally install it.
set -eu
# ── User overrides ────────────────────────────────────────────────────────────
INSTALL_DIR="${SKALD_DIR:-$HOME/.local/share/skald-circle}"
CHANNEL="${SKALD_CHANNEL:-nightly}"
# ── Detect interactive stdin ──────────────────────────────────────────────────
if [ -t 0 ]; then
IS_INTERACTIVE=true
else
IS_INTERACTIVE=false
fi
# ── Colours (if terminal) ─────────────────────────────────────────────────────
if [ -t 1 ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
info() { printf "${GREEN}%s${NC}\n" "$*"; }
warn() { printf "${YELLOW}⚠ %s${NC}\n" "$*"; }
err() { printf "${RED}✖ %s${NC}\n" "$*"; }
header(){ printf "\n${BOLD}%s${NC}\n" "$*"; }
banner(){ printf "\n${CYAN}${BOLD}%s${NC}\n" "$*"; }
# ── Network IP helper ──────────────────────────────────────────────────────────
# Returns the primary non-loopback network IP, or empty string if unavailable.
get_network_ip() {
if command -v ip >/dev/null 2>&1; then
ip route get 1 2>/dev/null | awk '{print $7}'
elif command -v ifconfig >/dev/null 2>&1; then
ifconfig 2>/dev/null | grep -E 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -1
elif command -v hostname >/dev/null 2>&1; then
hostname -I 2>/dev/null | awk '{print $1}'
fi
}
# ── Prompt helpers (work from piped stdin too) ────────────────────────────────
prompt_enter() {
local msg="${1:-Press Enter to continue or Ctrl+C to cancel}"
if [ "$IS_INTERACTIVE" = true ]; then
printf "%s" "$msg" >&2
read -r _ || true
elif (: </dev/tty) 2>/dev/null; then
printf "%s" "$msg" >/dev/tty
IFS= read -r _ </dev/tty || true
fi
}
prompt_yes_no() {
local msg="${1:-Continue? [Y/n]}"
local ans
if [ "$IS_INTERACTIVE" = true ]; then
printf "%s" "$msg" >&2
read -r ans || ans="y"
elif (: </dev/tty) 2>/dev/null; then
printf "%s" "$msg" >/dev/tty
IFS= read -r ans </dev/tty || ans="y"
else
ans="n"
fi
case "$(printf "%s" "$ans" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" in
""|"y"|"yes") return 0 ;;
*) return 1 ;;
esac
}
# Like prompt_yes_no but defaults to NO — for destructive confirmations.
prompt_yes_no_default_no() {
local msg="${1:-Continue? [y/N]}"
local ans=""
if [ "$IS_INTERACTIVE" = true ]; then
printf "%s" "$msg" >&2
read -r ans || ans=""
elif (: </dev/tty) 2>/dev/null; then
printf "%s" "$msg" >/dev/tty
IFS= read -r ans </dev/tty || ans=""
fi
case "$(printf "%s" "$ans" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" in
"y"|"yes") return 0 ;;
*) return 1 ;;
esac
}
# ── Stop a running instance (for reinstall over an existing install) ──────────
# Stops the service and waits, bounded, for the process to exit — so the
# extraction never overwrites a live binary (ETXTBSY on Linux).
stop_existing_service() {
case "$OS" in
linux)
command -v systemctl >/dev/null 2>&1 && \
systemctl --user stop skald-circle.service 2>/dev/null || true
;;
darwin)
command -v launchctl >/dev/null 2>&1 && \
launchctl unload "$HOME/Library/LaunchAgents/com.skald.circle.plist" 2>/dev/null || true
;;
esac
if command -v pgrep >/dev/null 2>&1; then
local i=0
while pgrep -f "${INSTALL_DIR}/bin/skald" >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -gt 20 ] && break
sleep 1
done
else
sleep 2
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 ─────────────────────────────────────────────────────
install_docker() {
if [ "$OS" = "linux" ]; then
info "▶ Installing Docker via get.docker.com …"
curl -fsSL https://get.docker.com | sh
info "✔ Docker installed"
if command -v usermod >/dev/null 2>&1; then
sudo usermod -aG docker "$USER"
warn "You may need to log out and back in for the docker group to take effect."
fi
info "✔ User added to docker group"
elif [ "$OS" = "darwin" ]; then
if command -v brew >/dev/null 2>&1; then
info "▶ Installing Docker Desktop via Homebrew …"
brew install --cask docker
info "✔ Docker Desktop installed. Open it from Applications to complete setup."
else
err "Homebrew not found. Please install Docker Desktop manually from:"
err " https://docs.docker.com/desktop/setup/install/mac-install/"
err "Then re-run this installer."
exit 1
fi
fi
}
ask_install_docker() {
echo ""
header "🐳 Docker"
echo ""
if command -v docker >/dev/null 2>&1 && docker version >/dev/null 2>&1; then
info "✔ Docker is installed and the daemon is running."
return 0
elif command -v docker >/dev/null 2>&1; then
echo " Docker CLI is present but the daemon is not running."
echo " Please start Docker before starting the server."
echo ""
return 0
else
warn "Docker is required but not found."
echo ""
echo " Skald uses Docker to run sandboxed user containers."
echo " The server will not start without it."
echo ""
if prompt_yes_no " Install Docker now? [Y/n] "; then
install_docker
echo ""
else
warn "Skipping Docker installation."
echo " You can install it later: https://docs.docker.com/engine/install/"
echo ""
fi
fi
}
# ── Optional dependency checks ────────────────────────────────────────────────
check_optional_deps() {
echo ""
header "🔧 Optional dependencies"
echo ""
if command -v python3 >/dev/null 2>&1; then
info "✔ Python 3 found ($(python3 --version 2>&1 | head -1))"
else
warn "Python 3 not found — the TTS plugins and host-run connectors will not work."
echo " Install it from https://www.python.org/downloads/"
echo ""
fi
if command -v node >/dev/null 2>&1; then
info "✔ Node.js found ($(node --version 2>&1 | head -1))"
else
warn "Node.js not found — WhatsApp MCP server will not work."
echo " Install it from https://nodejs.org/ (version 18 or later)"
echo ""
fi
}
# ── Platform detection ────────────────────────────────────────────────────────
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux) OS="linux" ;;
Darwin) OS="darwin" ;;
*) err "Unsupported OS: $OS"; exit 1 ;;
esac
case "$ARCH" in
x86_64)
ARCH="amd64"
if [ "$OS" = "darwin" ]; then
err "Intel Macs are not supported. Apple Silicon (M1+) only."
exit 1
fi
;;
aarch64|arm64)
ARCH="arm64"
;;
*) err "Unsupported architecture: $ARCH"; exit 1 ;;
esac
# ── Dependency checks ─────────────────────────────────────────────────────────
command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; }
if [ "$OS" = "linux" ]; then
command -v systemctl >/dev/null 2>&1 || { warn "systemd not found — service will not be installed automatically."; NOSYSTEMD=1; }
elif [ "$OS" = "darwin" ]; then
command -v launchctl >/dev/null 2>&1 || { err "launchctl not found."; exit 1; }
fi
# ── Banner + fetch latest nightly ─────────────────────────────────────────────
banner "╔══════════════════════════════════════════╗"
banner "║ Skald Circle — Nightly Installer ║"
banner "╚══════════════════════════════════════════╝"
echo ""
info "🔍 Looking up latest nightly build …"
BASE_URL="https://builds.skaldagent.net"
case "$CHANNEL" in
nightly)
TARBALL_URL="${BASE_URL}/nightly/skald-circle-nightly-${OS}-${ARCH}.tar.gz"
VERSION="nightly-$(date -u +%Y%m%d)"
DISPLAY_VERSION="nightly"
;;
*)
err "Unknown channel: $CHANNEL. Use SKALD_CHANNEL=nightly (default)."
exit 1
;;
esac
# ── Summary + confirmation ────────────────────────────────────────────────────
echo ""
header "Installation summary"
echo ""
echo " What : Skald Circle (${DISPLAY_VERSION})"
echo " Platform : ${OS}/${ARCH}"
echo " Install to : ${INSTALL_DIR}"
echo " Service : $( [ "$OS" = "linux" ] && echo "systemd (user)" || echo "launchd" )"
echo ""
echo " The installer will check for Docker and offer to install it if missing."
echo " Python 3 and Node.js are optional — needed for some MCP servers."
echo ""
prompt_enter "Press Enter to continue or Ctrl+C to cancel "
# ── Docker check & install ────────────────────────────────────────────────────
ask_install_docker
# ── Optional dependency check ─────────────────────────────────────────────────
check_optional_deps
# ── Existing installation? ────────────────────────────────────────────────────
# This installer targets a fresh install; the supported in-place upgrade path is
# update.sh (keeps your data, restarts the service safely). If an install is
# already here, point the user there and only reinstall over it on request.
if [ -x "$INSTALL_DIR/bin/skald" ]; then
echo ""
warn "An existing installation was found at ${INSTALL_DIR}."
echo " To upgrade in place (keeping your database and config), use:"
echo " ${INSTALL_DIR}/update.sh"
echo ""
if prompt_yes_no_default_no " Reinstall over it instead? Data is kept, the service restarts. [y/N] "; then
info "⏹️ Stopping the running instance before reinstalling …"
stop_existing_service
else
info "Aborted — run ${INSTALL_DIR}/update.sh to upgrade."
exit 0
fi
echo ""
fi
# ── Download & extract ────────────────────────────────────────────────────────
# Download to a temp file and verify the archive BEFORE touching the install dir
# — the same ordering update.sh uses, and for the same reason. Piping curl
# straight into tar half-extracts a truncated download, which on the
# reinstall-over-an-existing-install path above leaves a tree mixing old and new
# files: worse than either version, and with no error to say so.
info "↓ Downloading Skald Circle (${DISPLAY_VERSION}) …"
TMP_TARBALL="$(mktemp -t skald-install.XXXXXX.tar.gz)"
STAGING="$(mktemp -d -t skald-install-staging.XXXXXX)"
trap 'rm -f "$TMP_TARBALL" 2>/dev/null || true; rm -rf "$STAGING" 2>/dev/null || true' EXIT
curl -fsSL -o "$TMP_TARBALL" "$TARBALL_URL"
info "🔎 Verifying archive …"
tar xzf "$TMP_TARBALL" -C "$STAGING" --strip-components=1
if [ ! -x "$STAGING/bin/skald" ]; then
err "Downloaded archive is invalid — skald binary not found."
err "Nothing was written to ${INSTALL_DIR}."
exit 1
fi
mkdir -p "$INSTALL_DIR"
tar xzf "$TMP_TARBALL" -C "$INSTALL_DIR" --strip-components=1
if [ ! -x "$INSTALL_DIR/bin/skald" ]; then
err "Extraction failed — skald binary not found."
exit 1
fi
info "✔ Extracted to ${INSTALL_DIR}"
# ── Python venv (best-effort) ─────────────────────────────────────────────────
# Create the venv inline instead of calling run.sh (which also launches the
# server and would hang the installer).
info "🔧 Setting up Python virtual environment …"
VENV_DIR="${INSTALL_DIR}/.venv"
REQUIREMENTS="${INSTALL_DIR}/requirements.txt"
# A venv is usable only if python3 AND pip both work. Ubuntu's `python3 -m venv`
# without the python3-venv package leaves a venv with python3 but no pip — detect
# that and recreate, so a broken venv never survives a restart.
if [ ! -f "$VENV_DIR/bin/python3" ] || ! "$VENV_DIR/bin/python3" -m pip --version >/dev/null 2>&1; then
rm -rf "$VENV_DIR"
if command -v uv >/dev/null 2>&1; then
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
&& info "✔ Python venv ready (uv)" \
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
elif command -v python3 >/dev/null 2>&1; then
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
&& info "✔ Python venv ready (pip)" \
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
else
warn "python3 not found — the TTS plugins and host-run connectors will be unavailable."
fi
else
info "✔ Python venv already exists"
fi
# ── Install daemon ────────────────────────────────────────────────────────────
if [ "$OS" = "linux" ] && [ -z "${NOSYSTEMD:-}" ]; then
header "⚡ Installing systemd user service …"
mkdir -p "$HOME/.config/systemd/user"
cat > "$HOME/.config/systemd/user/skald-circle.service" <<- SERVICE
[Unit]
Description=Skald Circle (${DISPLAY_VERSION})
Documentation=https://skaldagent.net
# 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]
Type=simple
ExecStart=${INSTALL_DIR}/run.sh
WorkingDirectory=${INSTALL_DIR}
# 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
# The default soft limit is 1024, which one process shares between the HTTP
# listener, every user's SQLite handles and three pipes per connector process.
# Running out does not degrade gracefully: accept() starts failing with EMFILE
# and the whole app stops answering while still looking healthy from outside.
LimitNOFILE=65536
Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald
Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup
[Install]
WantedBy=default.target
SERVICE
systemctl --user daemon-reload
systemctl --user enable --now skald-circle.service
info "✔ Service installed and started"
enable_linger
echo ""
echo " Status: systemctl --user status skald-circle"
echo " Logs: journalctl --user -u skald-circle -f"
elif [ "$OS" = "darwin" ]; then
header "⚡ Installing launchd agent …"
mkdir -p "$HOME/Library/LaunchAgents" "$INSTALL_DIR/logs"
PLIST="$HOME/Library/LaunchAgents/com.skald.circle.plist"
cat > "$PLIST" <<- PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.skald.circle</string>
<key>ProgramArguments</key>
<array>
<string>${INSTALL_DIR}/run.sh</string>
</array>
<key>WorkingDirectory</key>
<string>${INSTALL_DIR}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${INSTALL_DIR}/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>${INSTALL_DIR}/logs/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>SKALD_BIN</key>
<string>${INSTALL_DIR}/bin/skald</string>
<key>SKALD_SETUP_BIN</key>
<string>${INSTALL_DIR}/bin/skald-setup</string>
</dict>
</dict>
</plist>
PLIST
# Idempotent: unload any previously-loaded agent first, so a re-install
# reloads the freshly written plist instead of failing on "already loaded".
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load "$PLIST"
info "✔ Agent installed and started"
echo ""
echo " Status: launchctl list com.skald.circle"
echo " Logs: tail -f ${INSTALL_DIR}/logs/stdout.log"
elif [ -n "${NOSYSTEMD:-}" ]; then
warn "systemd not available — start manually: ${INSTALL_DIR}/run.sh"
fi
# ── Welcome + first-run setup ─────────────────────────────────────────────────
echo ""
header "👋 Welcome to Skald Circle!"
if [ -x "$INSTALL_DIR/bin/skald-setup" ]; then
echo ""
echo " The server is running. Now let's create your admin account."
echo " You'll be asked for a username and password."
echo ""
# skald-setup uses the relative path `database/system.db`, so we cd to the
# install directory first. When running via curl | bash the cwd is ~, which
# would create `~/database/system.db` instead of the correct location.
cd "$INSTALL_DIR"
if [ "$IS_INTERACTIVE" = true ]; then
"bin/skald-setup"
elif (: </dev/tty) 2>/dev/null; then
"bin/skald-setup" </dev/tty
else
echo " From a terminal, run:"
echo " cd ${INSTALL_DIR} && ./bin/skald-setup"
fi
echo ""
fi
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
info "✅ Skald Circle (${DISPLAY_VERSION}) installed successfully!"
echo ""
# Write channel tag for future updates
echo "$CHANNEL" > "$INSTALL_DIR/.release-channel"
# ── Hint about optional deps ──────────────────────────────────────────────────
if [ -f "${INSTALL_DIR}/requirements-optional.txt" ]; then
echo ""
info "💡 Optional GPU/ML dependencies available:"
info " ${INSTALL_DIR}/requirements-optional.txt"
echo " Install them manually if you use the Orpheus TTS plugin:"
echo " cd ${INSTALL_DIR} && .venv/bin/pip install -r requirements-optional.txt"
fi
echo " Server : ${INSTALL_DIR}/run.sh"
echo " Binary : ${INSTALL_DIR}/bin/skald"
echo " Setup : ${INSTALL_DIR}/bin/skald-setup"
NET_IP="$(get_network_ip)"
ADMIN_URL="${NET_IP:+http://${NET_IP}:9000}"
echo " Admin console: ${ADMIN_URL:-http://localhost:9000} (on the machine, or use the IP above from another device)"
echo " Server IP: ${NET_IP:-localhost} (network address if available)"
echo ""
echo ""
echo " Start: $( [ "$OS" = "linux" ] && echo "systemctl --user start skald-circle" || echo "launchctl start com.skald.circle" )"
echo " Stop: $( [ "$OS" = "linux" ] && echo "systemctl --user stop skald-circle" || echo "launchctl stop com.skald.circle" )"
echo " Logs: $( [ "$OS" = "linux" ] && echo "journalctl --user -u skald-circle -f" || echo "tail -f ${INSTALL_DIR}/logs/stdout.log" )"
echo ""