Skip to main content

bhtune_server/
service.rs

1//! Platform service registration and lifecycle management (`server-windows-service`).
2//!
3//! Only the imperative Windows Service Control Manager (SCM) glue is
4//! `#[cfg(target_os = "windows")]` -- it is invisible to the Linux/macOS coverage runs, so it
5//! is kept as thin as possible. Everything that can be plain, platform-neutral logic (the
6//! service's identity/definition, how CLI flags become launch arguments, the reporting order
7//! of the SCM lifecycle, and how a "not launched by the SCM" failure is recognized) lives at
8//! the top of this file, is exercised by the tests below on every platform (including CI's
9//! `windows-latest` job, which compiles and runs the `#[cfg(windows)]` section too -- see
10//! `.github/workflows/checks.yml`), and is only *mapped onto* the real `windows_service`
11//! types inside the Windows-only section. Mirrors `opcda-bridge-gateway`'s own `service.rs`
12//! (same crate, same design), generalized for a binary that -- unlike that Windows-only
13//! gateway -- genuinely runs cross-platform.
14//!
15//! Linux and macOS have no equivalent self-registration API: the idiomatic path there is a
16//! static unit/plist file an administrator (or a future `.deb`/`.rpm`/Homebrew package)
17//! installs with the OS's own tooling, not something this binary does to itself at runtime --
18//! see `packaging/systemd/bhtune-server.service` and
19//! `packaging/launchd/com.bytehound-labs.bhtune-server.plist`. So on those platforms, this
20//! module's public functions are still real (not `#[cfg(windows)]`-gated away, so
21//! `bhtune-server install` on Linux fails with a helpful message rather than clap rejecting
22//! an unrecognized subcommand outright), but they only explain that and point at the
23//! relevant packaging file instead of touching anything.
24
25use crate::cli::Cli;
26use std::path::PathBuf;
27
28/// Service name registered with the SCM (used for `sc query`, event log sourcing, etc. --
29/// must contain no spaces).
30pub const SERVICE_NAME: &str = "BhtuneServer";
31/// Human-readable name shown in `services.msc`.
32pub const SERVICE_DISPLAY_NAME: &str = "BHTune Server";
33/// Shown as the service's description in `services.msc`.
34pub const SERVICE_DESCRIPTION: &str = "Serves BHTune's HTTP API and embedded web GUI for MRFT PID auto-tuning. \
35     https://github.com/bytehound-labs/bhtune";
36
37/// Plain, platform-neutral description of how the server should be registered with the SCM.
38/// Built and tested independent of the Windows-only `windows_service::service::ServiceInfo`
39/// it is later mapped onto one field at a time, so this construction logic runs -- and is
40/// covered -- on every platform.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ServiceDefinition {
43    pub name: String,
44    pub display_name: String,
45    pub description: String,
46    pub executable_path: PathBuf,
47    pub launch_arguments: Vec<String>,
48}
49
50/// Re-serialize whichever CLI flags were given to `install` into the argument list the SCM
51/// should launch the executable with. The SCM always starts a service's executable bare (no
52/// interactive shell, no inherited environment beyond the system default -- notably including
53/// a *different* `%APPDATA%` than whichever user ran `install` interactively, since services
54/// typically run under their own account), so an explicit `--config` an operator wants
55/// applied every time the service starts must be baked into the registration itself rather
56/// than relying on how the executable happened to be invoked once at install time -- see
57/// `crate::cli`'s module doc comment.
58pub fn service_launch_arguments(cli: &Cli) -> Vec<String> {
59    let mut args = Vec::new();
60    if let Some(config) = &cli.config {
61        args.push("--config".to_string());
62        args.push(config.display().to_string());
63    }
64    args
65}
66
67/// Build the platform-neutral service definition used by `install`, pairing the current
68/// executable's path with whichever CLI flags should carry over into the service's own
69/// launch.
70pub fn build_service_definition(executable_path: PathBuf, cli: &Cli) -> ServiceDefinition {
71    ServiceDefinition {
72        name: SERVICE_NAME.to_string(),
73        display_name: SERVICE_DISPLAY_NAME.to_string(),
74        description: SERVICE_DESCRIPTION.to_string(),
75        executable_path,
76        launch_arguments: service_launch_arguments(cli),
77    }
78}
79
80/// The SCM status lifecycle `bhtune-server` reports while running as a Windows service, kept
81/// as a plain enum (rather than directly using `windows_service::service::ServiceState`,
82/// which only exists on Windows) purely so the expected reporting order is itself
83/// unit-testable on every platform. The Windows-only reporting code maps each variant onto
84/// the real SCM API one-to-one.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ServiceLifecycle {
87    /// Reported immediately after registering the control handler, while config/logging/the
88    /// database are still being resolved and the listener has not bound yet.
89    StartPending,
90    /// Reported once [`crate::run::build_server`] has returned -- the listener is bound and
91    /// ready to serve.
92    Running,
93    /// Reported the instant a Stop/Shutdown control event arrives, before in-flight requests
94    /// have finished draining.
95    StopPending,
96    /// Reported after the server has fully drained and [`crate::run::serve`] has returned.
97    Stopped,
98}
99
100impl ServiceLifecycle {
101    /// The state that follows this one in the fixed reporting sequence, or `None` after
102    /// `Stopped` (the sequence's end). Encodes -- and lets tests lock in -- the intended
103    /// order without needing the Windows-only types the real reporting code sends to the SCM.
104    pub fn next(self) -> Option<Self> {
105        match self {
106            ServiceLifecycle::StartPending => Some(ServiceLifecycle::Running),
107            ServiceLifecycle::Running => Some(ServiceLifecycle::StopPending),
108            ServiceLifecycle::StopPending => Some(ServiceLifecycle::Stopped),
109            ServiceLifecycle::Stopped => None,
110        }
111    }
112}
113
114/// Windows' `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT`: the Win32 error code
115/// `StartServiceCtrlDispatcherW` returns when the calling process was launched interactively
116/// rather than by the Service Control Manager.
117const ERROR_FAILED_SERVICE_CONTROLLER_CONNECT: i32 = 1063;
118
119/// True when `code` is the raw OS error that means "this process wasn't started by the SCM"
120/// -- i.e. `main` should fall back to running the server directly in the foreground rather
121/// than treating this as a real failure. Kept as a plain function over the numeric code
122/// (rather than matching directly on `windows_service::Error`, which only exists on Windows)
123/// so this small but important piece of "which failure means fall back to console mode"
124/// logic is still covered by the cross-platform test run.
125pub fn is_scm_launch_error_code(code: Option<i32>) -> bool {
126    code == Some(ERROR_FAILED_SERVICE_CONTROLLER_CONNECT)
127}
128
129/// Explains why a `service`-management subcommand can't do anything on this platform, and
130/// where the real equivalent lives instead. Shared by every non-Windows stub below so the
131/// message only needs to be written once.
132#[cfg(not(target_os = "windows"))]
133fn platform_service_error(action: &str) -> anyhow::Error {
134    anyhow::anyhow!(
135        "`bhtune-server {action}` manages a Windows service and only works on Windows.\n\
136         On Linux, install the provided systemd unit instead:\n  \
137         packaging/systemd/bhtune-server.service\n\
138         On macOS, install the provided launchd daemon instead:\n  \
139         packaging/launchd/com.bytehound-labs.bhtune-server.plist\n\
140         See docs/getting-started/installation.md#run-as-a-background-service for the exact \
141         steps."
142    )
143}
144
145#[cfg(target_os = "windows")]
146mod windows_impl {
147    use super::{
148        SERVICE_DISPLAY_NAME, SERVICE_NAME, ServiceDefinition, ServiceLifecycle,
149        build_service_definition, is_scm_launch_error_code,
150    };
151    use crate::cli::Cli;
152    use crate::run;
153    use std::ffi::OsString;
154    use std::time::Duration;
155    use windows_service::service::{
156        ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode,
157        ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
158    };
159    use windows_service::service_control_handler::ServiceStatusHandle;
160    use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
161    use windows_service::service_dispatcher;
162    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
163
164    const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
165
166    windows_service::define_windows_service!(ffi_service_main, service_main);
167
168    /// Maps a [`ServiceDefinition`] onto the real, Windows-only `ServiceInfo` the SCM API
169    /// needs. Deliberately trivial -- all the actual decision-making already happened in
170    /// [`build_service_definition`].
171    fn to_service_info(definition: &ServiceDefinition) -> ServiceInfo {
172        ServiceInfo {
173            name: OsString::from(&definition.name),
174            display_name: OsString::from(&definition.display_name),
175            service_type: SERVICE_TYPE,
176            start_type: ServiceStartType::AutoStart,
177            error_control: ServiceErrorControl::Normal,
178            executable_path: definition.executable_path.clone(),
179            launch_arguments: definition
180                .launch_arguments
181                .iter()
182                .map(OsString::from)
183                .collect(),
184            dependencies: vec![],
185            account_name: None, // Run as LocalSystem.
186            account_password: None,
187        }
188    }
189
190    /// Registers `bhtune-server` with the SCM (does not start it).
191    pub fn install(cli: &Cli) -> anyhow::Result<()> {
192        // The install command deliberately registers the executable the operator invoked.
193        // nosemgrep: rust.lang.security.current-exe.current-exe
194        let exe = std::env::current_exe()?;
195        let definition = build_service_definition(exe, cli);
196        let manager = ServiceManager::local_computer(
197            None::<&str>,
198            ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
199        )?;
200        let service =
201            manager.create_service(&to_service_info(&definition), ServiceAccess::CHANGE_CONFIG)?;
202        service.set_description(&definition.description)?;
203        println!(
204            "Installed '{}' ({}). Start it with: bhtune-server.exe start",
205            definition.display_name, definition.name
206        );
207        Ok(())
208    }
209
210    /// Stops (if running) and removes the registered service.
211    pub fn uninstall() -> anyhow::Result<()> {
212        let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
213        let service = manager.open_service(
214            SERVICE_NAME,
215            ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
216        )?;
217        if service.query_status()?.current_state != ServiceState::Stopped {
218            service.stop()?;
219        }
220        service.delete()?;
221        println!("Uninstalled '{SERVICE_DISPLAY_NAME}'.");
222        Ok(())
223    }
224
225    /// Starts the registered service.
226    pub fn start() -> anyhow::Result<()> {
227        let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
228        let service = manager.open_service(SERVICE_NAME, ServiceAccess::START)?;
229        service.start::<&std::ffi::OsStr>(&[])?;
230        println!("Started '{SERVICE_DISPLAY_NAME}'.");
231        Ok(())
232    }
233
234    /// Requests the running service to stop.
235    pub fn stop() -> anyhow::Result<()> {
236        let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
237        let service = manager.open_service(SERVICE_NAME, ServiceAccess::STOP)?;
238        service.stop()?;
239        println!("Stop requested for '{SERVICE_DISPLAY_NAME}'.");
240        Ok(())
241    }
242
243    /// Prints the registered service's current SCM state.
244    pub fn status() -> anyhow::Result<()> {
245        let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
246        let service = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)?;
247        let status = service.query_status()?;
248        println!("{SERVICE_DISPLAY_NAME}: {:?}", status.current_state);
249        Ok(())
250    }
251
252    /// True when `err` is the specific failure `service_dispatcher::start` returns when this
253    /// process was launched interactively rather than by the SCM -- the signal that `main`
254    /// should fall back to console mode.
255    pub fn is_run_outside_scm(err: &windows_service::Error) -> bool {
256        matches!(
257            err,
258            windows_service::Error::Winapi(io_err) if is_scm_launch_error_code(io_err.raw_os_error())
259        )
260    }
261
262    /// Registers the generated service entry point with the SCM and blocks until the service
263    /// stops. Returns immediately with an error -- no threads spawned, nothing torn down -- if
264    /// this process was not actually launched by the SCM; see [`is_run_outside_scm`].
265    pub fn run_as_service() -> windows_service::Result<()> {
266        service_dispatcher::start(SERVICE_NAME, ffi_service_main)
267    }
268
269    /// Reports one step of the server's SCM lifecycle. `controls_accepted` is only
270    /// meaningful while `Running` -- a service in a pending state cannot yet (or any longer)
271    /// accept control events. `wait_hint` gives the SCM how long to wait before considering
272    /// the service hung; the generous `StopPending` hint gives in-flight HTTP requests and an
273    /// active tune's cancel/restore time to finish, matching [`run::serve`]'s own
274    /// graceful-shutdown behavior (see `SHUTDOWN_RUN_CANCEL_TIMEOUT`).
275    fn report_status(
276        handle: &ServiceStatusHandle,
277        state: ServiceLifecycle,
278    ) -> windows_service::Result<()> {
279        let (current_state, controls_accepted, wait_hint) = match state {
280            ServiceLifecycle::StartPending => (
281                ServiceState::StartPending,
282                ServiceControlAccept::empty(),
283                Duration::from_secs(10),
284            ),
285            ServiceLifecycle::Running => (
286                ServiceState::Running,
287                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
288                Duration::default(),
289            ),
290            ServiceLifecycle::StopPending => (
291                ServiceState::StopPending,
292                ServiceControlAccept::empty(),
293                Duration::from_secs(40),
294            ),
295            ServiceLifecycle::Stopped => (
296                ServiceState::Stopped,
297                ServiceControlAccept::empty(),
298                Duration::default(),
299            ),
300        };
301        handle.set_service_status(ServiceStatus {
302            service_type: SERVICE_TYPE,
303            current_state,
304            controls_accepted,
305            exit_code: ServiceExitCode::Win32(0),
306            checkpoint: 0,
307            wait_hint,
308            process_id: None,
309        })
310    }
311
312    /// The service entry point invoked by the SCM on a background thread. `_arguments` is the
313    /// SCM's secondary start-parameter channel (e.g. an operator running `sc start name
314    /// extra`) -- distinct from the process's real argv, which is what `Cli::parse()` inside
315    /// `run_service` sees, identically to console-mode startup, since it's the same launch
316    /// command `install` registered.
317    fn service_main(_arguments: Vec<OsString>) {
318        if let Err(e) = run_service() {
319            // No console and, if this failed early, possibly no SCM status handle either --
320            // file logging (once `run::build_server` initializes it) is the real record of
321            // this; stderr is a last-resort breadcrumb.
322            eprintln!("bhtune-server service run failed: {e:?}");
323        }
324    }
325
326    fn run_service() -> anyhow::Result<()> {
327        use clap::Parser;
328
329        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
330        let shutdown_tx = std::sync::Mutex::new(Some(shutdown_tx));
331
332        let event_handler = move |control_event| -> ServiceControlHandlerResult {
333            match control_event {
334                // All services must accept Interrogate even as a no-op.
335                ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
336                ServiceControl::Stop | ServiceControl::Shutdown => {
337                    if let Some(tx) = shutdown_tx.lock().unwrap_or_else(|e| e.into_inner()).take() {
338                        let _ = tx.send(());
339                    }
340                    ServiceControlHandlerResult::NoError
341                }
342                _ => ServiceControlHandlerResult::NotImplemented,
343            }
344        };
345
346        let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?;
347        report_status(&status_handle, ServiceLifecycle::StartPending)?;
348
349        // Real launch arguments (baked in by `install`), not the SCM's secondary
350        // `_arguments` parameter above.
351        let cli = Cli::parse();
352
353        let rt = tokio::runtime::Runtime::new()?;
354        // `build_server` runs the full startup sequence (config/log/db, migrations, bind) --
355        // only once it returns is the listener actually bound and ready, which is the
356        // moment `Running` becomes true, not a moment earlier (contrast
357        // `opcda-bridge-gateway`'s own `service_main`, which reports `Running` right after
358        // parsing CLI args since its own bootstrap has no comparable async setup cost).
359        let server = rt.block_on(run::build_server(cli.config.as_deref()))?;
360        report_status(&status_handle, ServiceLifecycle::Running)?;
361
362        // Reports `StopPending` the instant the stop signal arrives, before `run::serve`'s
363        // shutdown future actually resolves and the in-flight-request drain begins -- this
364        // is exactly the moment the SCM needs to stop expecting an immediate `Stopped`.
365        // `ServiceStatusHandle` is `Copy` (and documented safe to use from any thread), so
366        // this is a plain copy, not a deep clone.
367        let stop_status_handle = status_handle;
368        let shutdown = async move {
369            let _ = shutdown_rx.await;
370            let _ = report_status(&stop_status_handle, ServiceLifecycle::StopPending);
371        };
372
373        let result = rt.block_on(run::serve(server, shutdown));
374
375        report_status(&status_handle, ServiceLifecycle::Stopped)?;
376        result
377    }
378}
379
380#[cfg(target_os = "windows")]
381pub use windows_impl::{
382    install, is_run_outside_scm, run_as_service, start, status, stop, uninstall,
383};
384
385#[cfg(not(target_os = "windows"))]
386pub fn install(_cli: &Cli) -> anyhow::Result<()> {
387    Err(platform_service_error("install"))
388}
389
390#[cfg(not(target_os = "windows"))]
391pub fn uninstall() -> anyhow::Result<()> {
392    Err(platform_service_error("uninstall"))
393}
394
395#[cfg(not(target_os = "windows"))]
396pub fn start() -> anyhow::Result<()> {
397    Err(platform_service_error("start"))
398}
399
400#[cfg(not(target_os = "windows"))]
401pub fn stop() -> anyhow::Result<()> {
402    Err(platform_service_error("stop"))
403}
404
405#[cfg(not(target_os = "windows"))]
406pub fn status() -> anyhow::Result<()> {
407    Err(platform_service_error("status"))
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::path::PathBuf;
414
415    fn cli_with(config: Option<&str>) -> Cli {
416        Cli {
417            command: None,
418            config: config.map(PathBuf::from),
419        }
420    }
421
422    #[test]
423    fn service_launch_arguments_empty_when_no_config_flag_set() {
424        let cli = cli_with(None);
425        assert_eq!(service_launch_arguments(&cli), Vec::<String>::new());
426    }
427
428    #[test]
429    fn service_launch_arguments_includes_the_config_flag_when_set() {
430        let cli = cli_with(Some("/etc/bhtune/bhtune.toml"));
431        assert_eq!(
432            service_launch_arguments(&cli),
433            vec![
434                "--config".to_string(),
435                "/etc/bhtune/bhtune.toml".to_string(),
436            ]
437        );
438    }
439
440    #[test]
441    fn build_service_definition_carries_identity_and_arguments() {
442        let cli = cli_with(Some("C:\\ProgramData\\bhtune\\bhtune.toml"));
443        let definition = build_service_definition(PathBuf::from("C:\\bhtune-server.exe"), &cli);
444        assert_eq!(definition.name, SERVICE_NAME);
445        assert_eq!(definition.display_name, SERVICE_DISPLAY_NAME);
446        assert_eq!(definition.description, SERVICE_DESCRIPTION);
447        assert_eq!(
448            definition.executable_path,
449            PathBuf::from("C:\\bhtune-server.exe")
450        );
451        assert_eq!(
452            definition.launch_arguments,
453            vec![
454                "--config".to_string(),
455                "C:\\ProgramData\\bhtune\\bhtune.toml".to_string(),
456            ]
457        );
458    }
459
460    #[test]
461    fn build_service_definition_with_no_flags_has_no_launch_arguments() {
462        let cli = cli_with(None);
463        let definition = build_service_definition(PathBuf::from("/usr/bin/bhtune-server"), &cli);
464        assert_eq!(definition.launch_arguments, Vec::<String>::new());
465    }
466
467    #[test]
468    fn service_lifecycle_sequence_order() {
469        assert_eq!(
470            ServiceLifecycle::StartPending.next(),
471            Some(ServiceLifecycle::Running)
472        );
473        assert_eq!(
474            ServiceLifecycle::Running.next(),
475            Some(ServiceLifecycle::StopPending)
476        );
477        assert_eq!(
478            ServiceLifecycle::StopPending.next(),
479            Some(ServiceLifecycle::Stopped)
480        );
481    }
482
483    #[test]
484    fn service_lifecycle_stopped_is_terminal() {
485        assert_eq!(ServiceLifecycle::Stopped.next(), None);
486    }
487
488    #[test]
489    fn is_scm_launch_error_code_matches_expected_code() {
490        assert!(is_scm_launch_error_code(Some(1063)));
491    }
492
493    #[test]
494    fn is_scm_launch_error_code_rejects_other_codes() {
495        assert!(!is_scm_launch_error_code(Some(5)));
496        assert!(!is_scm_launch_error_code(None));
497    }
498
499    #[cfg(not(target_os = "windows"))]
500    #[test]
501    fn non_windows_stubs_name_the_action_and_point_at_packaging() {
502        for (action, result) in [
503            ("install", install(&cli_with(None))),
504            ("uninstall", uninstall()),
505            ("start", start()),
506            ("stop", stop()),
507            ("status", status()),
508        ] {
509            let message = result.unwrap_err().to_string();
510            assert!(
511                message.contains(&format!("bhtune-server {action}")),
512                "message for {action} should name the action verbatim: {message}"
513            );
514            assert!(message.contains("packaging/systemd/bhtune-server.service"));
515            assert!(message.contains("packaging/launchd/"));
516        }
517    }
518}