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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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(¤t).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&D</string>"));
|
||||
assert!(plist.contains("<string>/srv/R&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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ;;
|
||||
|
||||
Reference in New Issue
Block a user