#[cfg(any(test, target_os = "windows"))]
use std::sync::{Arc, Mutex};
#[cfg(any(test, target_os = "windows"))]
use std::time::Duration;
#[cfg(any(test, target_os = "windows"))]
use tokio::sync::oneshot;
#[cfg(any(test, target_os = "windows"))]
use super::{ServiceError, ServiceManager, ServiceRegistration, ServiceState};
#[cfg(any(test, target_os = "windows"))]
use std::path::{Path, PathBuf};
#[cfg(target_os = "windows")]
use std::sync::OnceLock;
#[cfg(target_os = "windows")]
use windows_service::{define_windows_service, service_dispatcher};
pub const SERVICE_NAME: &str = "greggd";
pub const SERVICE_DISPLAY_NAME: &str = "Gregg Metrics Daemon";
#[cfg(target_os = "windows")]
static SERVICE_LAUNCH_CONFIG: OnceLock<PathBuf> = OnceLock::new();
#[cfg(target_os = "windows")]
define_windows_service!(ffi_service_main, service_main);
#[cfg(any(test, target_os = "windows"))]
const STATE_TRANSITION_TIMEOUT_MS: u64 = 30_000;
#[cfg(any(test, target_os = "windows"))]
const STATE_POLL_INTERVAL_MS: u64 = 200;
#[cfg(any(test, target_os = "windows"))]
pub(crate) trait ScmAdapter: Send + Sync {
fn query_state(&self) -> Result<ServiceState, ServiceError>;
fn query_registration(&self) -> Result<ServiceRegistration, ServiceError>;
fn start_service(&self) -> Result<(), ServiceError>;
fn stop_service(&self) -> Result<(), ServiceError>;
fn delete_service(&self) -> Result<(), ServiceError>;
}
#[cfg(any(test, target_os = "windows"))]
fn wait_for_state(adapter: &dyn ScmAdapter, target: ServiceState) -> Result<(), ServiceError> {
let deadline = Duration::from_millis(STATE_TRANSITION_TIMEOUT_MS);
let poll = Duration::from_millis(STATE_POLL_INTERVAL_MS);
let start = std::time::Instant::now();
loop {
let current = adapter.query_state()?;
if current == target {
return Ok(());
}
if start.elapsed() >= deadline {
return Err(ServiceError::Timeout {
waited_ms: STATE_TRANSITION_TIMEOUT_MS,
});
}
std::thread::sleep(poll);
}
}
#[cfg(target_os = "windows")]
pub(crate) struct NativeScmAdapter {
service_name: String,
}
#[cfg(target_os = "windows")]
impl NativeScmAdapter {
#[must_use]
pub fn new(service_name: impl Into<String>) -> Self {
Self {
service_name: service_name.into(),
}
}
}
#[cfg(target_os = "windows")]
impl ScmAdapter for NativeScmAdapter {
fn query_state(&self) -> Result<ServiceState, ServiceError> {
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| ServiceError::StateQueryFailed {
source: std::io::Error::other(e),
})?;
let service = match manager.open_service(
&self.service_name,
windows_service::service::ServiceAccess::QUERY_STATUS,
) {
Ok(service) => service,
Err(e) if is_missing_service(&e) => return Ok(ServiceState::NotInstalled),
Err(e) => {
return Err(ServiceError::StateQueryFailed {
source: std::io::Error::other(e),
});
}
};
let status = service
.query_status()
.map_err(|e| ServiceError::StateQueryFailed {
source: std::io::Error::other(e),
})?;
Ok(map_service_state(status.current_state))
}
fn query_registration(&self) -> Result<ServiceRegistration, ServiceError> {
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(native_query_error)?;
let service = match manager.open_service(
&self.service_name,
windows_service::service::ServiceAccess::QUERY_STATUS
| windows_service::service::ServiceAccess::QUERY_CONFIG,
) {
Ok(service) => service,
Err(e) if is_missing_service(&e) => {
return Ok(ServiceRegistration {
state: ServiceState::NotInstalled,
executable_path: None,
});
}
Err(e) => return Err(native_query_error(e)),
};
let status = service.query_status().map_err(native_query_error)?;
let config = service.query_config().map_err(native_query_error)?;
let executable_path =
parse_service_executable(&config.executable_path).ok_or_else(|| {
ServiceError::StateQueryFailed {
source: std::io::Error::new(
std::io::ErrorKind::InvalidData,
"SCM executable command is ambiguous or has no absolute image path",
),
}
})?;
Ok(ServiceRegistration {
state: map_service_state(status.current_state),
executable_path: Some(executable_path),
})
}
fn start_service(&self) -> Result<(), ServiceError> {
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| ServiceError::ExecFailed {
command: "ServiceManager::connect".into(),
source: std::io::Error::other(e),
})?;
let service = manager
.open_service(
&self.service_name,
windows_service::service::ServiceAccess::START,
)
.map_err(|e| ServiceError::ExecFailed {
command: format!("open service `{}`", self.service_name),
source: std::io::Error::other(e),
})?;
let args: [&str; 0] = [];
service.start(&args).map_err(|e| ServiceError::ExecFailed {
command: format!("start service `{}`", self.service_name),
source: std::io::Error::other(e),
})?;
Ok(())
}
fn stop_service(&self) -> Result<(), ServiceError> {
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| ServiceError::ExecFailed {
command: "ServiceManager::connect".into(),
source: std::io::Error::other(e),
})?;
let service = manager
.open_service(
&self.service_name,
windows_service::service::ServiceAccess::STOP,
)
.map_err(|e| ServiceError::ExecFailed {
command: format!("open service `{}`", self.service_name),
source: std::io::Error::other(e),
})?;
service.stop().map_err(|e| ServiceError::ExecFailed {
command: format!("stop service `{}`", self.service_name),
source: std::io::Error::other(e),
})?;
Ok(())
}
fn delete_service(&self) -> Result<(), ServiceError> {
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| ServiceError::ExecFailed {
command: "ServiceManager::connect".into(),
source: std::io::Error::other(e),
})?;
let service = match manager.open_service(
&self.service_name,
windows_service::service::ServiceAccess::DELETE,
) {
Ok(service) => service,
Err(e) if is_missing_service(&e) => return Ok(()),
Err(e) if is_access_denied_error(&e) => return Err(ServiceError::AccessDenied),
Err(e) => {
return Err(ServiceError::ExecFailed {
command: format!("open service `{}`", self.service_name),
source: std::io::Error::other(e),
});
}
};
match service.delete() {
Ok(()) => Ok(()),
Err(e) if is_missing_service(&e) || is_marked_for_delete(&e) => Ok(()),
Err(e) if is_access_denied_error(&e) => Err(ServiceError::AccessDenied),
Err(e) => Err(ServiceError::ExecFailed {
command: format!("delete service `{}`", self.service_name),
source: std::io::Error::other(e),
}),
}
}
}
#[cfg(any(test, target_os = "windows"))]
fn parse_service_executable(command: &Path) -> Option<PathBuf> {
let text = command.to_str()?.trim_start();
let image = if let Some(quoted) = text.strip_prefix('"') {
let end = quoted.find('"')?;
let image = "ed[..end];
let remainder = "ed[end + 1..];
if !remainder.is_empty() && !remainder.chars().next()?.is_whitespace() {
return None;
}
image
} else {
if text.chars().any(char::is_whitespace) {
return None;
}
text
};
let path = PathBuf::from(image);
path.is_absolute().then_some(path)
}
#[cfg(target_os = "windows")]
fn native_query_error(error: windows_service::Error) -> ServiceError {
if is_access_denied_error(&error) {
ServiceError::AccessDenied
} else {
ServiceError::StateQueryFailed {
source: std::io::Error::other(error),
}
}
}
#[cfg(target_os = "windows")]
fn scm_raw_code(error: &windows_service::Error) -> Option<i32> {
match error {
windows_service::Error::Winapi(io) => io.raw_os_error(),
_ => None,
}
}
#[cfg(target_os = "windows")]
fn is_missing_service(error: &windows_service::Error) -> bool {
scm_raw_code(error) == Some(1060)
}
#[cfg(target_os = "windows")]
fn is_access_denied_error(error: &windows_service::Error) -> bool {
if scm_raw_code(error) == Some(5) {
return true;
}
let message = error.to_string().to_ascii_lowercase();
message.contains("access is denied") || message.contains("access denied")
}
#[cfg(target_os = "windows")]
fn is_marked_for_delete(error: &windows_service::Error) -> bool {
scm_raw_code(error) == Some(1072)
}
#[cfg(target_os = "windows")]
fn map_service_state(state: windows_service::service::ServiceState) -> ServiceState {
use windows_service::service::ServiceState as WsState;
match state {
WsState::StartPending => ServiceState::StartPending,
WsState::Stopped => ServiceState::Stopped,
WsState::StopPending => ServiceState::StopPending,
WsState::Running | WsState::PausePending | WsState::Paused | WsState::ContinuePending => {
ServiceState::Running
}
}
}
#[cfg(any(test, target_os = "windows"))]
type ShutdownSender = Arc<Mutex<Option<oneshot::Sender<&'static str>>>>;
#[cfg(any(test, target_os = "windows"))]
fn shutdown_channel() -> (ShutdownSender, oneshot::Receiver<&'static str>) {
let (sender, receiver) = oneshot::channel();
(Arc::new(Mutex::new(Some(sender))), receiver)
}
#[cfg(any(test, target_os = "windows"))]
fn send_shutdown(sender: &ShutdownSender, reason: &'static str) {
if let Ok(mut sender) = sender.lock() {
if let Some(sender) = sender.take() {
let _ = sender.send(reason);
}
}
}
#[cfg(target_os = "windows")]
pub fn start_service_dispatcher(config_path: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
SERVICE_LAUNCH_CONFIG.set(config_path).map_err(|_| {
Box::new(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"Windows service launch context was already initialized",
)) as Box<dyn std::error::Error>
})?;
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
#[cfg(target_os = "windows")]
fn service_main(_service_arguments: Vec<std::ffi::OsString>) {
let result = SERVICE_LAUNCH_CONFIG
.get()
.ok_or_else(|| {
Box::new(std::io::Error::other(
"Windows service launch context is missing",
)) as Box<dyn std::error::Error>
})
.and_then(|config_path| run_service_worker(config_path));
if let Err(error) = result {
tracing::error!(error = %error, "Windows service exited with an error");
}
}
#[cfg(target_os = "windows")]
fn run_service_worker(config_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
use windows_service::service::ServiceState as WsState;
use windows_service::service_control_handler;
let (shutdown_sender, shutdown_receiver) = shutdown_channel();
let status_handle = service_control_handler::register(SERVICE_NAME, move |control| {
handle_service_control(control, &shutdown_sender)
})
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
let result = (|| {
update_status(
status_handle,
WsState::StartPending,
0,
Duration::from_secs(5),
)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
let config = crate::config::Config::load(config_path)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
let collector =
crate::collector::windows::WindowsCollector::new(Some(config.name.as_str()))
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
let shutdown_future =
async move { shutdown_receiver.await.unwrap_or("SCM_CHANNEL_CLOSED") };
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
rt.block_on(crate::run::run_with_shutdown_on_ready(
collector,
config,
shutdown_future,
|| {
update_status(status_handle, WsState::Running, 0, Duration::from_secs(10))
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
},
))
})();
let exit_code = u32::from(result.is_err());
let _ = update_status(
status_handle,
WsState::Stopped,
exit_code,
Duration::from_secs(5),
);
result
}
#[cfg(target_os = "windows")]
fn handle_service_control(
control: windows_service::service::ServiceControl,
shutdown: &ShutdownSender,
) -> windows_service::service_control_handler::ServiceControlHandlerResult {
use windows_service::service::ServiceControl;
use windows_service::service_control_handler::ServiceControlHandlerResult;
match control {
ServiceControl::Stop => {
send_shutdown(shutdown, "SCM_STOP");
ServiceControlHandlerResult::NoError
}
ServiceControl::Shutdown => {
send_shutdown(shutdown, "SCM_SHUTDOWN");
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
}
#[cfg(target_os = "windows")]
fn update_status(
status_handle: windows_service::service_control_handler::ServiceStatusHandle,
state: windows_service::service::ServiceState,
exit_code: u32,
wait_hint: Duration,
) -> windows_service::Result<()> {
use windows_service::service::{
ServiceControlAccept, ServiceExitCode, ServiceStatus, ServiceType,
};
let status = ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: state,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(exit_code),
checkpoint: 0,
wait_hint,
process_id: None,
};
status_handle.set_service_status(status)
}
#[cfg(any(test, target_os = "windows"))]
pub struct WindowsServiceManager {
adapter: Box<dyn ScmAdapter>,
}
#[cfg(any(test, target_os = "windows"))]
impl std::fmt::Debug for WindowsServiceManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WindowsServiceManager")
.field("service", &SERVICE_NAME)
.finish()
}
}
#[cfg(any(test, target_os = "windows"))]
impl WindowsServiceManager {
#[must_use]
pub fn production() -> Self {
#[cfg(target_os = "windows")]
{
Self {
adapter: Box::new(NativeScmAdapter::new(SERVICE_NAME)),
}
}
#[cfg(not(target_os = "windows"))]
{
panic!("WindowsServiceManager::production() can only be called on Windows")
}
}
#[cfg(test)]
#[must_use]
pub(crate) fn with_adapter(adapter: Box<dyn ScmAdapter>) -> Self {
Self { adapter }
}
}
#[cfg(any(test, target_os = "windows"))]
impl ServiceManager for WindowsServiceManager {
fn query_registration(&self) -> Result<ServiceRegistration, ServiceError> {
self.adapter.query_registration()
}
fn start(&self) -> Result<(), ServiceError> {
let state = self.adapter.query_state()?;
match state {
ServiceState::Running | ServiceState::StartPending => {
Ok(())
}
ServiceState::StopPending => {
wait_for_state(&*self.adapter, ServiceState::Stopped)?;
self.adapter.start_service()?;
wait_for_state(&*self.adapter, ServiceState::Running)
}
ServiceState::Stopped | ServiceState::NotInstalled => {
self.adapter.start_service()?;
wait_for_state(&*self.adapter, ServiceState::Running)
}
}
}
fn stop(&self) -> Result<(), ServiceError> {
let state = self.adapter.query_state()?;
match state {
ServiceState::Stopped | ServiceState::NotInstalled => {
Ok(())
}
ServiceState::Running | ServiceState::StartPending => {
self.adapter.stop_service()?;
wait_for_state(&*self.adapter, ServiceState::Stopped)
}
ServiceState::StopPending => {
wait_for_state(&*self.adapter, ServiceState::Stopped)
}
}
}
fn restart(&self) -> Result<(), ServiceError> {
self.stop()?;
self.start()
}
fn is_active(&self) -> Result<bool, ServiceError> {
let state = self.adapter.query_state()?;
Ok(state.is_active())
}
fn unregister(&self) -> Result<(), ServiceError> {
match self.adapter.query_state()? {
ServiceState::NotInstalled => Ok(()),
ServiceState::Stopped => self.adapter.delete_service(),
ServiceState::Running | ServiceState::StartPending => {
self.adapter.stop_service()?;
wait_for_state(&*self.adapter, ServiceState::Stopped)?;
self.adapter.delete_service()
}
ServiceState::StopPending => {
wait_for_state(&*self.adapter, ServiceState::Stopped)?;
self.adapter.delete_service()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
struct MockScmAdapter {
state: Mutex<ServiceState>,
start_error: Mutex<Option<ServiceError>>,
stop_error: Mutex<Option<ServiceError>>,
delete_error: Mutex<Option<ServiceError>>,
query_error: Mutex<Option<ServiceError>>,
immediate_stop: bool,
calls: Mutex<Vec<&'static str>>,
}
impl MockScmAdapter {
fn new(initial: ServiceState) -> Arc<Self> {
Arc::new(Self {
state: Mutex::new(initial),
start_error: Mutex::new(None),
stop_error: Mutex::new(None),
delete_error: Mutex::new(None),
query_error: Mutex::new(None),
immediate_stop: false,
calls: Mutex::new(Vec::new()),
})
}
fn calls(&self) -> Vec<&'static str> {
self.calls.lock().unwrap().clone()
}
}
impl ScmAdapter for MockScmAdapter {
fn query_state(&self) -> Result<ServiceState, ServiceError> {
self.calls.lock().unwrap().push("query");
if let Some(err) = self.query_error.lock().unwrap().take() {
return Err(err);
}
Ok(*self.state.lock().unwrap())
}
fn query_registration(&self) -> Result<ServiceRegistration, ServiceError> {
Ok(ServiceRegistration {
state: self.query_state()?,
executable_path: None,
})
}
fn start_service(&self) -> Result<(), ServiceError> {
self.calls.lock().unwrap().push("start");
if let Some(err) = self.start_error.lock().unwrap().take() {
return Err(err);
}
*self.state.lock().unwrap() = ServiceState::StartPending;
Ok(())
}
fn stop_service(&self) -> Result<(), ServiceError> {
self.calls.lock().unwrap().push("stop");
if let Some(err) = self.stop_error.lock().unwrap().take() {
return Err(err);
}
*self.state.lock().unwrap() = if self.immediate_stop {
ServiceState::Stopped
} else {
ServiceState::StopPending
};
Ok(())
}
fn delete_service(&self) -> Result<(), ServiceError> {
self.calls.lock().unwrap().push("delete");
if let Some(err) = self.delete_error.lock().unwrap().take() {
return Err(err);
}
*self.state.lock().unwrap() = ServiceState::NotInstalled;
Ok(())
}
}
struct MockScmAdapterWrapper(Arc<MockScmAdapter>);
impl ScmAdapter for MockScmAdapterWrapper {
fn query_state(&self) -> Result<ServiceState, ServiceError> {
self.0.query_state()
}
fn query_registration(&self) -> Result<ServiceRegistration, ServiceError> {
self.0.query_registration()
}
fn start_service(&self) -> Result<(), ServiceError> {
self.0.start_service()
}
fn stop_service(&self) -> Result<(), ServiceError> {
self.0.stop_service()
}
fn delete_service(&self) -> Result<(), ServiceError> {
self.0.delete_service()
}
}
fn manager_with_mock(
mock: Arc<MockScmAdapter>,
) -> (WindowsServiceManager, Arc<MockScmAdapter>) {
let mgr =
WindowsServiceManager::with_adapter(Box::new(MockScmAdapterWrapper(Arc::clone(&mock))));
(mgr, mock)
}
#[test]
fn service_state_is_active() {
assert!(ServiceState::Running.is_active());
assert!(ServiceState::StartPending.is_active());
assert!(!ServiceState::Stopped.is_active());
assert!(!ServiceState::StopPending.is_active());
assert!(!ServiceState::NotInstalled.is_active());
}
#[test]
fn start_when_running_is_idempotent() {
let mock = MockScmAdapter::new(ServiceState::Running);
let (mgr, mock_ref) = manager_with_mock(mock);
assert!(mgr.start().is_ok());
assert_eq!(mock_ref.calls(), vec!["query"]);
}
#[test]
fn start_when_start_pending_is_idempotent() {
let mock = MockScmAdapter::new(ServiceState::StartPending);
let (mgr, mock_ref) = manager_with_mock(mock);
assert!(mgr.start().is_ok());
assert_eq!(mock_ref.calls(), vec!["query"]);
}
#[test]
fn stop_when_stopped_is_idempotent() {
let mock = MockScmAdapter::new(ServiceState::Stopped);
let (mgr, mock_ref) = manager_with_mock(mock);
assert!(mgr.stop().is_ok());
assert_eq!(mock_ref.calls(), vec!["query"]);
}
#[test]
fn stop_when_not_installed_is_idempotent() {
let mock = MockScmAdapter::new(ServiceState::NotInstalled);
let (mgr, mock_ref) = manager_with_mock(mock);
assert!(mgr.stop().is_ok());
assert_eq!(mock_ref.calls(), vec!["query"]);
}
#[test]
fn stop_when_stop_pending_waits_then_times_out() {
let mock = MockScmAdapter::new(ServiceState::StopPending);
let (mgr, _mock_ref) = manager_with_mock(mock);
let result = mgr.stop();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ServiceError::Timeout { .. }));
}
#[test]
fn restart_when_stopped_queries_and_starts() {
let mock = MockScmAdapter::new(ServiceState::Stopped);
let (mgr, mock_ref) = manager_with_mock(mock);
let _ = mgr.restart();
let calls = mock_ref.calls();
assert!(calls.contains(&"start"));
}
#[test]
fn restart_when_running_stops_then_attempts_start() {
let mock = MockScmAdapter::new(ServiceState::Running);
let (mgr, mock_ref) = manager_with_mock(mock);
let result = mgr.restart();
let calls = mock_ref.calls();
assert!(calls.contains(&"stop"));
assert!(result.is_err());
}
#[test]
fn is_active_running_returns_true() {
let mock = MockScmAdapter::new(ServiceState::Running);
let (mgr, _mock_ref) = manager_with_mock(mock);
assert!(mgr.is_active().unwrap());
}
#[test]
fn is_active_stopped_returns_false() {
let mock = MockScmAdapter::new(ServiceState::Stopped);
let (mgr, _mock_ref) = manager_with_mock(mock);
assert!(!mgr.is_active().unwrap());
}
#[test]
fn is_active_not_installed_returns_false() {
let mock = MockScmAdapter::new(ServiceState::NotInstalled);
let (mgr, _mock_ref) = manager_with_mock(mock);
assert!(!mgr.is_active().unwrap());
}
#[test]
fn is_active_start_pending_returns_true() {
let mock = MockScmAdapter::new(ServiceState::StartPending);
let (mgr, _mock_ref) = manager_with_mock(mock);
assert!(mgr.is_active().unwrap());
}
#[test]
fn is_active_stop_pending_returns_false() {
let mock = MockScmAdapter::new(ServiceState::StopPending);
let (mgr, _mock_ref) = manager_with_mock(mock);
assert!(!mgr.is_active().unwrap());
}
#[test]
fn start_propagates_query_error() {
let mock = MockScmAdapter::new(ServiceState::Stopped);
mock.query_error
.lock()
.unwrap()
.replace(ServiceError::AccessDenied);
let (mgr, _mock_ref) = manager_with_mock(mock);
let err = mgr.start().unwrap_err();
assert!(matches!(err, ServiceError::AccessDenied));
}
#[test]
fn stop_propagates_query_error() {
let mock = MockScmAdapter::new(ServiceState::Running);
mock.query_error
.lock()
.unwrap()
.replace(ServiceError::AccessDenied);
let (mgr, _mock_ref) = manager_with_mock(mock);
let err = mgr.stop().unwrap_err();
assert!(matches!(err, ServiceError::AccessDenied));
}
#[test]
fn start_propagates_start_error() {
let mock = MockScmAdapter::new(ServiceState::Stopped);
mock.start_error
.lock()
.unwrap()
.replace(ServiceError::AccessDenied);
let (mgr, _mock_ref) = manager_with_mock(mock);
let err = mgr.start().unwrap_err();
assert!(matches!(err, ServiceError::AccessDenied));
}
#[test]
fn stop_propagates_stop_error() {
let mock = MockScmAdapter::new(ServiceState::Running);
mock.stop_error
.lock()
.unwrap()
.replace(ServiceError::AccessDenied);
let (mgr, _mock_ref) = manager_with_mock(mock);
let err = mgr.stop().unwrap_err();
assert!(matches!(err, ServiceError::AccessDenied));
}
#[test]
fn service_name_is_stable() {
assert_eq!(SERVICE_NAME, "greggd");
}
#[test]
fn service_display_name_is_human_readable() {
assert!(!SERVICE_DISPLAY_NAME.is_empty());
assert!(!SERVICE_DISPLAY_NAME.contains('\n'));
}
#[cfg(target_os = "windows")]
const SCM_TEST_IMAGE: &str = r"C:\Gregg\greggd.exe";
#[cfg(not(target_os = "windows"))]
const SCM_TEST_IMAGE: &str = "/opt/gregg/greggd";
#[test]
fn scm_command_parser_extracts_quoted_image() {
let command = format!(r#""{SCM_TEST_IMAGE}" service --config "C:\Gregg\greggd.toml""#);
assert_eq!(
parse_service_executable(Path::new(&command)),
Some(PathBuf::from(SCM_TEST_IMAGE))
);
}
#[test]
fn scm_command_parser_accepts_plain_image_without_arguments() {
assert_eq!(
parse_service_executable(Path::new(SCM_TEST_IMAGE)),
Some(PathBuf::from(SCM_TEST_IMAGE))
);
}
#[test]
fn scm_command_parser_rejects_ambiguous_commands() {
let unquoted = format!("{SCM_TEST_IMAGE} service");
assert_eq!(parse_service_executable(Path::new(&unquoted)), None);
let unterminated = format!(r#""{SCM_TEST_IMAGE} service"#);
assert_eq!(parse_service_executable(Path::new(&unterminated)), None);
assert_eq!(parse_service_executable(Path::new("greggd service")), None);
}
#[test]
fn unregister_when_not_installed_is_idempotent() {
let mock = MockScmAdapter::new(ServiceState::NotInstalled);
let (mgr, mock_ref) = manager_with_mock(mock);
assert!(mgr.unregister().is_ok());
assert_eq!(mock_ref.calls(), vec!["query"]);
}
#[test]
fn unregister_when_stopped_deletes_without_stopping() {
let mock = MockScmAdapter::new(ServiceState::Stopped);
let (mgr, mock_ref) = manager_with_mock(mock);
assert!(mgr.unregister().is_ok());
assert_eq!(mock_ref.calls(), vec!["query", "delete"]);
assert_eq!(*mock_ref.state.lock().unwrap(), ServiceState::NotInstalled);
}
#[test]
fn unregister_when_running_stops_waits_then_deletes() {
let mut mock = MockScmAdapter::new(ServiceState::Running);
Arc::get_mut(&mut mock)
.expect("single owner")
.immediate_stop = true;
let (mgr, mock_ref) = manager_with_mock(mock);
assert!(mgr.unregister().is_ok());
let calls = mock_ref.calls();
assert!(
calls.contains(&"stop"),
"must stop before delete: {calls:?}"
);
assert!(calls.contains(&"delete"), "must delete: {calls:?}");
assert!(
calls.iter().position(|c| *c == "stop") < calls.iter().position(|c| *c == "delete"),
"stop must precede delete: {calls:?}"
);
assert_eq!(*mock_ref.state.lock().unwrap(), ServiceState::NotInstalled);
}
#[test]
fn unregister_propagates_access_denied_on_delete() {
let mock = MockScmAdapter::new(ServiceState::Stopped);
mock.delete_error
.lock()
.unwrap()
.replace(ServiceError::AccessDenied);
let (mgr, _mock_ref) = manager_with_mock(mock);
let err = mgr.unregister().unwrap_err();
assert!(matches!(err, ServiceError::AccessDenied));
}
#[test]
fn unregister_propagates_query_error() {
let mock = MockScmAdapter::new(ServiceState::Running);
mock.query_error
.lock()
.unwrap()
.replace(ServiceError::AccessDenied);
let (mgr, mock_ref) = manager_with_mock(mock);
let err = mgr.unregister().unwrap_err();
assert!(matches!(err, ServiceError::AccessDenied));
assert_eq!(mock_ref.calls(), vec!["query"]);
}
#[test]
fn windows_service_manager_debug() {
let mock = MockScmAdapter::new(ServiceState::Running);
let (mgr, _mock_ref) = manager_with_mock(mock);
let debug = format!("{mgr:?}");
assert!(debug.contains("WindowsServiceManager"));
}
#[tokio::test]
async fn stop_completes_the_async_shutdown_signal_once() {
let (sender, receiver) = shutdown_channel();
send_shutdown(&sender, "SCM_STOP");
send_shutdown(&sender, "SCM_SHUTDOWN");
assert_eq!(receiver.await, Ok("SCM_STOP"));
}
#[tokio::test]
async fn shutdown_completes_the_async_shutdown_signal() {
let (sender, receiver) = shutdown_channel();
send_shutdown(&sender, "SCM_SHUTDOWN");
assert_eq!(receiver.await, Ok("SCM_SHUTDOWN"));
}
#[tokio::test]
async fn dropped_shutdown_sender_has_a_stable_reason() {
let (sender, receiver) = shutdown_channel();
drop(sender);
assert_eq!(
receiver.await.unwrap_or("SCM_CHANNEL_CLOSED"),
"SCM_CHANNEL_CLOSED"
);
}
#[cfg(target_os = "windows")]
#[test]
fn stop_control_maps_to_no_error_and_scm_stop() {
use windows_service::service::ServiceControl;
use windows_service::service_control_handler::ServiceControlHandlerResult;
let (sender, mut receiver) = shutdown_channel();
assert!(matches!(
handle_service_control(ServiceControl::Stop, &sender),
ServiceControlHandlerResult::NoError
));
assert_eq!(receiver.try_recv(), Ok("SCM_STOP"));
}
#[cfg(target_os = "windows")]
#[test]
fn shutdown_control_maps_to_no_error_and_scm_shutdown() {
use windows_service::service::ServiceControl;
use windows_service::service_control_handler::ServiceControlHandlerResult;
let (sender, mut receiver) = shutdown_channel();
assert!(matches!(
handle_service_control(ServiceControl::Shutdown, &sender),
ServiceControlHandlerResult::NoError
));
assert_eq!(receiver.try_recv(), Ok("SCM_SHUTDOWN"));
}
#[cfg(target_os = "windows")]
#[test]
fn interrogate_does_not_complete_shutdown() {
use windows_service::service::ServiceControl;
use windows_service::service_control_handler::ServiceControlHandlerResult;
let (sender, mut receiver) = shutdown_channel();
assert!(matches!(
handle_service_control(ServiceControl::Interrogate, &sender),
ServiceControlHandlerResult::NoError
));
assert!(matches!(
receiver.try_recv(),
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
));
}
#[cfg(target_os = "windows")]
#[test]
fn unsupported_control_is_not_implemented_and_does_not_shutdown() {
use windows_service::service::ServiceControl;
use windows_service::service_control_handler::ServiceControlHandlerResult;
let (sender, mut receiver) = shutdown_channel();
assert!(matches!(
handle_service_control(ServiceControl::Pause, &sender),
ServiceControlHandlerResult::NotImplemented
));
assert!(matches!(
receiver.try_recv(),
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
));
}
#[cfg(target_os = "windows")]
#[test]
fn duplicate_stop_and_shutdown_controls_preserve_first_reason() {
use windows_service::service::ServiceControl;
let (sender, mut receiver) = shutdown_channel();
handle_service_control(ServiceControl::Stop, &sender);
handle_service_control(ServiceControl::Shutdown, &sender);
assert_eq!(receiver.try_recv(), Ok("SCM_STOP"));
let (sender, mut receiver) = shutdown_channel();
handle_service_control(ServiceControl::Shutdown, &sender);
handle_service_control(ServiceControl::Stop, &sender);
assert_eq!(receiver.try_recv(), Ok("SCM_SHUTDOWN"));
}
#[test]
fn service_error_access_denied_display() {
let err = ServiceError::AccessDenied;
let msg = format!("{err}");
assert!(msg.contains("access denied"));
}
#[test]
fn service_error_timeout_display() {
let err = ServiceError::Timeout { waited_ms: 5000 };
let msg = format!("{err}");
assert!(msg.contains("5000"));
assert!(msg.contains("timed out"));
}
#[test]
fn access_denied_maps_to_permission_denied() {
let code = crate::cli::ExitCode::from(&ServiceError::AccessDenied);
assert_eq!(code, crate::cli::ExitCode::PermissionDenied);
}
#[test]
fn timeout_maps_to_service_error() {
let code = crate::cli::ExitCode::from(&ServiceError::Timeout { waited_ms: 1000 });
assert_eq!(code, crate::cli::ExitCode::ServiceError);
}
}