feat(sidecar): start as a real Windows service
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m22s

`sc.exe start RunicGatewayLink` failed with 1053 on every Windows install:
"a timeout was reached (30000 milliseconds) while waiting for the service to
connect", with SERVICE_EXIT_CODE 0. Nothing had crashed. The sidecar was a
plain console program, and the Windows service control manager only supervises
a process that calls StartServiceCtrlDispatcher and identifies itself within
~30 seconds.

The installer's design assumed symmetry with systemd, which supervises any
foreground process. Windows has no equivalent: it is a service-aware binary or
a shim, and a shim was already rejected as a third binary to keep current.

Split the entry point so the platform only owns starting and stopping:

  systemd --> main --> unix::run ---------------+
                                                +--> app::run
  SCM ------> main --> windows::run --> ServiceMain
                                    \-> console fallback

- app.rs is the whole sidecar, unchanged and shared. No #[cfg] on the data path.
- windows.rs speaks the SCM handshake. The dispatcher is tried first and failing
  is expected: ERROR_FAILED_SERVICE_CONTROLLER_CONNECT (1063) means "not started
  by the SCM" and falls through to a normal foreground run, so one binary does
  both with no --service flag to forget.
- Running is reported only once the shard port is bound and the store is open, so
  a bad config fails the start instead of flapping Running -> Stopped, and a
  failed run leaves a nonzero SERVICE_EXIT_CODE instead of the misleading 0.
- A service has no stdout, so service mode logs to uo-link-sidecar.log.<date>
  beside its config, rolled daily, seven kept.
- unix.rs additionally handles SIGTERM, which is what systemctl stop sends and
  which previously took the default disposition mid-write.

The Windows crates are declared under [target.'cfg(windows)'.dependencies].
Verified: a Linux build in rust:1-slim-bookworm succeeds and resolves neither
windows-service nor tracing-appender.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 13:35:52 -05:00
parent 915f0296a9
commit 96af2afa68
7 changed files with 768 additions and 300 deletions

41
sidecar/src/unix.rs Normal file
View File

@@ -0,0 +1,41 @@
//! Unix startup and shutdown.
//!
//! There is no supervisor protocol to speak: systemd starts the process, and stops it by sending
//! `SIGTERM`. All this module does is translate the two signals that mean "stop" into the future
//! [`crate::app::run`] waits on, so a `systemctl stop` unwinds the same way a Ctrl-C does instead
//! of being killed by the default `SIGTERM` disposition mid-write.
use tokio::signal::unix::{signal, SignalKind};
pub fn run(config_path: Option<&str>) -> anyhow::Result<()> {
crate::init_console_tracing();
tokio::runtime::Runtime::new()?.block_on(crate::app::run(config_path, || {}, shutdown_signal()))
}
/// Resolves on the first `SIGINT` or `SIGTERM`.
async fn shutdown_signal() {
// A failure to install a handler is not worth aborting a running sidecar for: fall back to
// pending, which leaves that signal's default disposition (terminate) in place.
let mut term = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "could not listen for SIGTERM");
std::future::pending::<()>().await;
unreachable!()
}
};
let mut int = match signal(SignalKind::interrupt()) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "could not listen for SIGINT");
term.recv().await;
return;
}
};
tokio::select! {
_ = term.recv() => tracing::info!("SIGTERM received"),
_ = int.recv() => tracing::info!("SIGINT received"),
}
}