//! 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> = 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) { // 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 { 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"); } }