horizon-server on Linux: say which libraries the game needs, and follow updates

On a Linux without ALSA's library (most minimal server images), the
game exits at once with the loader's "error while loading shared
libraries: libasound.so.2". The supervisor took that for a crash and
restarted it five times over two and a half minutes before giving up
with "see logs/latest.log", which holds nothing about it.

Now:
- `run` starts the game with --help first (it loads no game files) and,
  if the system can't load it, stops at once with the loader's line and
  the packages to install on Debian/Ubuntu, Fedora, Arch and NixOS;
- `init` prints the same as a warning after "Game:";
- install.sh lists the libraries `ldd` can't find, with the same advice;
- docs/server.md says so in the install section.

Also, a service installed without the installer's launcher on the PATH
ran `<dir>/releases/<tag>/horizon-server`: Linux reports the running
program with links resolved, so the unit pinned that release and an
update never reached it. The fallback now goes through `<dir>/current`
while it points at that release.

Found by the end-to-end install check on the ThinkPad (NixOS, no
nix-ld libraries) with v2026.10.18.15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-18 18:15:17 +02:00
parent 44fba3bf9d
commit 4ea2243e4d
6 changed files with 155 additions and 2 deletions
+92
View File
@@ -49,6 +49,52 @@ pub fn program(config: &Config, dir: &Path) -> Result<PathBuf, String> {
})
}
/// Checks that the game program starts at all: `--help` loads no game files, so it fails only when the
/// system can't load the program, as on a Linux without the ALSA and udev libraries it links (even a
/// server needs them until there is a headless engine). The reason, with what to install.
pub fn check_starts(program: &Path) -> Result<(), String> {
let output = Command::new(program)
.arg("--help")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("the game ({}) can't start: {e}", program.display()))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(start_failure(program, output.status.code(), &stderr))
}
/// What to tell the owner when the game program exits at once.
fn start_failure(program: &Path, code: Option<i32>, stderr: &str) -> String {
let reason = stderr
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
let mut text = format!(
"the game ({}) can't start{}{}",
program.display(),
code.map_or(String::new(), |c| format!(" (exit status {c})")),
if reason.is_empty() {
String::new()
} else {
format!(": {reason}")
}
);
if reason.contains("error while loading shared libraries") {
text.push_str(
"\nIt needs ALSA's and udev's libraries (libasound.so.2, libudev.so.1), even on a server. Install \
them:\n Debian, Ubuntu: sudo apt install libasound2 libudev1 (Ubuntu 24.04 and later: \
libasound2t64)\n Fedora, RHEL: sudo dnf install alsa-lib systemd-libs\n Arch: sudo pacman -S \
alsa-lib systemd-libs\n NixOS: enable programs.nix-ld with alsa-lib and udev in its libraries",
);
}
text
}
/// Where the prepared game files may be, in the order they are tried.
pub fn resource_candidates(config: &Config, dir: &Path, program: Option<&Path>) -> Vec<PathBuf> {
if !config.engine.resources.is_empty() {
@@ -195,4 +241,50 @@ mod tests {
assert_eq!(resources(&config, &server, Some(&program)), Ok(prepared));
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn a_missing_library_says_what_to_install() {
let program = Path::new("/opt/horizon/horizon-client");
let text = start_failure(
program,
Some(127),
"/opt/horizon/horizon-client: error while loading shared libraries: libasound.so.2: cannot open \
shared object file: No such file or directory\n",
);
assert!(text.starts_with(
"the game (/opt/horizon/horizon-client) can't start (exit status 127): /opt/horizon/horizon-client: \
error while loading shared libraries: libasound.so.2"
));
assert!(text.contains("sudo apt install libasound2 libudev1"));
let other = start_failure(program, Some(1), "something else\n");
assert_eq!(
other,
"the game (/opt/horizon/horizon-client) can't start (exit status 1): something else"
);
}
#[cfg(unix)]
#[test]
fn the_check_runs_the_program() {
use std::os::unix::fs::PermissionsExt;
let root =
std::env::temp_dir().join(format!("horizon-server-check-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let script = |name: &str, body: &str| {
let path = root.join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
};
assert_eq!(check_starts(&script("starts", "exit 0")), Ok(()));
let fails = script("fails", "echo 'no luck' >&2; exit 3");
assert_eq!(
check_starts(&fails),
Err(format!(
"the game ({}) can't start (exit status 3): no luck",
fails.display()
))
);
std::fs::remove_dir_all(&root).unwrap();
}
}
+3
View File
@@ -205,6 +205,9 @@ pub fn run(dir: &Path, answers: Answers) -> Result<(), String> {
match engine::program(&cfg, dir) {
Ok(program) => {
println!("Game: {}", program.display());
if let Err(e) = engine::check_starts(&program) {
println!("Warning: {e}");
}
match engine::resources(&cfg, dir, Some(&program)) {
Ok(resources) => println!("Game files: {}", resources.display()),
Err(e) => println!("{e}"),
+41 -2
View File
@@ -32,7 +32,8 @@ pub fn name(dir: &Path) -> String {
}
/// The program the service runs: the `horizon-server` on the PATH (the installer's launcher, which keeps
/// running the current version after an update), else this program.
/// running the current version after an update), else this program, through the installer's `current`
/// link when it runs from an installed release.
fn program() -> Result<PathBuf, String> {
let on_path = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
@@ -42,10 +43,30 @@ fn program() -> Result<PathBuf, String> {
.find(|p| p.is_file());
match on_path {
Some(path) => Ok(path),
None => std::env::current_exe().map_err(|e| e.to_string()),
None => std::env::current_exe()
.map(|exe| through_current(&exe))
.map_err(|e| e.to_string()),
}
}
/// `<dir>/releases/<tag>/horizon-server` as `<dir>/current/horizon-server` while `current` points at that
/// release (install.sh and install.ps1's layout), so an update applies at the next restart. Linux reports
/// the running program with links resolved, so the release's own path is what this program sees there.
fn through_current(exe: &Path) -> PathBuf {
let current = (|| {
let release = exe.parent()?;
let releases = release.parent()?;
if releases.file_name()? != "releases" {
return None;
}
let current = releases.parent()?.join("current");
let name = exe.file_name()?;
let same = std::fs::canonicalize(&current).ok()? == std::fs::canonicalize(release).ok()?;
same.then(|| current.join(name))
})();
current.unwrap_or_else(|| exe.to_path_buf())
}
/// A systemd unit: a user unit, or with `user` a system unit running as that user.
pub fn systemd_unit(program: &Path, dir: &Path, user: Option<&str>) -> String {
let (run_as, wanted_by) = match user {
@@ -374,4 +395,22 @@ mod tests {
assert!(plist.contains("<string>/srv/R&amp;D</string>"));
assert!(plist.contains("<string>/srv/R&amp;D/logs/supervisor.log</string>"));
}
#[cfg(unix)]
#[test]
fn an_installed_release_runs_through_current() {
let root =
std::env::temp_dir().join(format!("horizon-service-current-{}", std::process::id()));
let release = root.join("releases/v1");
std::fs::create_dir_all(&release).unwrap();
let exe = release.join("horizon-server");
std::fs::write(&exe, "").unwrap();
// Not the release in use (or no installer layout): this program as it is.
assert_eq!(through_current(&exe), exe);
std::os::unix::fs::symlink(&release, root.join("current")).unwrap();
assert_eq!(through_current(&exe), root.join("current/horizon-server"));
let other = root.join("bin/horizon-server");
assert_eq!(through_current(&other), other);
std::fs::remove_dir_all(&root).unwrap();
}
}
+2
View File
@@ -76,6 +76,8 @@ pub fn run(dir: &Path) -> Result<(), String> {
let config = Config::load(dir)?;
let program = engine::program(&config, dir)?;
let resources = engine::resources(&config, dir, Some(&program))?;
// A game the system can't load would only crash again at every restart: say why at once.
engine::check_starts(&program)?;
let _lock = lock(dir)?;
let shared = Shared::new(dir);
let _endpoint = api::start(shared.clone())?;
+5
View File
@@ -185,6 +185,11 @@ comes out of the headless-engine work.
it from GitHub Releases, and checks it against the SHA-256 GitHub publishes for each asset. It never asks
for sudo. Options: `--version vX.Y.Z`, `--dir` (default `~/.local/share/horizon`), `--bin` (default
`~/.local/bin`).
On Linux the game links ALSA's and udev's libraries (`libasound.so.2`, `libudev.so.1`) even as a server,
until the headless engine exists. Minimal server images often lack ALSA (Debian and Ubuntu:
`sudo apt install libasound2 libudev1`, `libasound2t64` on Ubuntu 24.04 and later). The script lists any
that are missing, `init` warns, and `run` stops at once with what to install, rather than restarting a
game that can't load.
- Windows:
`irm https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.ps1 | iex`
The script does the same into `%LOCALAPPDATA%\Horizon` (`-Version`, `-Dir`), with junctions instead of
+12
View File
@@ -86,6 +86,18 @@ for tool in horizon-server horizon-prepare; do
done
echo "Installed Horizon $TAG in $RELEASE; horizon-server and horizon-prepare are in $BIN."
# The game links ALSA and udev, even as a server; minimal Linux images often lack them.
if [ "$(uname -s)" = Linux ] && command -v ldd >/dev/null; then
MISSING=$(ldd "$RELEASE/horizon-client" 2>/dev/null | awk '/not found/ { printf "%s ", $1 }')
if [ -n "$MISSING" ]; then
echo "The game needs libraries this system doesn't have: $MISSING"
echo "Install them before starting a server:"
echo " Debian, Ubuntu: sudo apt install libasound2 libudev1 (Ubuntu 24.04 and later: libasound2t64)"
echo " Fedora, RHEL: sudo dnf install alsa-lib systemd-libs"
echo " Arch: sudo pacman -S alsa-lib systemd-libs"
echo " NixOS: enable programs.nix-ld with alsa-lib and udev in its libraries"
fi
fi
case ":$PATH:" in
*":$BIN:"*) ;;
*) echo "Add $BIN to your PATH, for example: echo 'export PATH=\"$BIN:\$PATH\"' >> ~/.profile" ;;