feat(sidecar): a Windows service, the egg and its launcher, and the first release workflow (phase 18)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m53s

Module-rust phase 18, step 4 of docs/modules/rust/PLAN.md §34.2.7.

The Windows service (D149, §34.2.5): src/windows.rs, ported from link's fix
for error 1053. The same exe tries the SCM handshake and falls through to a
console run on 1063; it reports Running only once the listener and store are
up, and logs to a daily file beside its config. One binary serves every
RunicGatewayRust-<id> instance, because the SCM ignores the dispatcher's name
for an own-process service.

An empty environment variable now counts as unset. A Pterodactyl egg exports
every variable it declares, so a blank RUSTLINK_WEB_TOKEN arrived as "" and
overrode the saved token, and a new one was generated and persisted on every
boot. That breaks D152, which this change makes true.

The egg (R20, R22, D151, D152, §34.2.6), in egg/:
- install.sh is egg 18's script with two changes. A wipe guard moves
  rust-link/ to /tmp around `rm -rf ${REMOVE_FILES}`. The bridge block then
  fetches a schema-2 Rust bundle (pinnable by RUNICGATEWAY_BUNDLE), checks
  every asset's sha256 and the plugin's protocol before placing anything, and
  places the plugin by FRAMEWORK. Vanilla installs nothing and does not fail.
- with-sidecar.sh is the launcher. It unsets blank variables, builds the web
  bind from RUSTLINK_WEB_PORT, and runs --print-config so that a newly
  generated token is printed once. It prints the URL and server id for the
  admin page, then execs the game. It no longer uses `set -e`: nothing the
  bridge gets wrong may keep the game from booting.
- The startup's launcher prefix is conditional, so a server with no bridge
  boots exactly as egg 18 does.
- build.sh assembles egg-rust-runicgateway.json. PR Checks runs it.

The release (D145, §34.2.1) reuses servuo-plugins' engine. It publishes the
static musl Linux binary, the Windows exe, the launcher, the egg and
SHA256SUMS, and dispatches the installer's bundle.yml. PR Checks gains a
clippy run for the Windows target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 23:14:57 -05:00
parent 6aa6cdcf97
commit b3b66b1cc2
13 changed files with 1559 additions and 16 deletions

95
sidecar/Cargo.lock generated
View File

@@ -233,6 +233,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -275,6 +284,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.10.7"
@@ -912,6 +927,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
@@ -1039,6 +1060,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1165,7 +1192,9 @@ dependencies = [
"tokio",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows-service",
]
[[package]]
@@ -1573,6 +1602,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "2.0.119"
@@ -1661,6 +1696,36 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -1808,6 +1873,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.20",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -2018,6 +2096,12 @@ dependencies = [
"wasite",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -2068,6 +2152,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-service"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2"
dependencies = [
"bitflags",
"widestring",
"windows-sys 0.61.2",
]
[[package]]
name = "windows-strings"
version = "0.5.1"

View File

@@ -18,5 +18,10 @@ toml = "0.8"
getrandom = "0.2"
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
# The SCM handshake (src/windows.rs). Windows only: systemd supervises a console program as-is.
[target.'cfg(windows)'.dependencies]
windows-service = "0.8"
tracing-appender = "0.2"
[profile.release]
opt-level = 2

View File

@@ -6,7 +6,9 @@ Configuration reference and endpoint list. For what this component *is*, see the
## Configuration
`sidecar.toml`, resolved in this order: `--config <PATH>`, else `$RUSTLINK_CONFIG`, else
`./sidecar.toml`. Environment variables override the file; the file overrides the defaults.
`./sidecar.toml`. Environment variables override the file; the file overrides the defaults. **An
empty or blank variable counts as unset**: a Pterodactyl egg exports every variable it declares, so a
field left blank arrives as `VAR=""`, and honouring that would erase the saved token on every boot.
| Key | Env | Default | What it is |
|---|---|---|---|
@@ -42,6 +44,15 @@ Resolves the configuration exactly as a normal start would — writing the file
token if they are missing — and prints it as JSON on stdout, **including the token in clear text**.
That is the supported way for an installer to obtain it; the alternative is scraping a log.
## As a Windows service
The same `.exe` runs from a shell or under the Service Control Manager. It tries the SCM handshake
first and falls through to an ordinary console run when a human started it, so there is no
`--service` flag to forget. Under the SCM it reports `Running` only once the game listener and the
store are up, turns a stop request into a clean shutdown, and logs to a daily-rolled
`rust-link-sidecar.YYYY-MM-DD.log` beside its config (a service has no console). One service per
game server, each with its own `--config`; the installer names them `RunicGatewayRust-<server id>`.
## Endpoints
Everything except `/health` requires the token, as `Authorization: Bearer <t>`, `X-Api-Key: <t>`,

View File

@@ -187,22 +187,36 @@ impl Config {
/// Environment overrides, so a deployment can set secrets without editing the file.
fn apply_env(&mut self) {
if let Ok(v) = env::var("RUSTLINK_GAME_BIND") {
self.apply_env_from(|key| env::var(key).ok());
}
/// [`Self::apply_env`] against any lookup, so the rules below are testable without mutating the
/// process environment (which the test harness shares across threads).
///
/// **An empty or blank value is the same as an unset one.** A Pterodactyl egg exports every
/// variable it declares, so an operator who leaves `RUSTLINK_WEB_TOKEN` blank arrives here as
/// `RUSTLINK_WEB_TOKEN=""`. Honouring that as an override would blank the token saved in
/// `sidecar.toml` on every boot, and a fresh one would be generated and persisted each time:
/// the website's copy would go stale at every restart (PLAN.md §34, D152). No variable here has
/// a meaningful empty value — an empty bind or database path can only fail later, less clearly.
fn apply_env_from(&mut self, get: impl Fn(&str) -> Option<String>) {
let get = |key: &str| get(key).filter(|v| !v.trim().is_empty());
if let Some(v) = get("RUSTLINK_GAME_BIND") {
self.game.bind = v;
}
if let Ok(v) = env::var("RUSTLINK_SERVER_ID") {
if let Some(v) = get("RUSTLINK_SERVER_ID") {
self.game.server_id = v;
}
if let Ok(v) = env::var("RUSTLINK_WEB_BIND") {
if let Some(v) = get("RUSTLINK_WEB_BIND") {
self.web.bind = v;
}
if let Ok(v) = env::var("RUSTLINK_WEB_TOKEN") {
if let Some(v) = get("RUSTLINK_WEB_TOKEN") {
self.web.auth_token = v;
}
if let Ok(v) = env::var("RUSTLINK_DB_PATH") {
if let Some(v) = get("RUSTLINK_DB_PATH") {
self.store.path = v;
}
if let Ok(v) = env::var("RUSTLINK_RETAIN_DAYS") {
if let Some(v) = get("RUSTLINK_RETAIN_DAYS") {
// A malformed value is ignored rather than fatal: this reaches the process as a panel
// variable somebody typed (R22), and refusing to start over a stray character would
// take the bridge down for a setting that has a perfectly good default.
@@ -449,4 +463,34 @@ mod tests {
assert_eq!(cfg.store.path, default_db_path());
assert_eq!(cfg.game.server_id, "");
}
/// The egg's case (D152): a blank panel variable must not erase the token already saved in the
/// file, or a new one would be generated on every boot.
#[test]
fn an_empty_variable_does_not_override_the_file() {
let mut cfg: Config = toml::from_str(&default_file("saved-token")).unwrap();
cfg.apply_env_from(|key| match key {
"RUSTLINK_WEB_TOKEN" => Some(String::new()),
"RUSTLINK_WEB_BIND" => Some(" ".into()),
"RUSTLINK_SERVER_ID" => Some(String::new()),
_ => None,
});
assert_eq!(cfg.web.auth_token, "saved-token");
assert_eq!(cfg.web.bind, default_web_bind());
assert_eq!(cfg.game.server_id, "");
}
#[test]
fn a_set_variable_still_overrides_the_file() {
let mut cfg: Config = toml::from_str(&default_file("saved-token")).unwrap();
cfg.apply_env_from(|key| match key {
"RUSTLINK_WEB_TOKEN" => Some("from-env".into()),
"RUSTLINK_WEB_BIND" => Some("0.0.0.0:21009".into()),
"RUSTLINK_SERVER_ID" => Some("alpha".into()),
_ => None,
});
assert_eq!(cfg.web.auth_token, "from-env");
assert_eq!(cfg.web.bind, "0.0.0.0:21009");
assert_eq!(cfg.game.server_id, "alpha");
}
}

View File

@@ -20,8 +20,10 @@
//! # Layout
//!
//! `main` does argument handling and nothing else; the sidecar proper lives in [`app`], which is
//! parameterised on `ready`/`shutdown` so that a future service wrapper (the installer's phase)
//! can supply the host's own start and stop without restructuring anything.
//! parameterised on `ready`/`shutdown` so a service wrapper can supply the host's own start and
//! stop. On Windows that wrapper is [`windows`] — the SCM handshake, without which a registered
//! service dies with error 1053 (PLAN.md §34.2.5). On Linux there is none: systemd supervises a
//! console program as it is, and stops it with `SIGTERM`, which [`shutdown_signal`] already hears.
mod app;
mod cli;
@@ -30,6 +32,8 @@ mod game;
mod rpc;
mod store;
mod web;
#[cfg(windows)]
mod windows;
use tracing_subscriber::EnvFilter;
@@ -198,13 +202,20 @@ fn main() -> anyhow::Result<()> {
cli::Mode::Run => {}
}
init_console_tracing();
// The SCM's way in. It falls through to a console run when a human started the process.
#[cfg(windows)]
return windows::run(args.config.as_deref());
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
#[cfg(not(windows))]
{
init_console_tracing();
runtime.block_on(app::run(args.config.as_deref(), || {}, shutdown_signal()))
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
runtime.block_on(app::run(args.config.as_deref(), || {}, shutdown_signal()))
}
}
/// Logging for a foreground run: human-readable, on stdout.
@@ -217,7 +228,7 @@ pub fn init_console_tracing() {
}
/// Resolves on Ctrl-C, and on `SIGTERM` where there is one.
async fn shutdown_signal() {
pub(crate) async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};

259
sidecar/src/windows.rs Normal file
View File

@@ -0,0 +1,259 @@
//! Windows startup and shutdown: the SCM handshake.
//!
//! Ported from `link`'s `windows.rs`, which learned it the hard way (docs/modules/rust/PLAN.md
//! §34.2.5, D149). The Windows Service Control Manager cannot supervise an arbitrary console
//! program. A binary registered with `sc.exe create` has ~30 seconds to call
//! `StartServiceCtrlDispatcher` and connect back to the SCM; one that never does is killed with
//! **error 1053, "the service did not respond to the start request in a timely fashion"** — even
//! though the process itself started perfectly and is sitting there serving traffic. That is the
//! entire reason this module exists.
//!
//! ## One binary, two ways in
//!
//! The dispatcher is tried first and *failing is expected*: when the process was started from a
//! shell rather than by the SCM, the connect fails with `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT`
//! (1063), and that — and only that — falls through to a normal foreground run. So
//! `rust-link-sidecar.exe --config ...` stays an ordinary console app you can Ctrl-C, `cargo run`
//! still works, and the same binary can be registered as a service with no `--service` flag for an
//! operator to forget. Any other dispatcher error is a real failure and is reported.
//!
//! ## One binary, many services
//!
//! A Rust host runs one sidecar per game server (R8), so the installer registers one service per
//! instance — `RunicGatewayRust-<server id>` (D148) — all pointing at this one executable with a
//! different `--config`. That works without this module knowing the instance's name: for an
//! **own-process** service the SCM ignores the name handed to the dispatcher and to the control
//! handler, because the process can only ever host the one service it was started as.
//!
//! ## Logging goes to a file, because a service has no stdout
//!
//! Under the SCM there is no console attached, so the normal stdout subscriber writes into the
//! void. In service mode the sidecar logs to a daily-rolled file next to its config instead
//! (`rust-link-sidecar.YYYY-MM-DD.log`, seven kept). Instances keep their configs apart, so their
//! logs are apart too. A service whose start fails leaves a reason behind rather than only an SCM
//! error code.
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::sync::Notify;
use tracing_subscriber::EnvFilter;
use windows_service::service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::{define_windows_service, service_dispatcher};
/// The prefix of every instance's service name (`RunicGatewayRust-<server id>`, installed by
/// `installer/src/service.rs`). Passed to the dispatcher and the control handler, which ignore it
/// for an own-process service — see the module docs. It is a literal on both sides; the two repos
/// are released independently and share no crate.
pub const SERVICE_NAME_PREFIX: &str = "RunicGatewayRust";
const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
/// `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` — "this process was not started by the SCM", which is
/// the normal answer when a human runs the binary.
const ERROR_FAILED_SERVICE_CONTROLLER_CONNECT: i32 = 1063;
/// `service_main` is called through an `extern "system"` trampoline and so can capture nothing.
/// The parsed `--config` is handed over here instead of being re-parsed, so the service and a
/// console run resolve their configuration through exactly the same code path.
static CONFIG_PATH: OnceLock<Option<String>> = OnceLock::new();
pub fn run(config_path: Option<&str>) -> anyhow::Result<()> {
let _ = CONFIG_PATH.set(config_path.map(str::to_string));
match service_dispatcher::start(SERVICE_NAME_PREFIX, ffi_service_main) {
Ok(()) => Ok(()),
// Not started by the SCM: this is a foreground run, which is not an error.
Err(windows_service::Error::Winapi(e))
if e.raw_os_error() == Some(ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) =>
{
console_run(config_path)
}
Err(e) => Err(anyhow::Error::new(e)
.context("could not connect to the Windows service control manager")),
}
}
/// A normal foreground run: stdout logging, Ctrl-C to stop. Exactly what `main` does elsewhere.
fn console_run(config_path: Option<&str>) -> anyhow::Result<()> {
crate::init_console_tracing();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(crate::app::run(
config_path,
|| {},
crate::shutdown_signal(),
))
}
define_windows_service!(ffi_service_main, service_main);
fn service_main(_arguments: Vec<OsString>) {
// Arguments are deliberately ignored: for an own-process service the `binPath=` arguments
// arrive on the process command line and have already been parsed in `main`. What lands here
// is whatever was typed after `sc start`, which nothing in this deployment uses.
if let Err(e) = serve() {
// Nowhere left to report to but the log: the status handle is gone or was never obtained.
tracing::error!(error = %e, "service exited with an error");
}
}
fn serve() -> anyhow::Result<()> {
let config_path = CONFIG_PATH.get().cloned().flatten();
// Held for the life of the service: dropping the guard stops the background log writer.
let _log_guard = init_service_tracing(config_path.as_deref());
// The SCM calls the control handler on its own thread, so the stop signal crosses a thread
// boundary into the async world. `notify_one` stores a permit if nothing is waiting yet, so a
// stop that arrives during startup is not lost.
let stop = Arc::new(Notify::new());
let handler_stop = stop.clone();
let status_handle =
service_control_handler::register(SERVICE_NAME_PREFIX, move |control| match control {
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
ServiceControl::Stop | ServiceControl::Shutdown => {
handler_stop.notify_one();
ServiceControlHandlerResult::NoError
}
_ => ServiceControlHandlerResult::NotImplemented,
})?;
// Registering the handler is the handshake 1053 was about. Everything after this point gets to
// take as long as it credibly needs, as long as the state keeps being reported.
status_handle.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::StartPending,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(30),
process_id: None,
})?;
let ready_handle = status_handle;
let result = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(crate::app::run(
config_path.as_deref(),
// Reported only once the game listener is bound and the store is open, so a bad config
// or a taken port fails the *start* instead of flapping Running → Stopped a moment later.
move || {
let _ = ready_handle.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
});
},
async move { stop.notified().await },
));
// A failed run must leave a nonzero SERVICE_EXIT_CODE behind: `sc query` reporting STOPPED with
// exit code 0 is what made `link`'s original failure look like a clean stop.
let exit_code = match &result {
Ok(()) => ServiceExitCode::Win32(0),
Err(e) => {
tracing::error!(error = %e, "sidecar failed");
ServiceExitCode::ServiceSpecific(1)
}
};
status_handle.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code,
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
result
}
/// Where the service writes its log: beside the config it was pointed at, which is the directory
/// the installer already provisions and grants the service account write access to.
fn log_dir(config_path: Option<&str>) -> PathBuf {
if let Some(parent) = config_path
.map(PathBuf::from)
.as_deref()
.and_then(|p| p.parent())
.filter(|p| !p.as_os_str().is_empty())
{
return parent.to_path_buf();
}
match std::env::var_os("ProgramData") {
Some(program_data) => PathBuf::from(program_data)
.join("RunicGateway")
.join("rust"),
None => std::env::temp_dir(),
}
}
/// Returns `None` if the log file could not be opened — a service that cannot write a log is still
/// a service worth running, and the SCM start must not fail over it.
fn init_service_tracing(
config_path: Option<&str>,
) -> Option<tracing_appender::non_blocking::WorkerGuard> {
let appender = tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("rust-link-sidecar")
.filename_suffix("log")
.max_log_files(7)
.build(log_dir(config_path))
.ok()?;
let (writer, guard) = tracing_appender::non_blocking(appender);
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_ansi(false) // a log file is not a terminal
.with_writer(writer)
.init();
Some(guard)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_dir_follows_the_config_file() {
assert_eq!(
log_dir(Some(r"C:\ProgramData\RunicGateway\rust\alpha.toml")),
PathBuf::from(r"C:\ProgramData\RunicGateway\rust")
);
}
#[test]
fn a_bare_filename_does_not_become_the_filesystem_root() {
// `--config sidecar.toml` has a parent of "", which as a path means the root of the current
// drive — somewhere a service account cannot write. Fall back instead.
let dir = log_dir(Some("sidecar.toml"));
assert_ne!(dir, PathBuf::from(""));
assert!(dir.is_absolute(), "{}", dir.display());
}
#[test]
fn no_config_falls_back_to_program_data() {
let dir = log_dir(None);
assert!(dir.is_absolute(), "{}", dir.display());
}
#[test]
fn service_name_prefix_matches_the_installer() {
// installer/src/service.rs names each instance `RunicGatewayRust-<server id>`.
assert_eq!(SERVICE_NAME_PREFIX, "RunicGatewayRust");
}
}