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>
240 lines
10 KiB
Rust
240 lines
10 KiB
Rust
//! Windows startup and shutdown: the SCM handshake.
|
|
//!
|
|
//! Unlike systemd, 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
|
|
//! `uo-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.
|
|
//!
|
|
//! ## 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
|
|
//! (`uo-link-sidecar.YYYY-MM-DD.log`, seven kept). 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};
|
|
|
|
/// Must match the name the installer registers (`installer/src/service.rs::WINDOWS_SERVICE`). For
|
|
/// an own-process service the SCM ignores it, but a mismatch would be a trap for whoever converts
|
|
/// this to a shared-process service later.
|
|
pub const SERVICE_NAME: &str = "RunicGatewayLink";
|
|
|
|
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, 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.
|
|
fn console_run(config_path: Option<&str>) -> anyhow::Result<()> {
|
|
crate::init_console_tracing();
|
|
tokio::runtime::Runtime::new()?.block_on(crate::app::run(config_path, || {}, async {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
}))
|
|
}
|
|
|
|
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, 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::Runtime::new()?.block_on(crate::app::run(
|
|
config_path.as_deref(),
|
|
// Reported only once the shard port 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 the 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"),
|
|
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("uo-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\sidecar.toml")),
|
|
PathBuf::from(r"C:\ProgramData\RunicGateway")
|
|
);
|
|
}
|
|
|
|
#[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_matches_the_installer() {
|
|
// installer/src/service.rs::WINDOWS_SERVICE. Kept as a literal on both sides — the two
|
|
// repos are released independently and do not share a crate.
|
|
assert_eq!(SERVICE_NAME, "RunicGatewayLink");
|
|
}
|
|
}
|