From a29a676bb15c5f8ac9ec0d7c8a678abdc65a52ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:08:13 +0100 Subject: [PATCH 1/5] feat(service): add linger option for service install and restart commands --- clients/agent-runtime/README.md | 7 +- clients/agent-runtime/src/daemon/mod.rs | 5 + clients/agent-runtime/src/gateway/mod.rs | 6 +- clients/agent-runtime/src/lib.rs | 17 +- clients/agent-runtime/src/main.rs | 29 +- clients/agent-runtime/src/service/mod.rs | 77 +++- .../agent-runtime/src/tools/http_request.rs | 3 +- clients/agent-runtime/src/update/mod.rs | 335 ++++++++++++++++++ 8 files changed, 462 insertions(+), 17 deletions(-) create mode 100644 clients/agent-runtime/src/update/mod.rs diff --git a/clients/agent-runtime/README.md b/clients/agent-runtime/README.md index 7e0271c84..6b77265f7 100755 --- a/clients/agent-runtime/README.md +++ b/clients/agent-runtime/README.md @@ -112,6 +112,8 @@ corvus integrations info Telegram # Manage background service corvus service install +corvus service install --linger on # Linux: keep running after logout/reboot (user service + linger) +corvus service restart # useful after binary updates corvus service status # Migrate memory from OpenClaw (safe preview first) @@ -119,6 +121,9 @@ corvus migrate openclaw --dry-run corvus migrate openclaw ``` +Corvus also checks for newer releases in `agent`, `daemon`, and `status` commands. +Set `CORVUS_DISABLE_UPDATE_CHECK=1` to disable update notifications. + > **Dev fallback (no global install):** prefix commands with `cargo run --release --` (example: `cargo run --release -- status`). @@ -447,7 +452,7 @@ See [aieos.org](https://aieos.org) for the full schema and live examples. | `gateway` | Start webhook server (default: `127.0.0.1:8080`) | | `gateway --port 0` | Random port mode | | `daemon` | Start long-running autonomous runtime | -| `service install/start/stop/status/uninstall` | Manage user-level background service | +| `service install/start/restart/stop/status/uninstall` | Manage background service lifecycle | | `doctor` | Diagnose daemon/scheduler/channel freshness | | `status` | Show full system status | | `channel doctor` | Run health checks for configured channels | diff --git a/clients/agent-runtime/src/daemon/mod.rs b/clients/agent-runtime/src/daemon/mod.rs index 9eec82ce2..98abb1af7 100755 --- a/clients/agent-runtime/src/daemon/mod.rs +++ b/clients/agent-runtime/src/daemon/mod.rs @@ -162,6 +162,11 @@ where Err(e) => { crate::health::mark_component_error(name, e.to_string()); tracing::error!("Daemon component '{name}' failed: {e}"); + if name == "gateway" && e.to_string().contains("Address already in use") { + tracing::warn!( + "Gateway port is already in use. This usually means another daemon/gateway instance is already running. If this happened after an upgrade, run `corvus service restart` instead of starting a second daemon process." + ); + } } } diff --git a/clients/agent-runtime/src/gateway/mod.rs b/clients/agent-runtime/src/gateway/mod.rs index 5a3000ffb..46af767eb 100755 --- a/clients/agent-runtime/src/gateway/mod.rs +++ b/clients/agent-runtime/src/gateway/mod.rs @@ -1100,7 +1100,6 @@ mod tests { assert!(text.contains("corvus_heartbeat_ticks_total 1")); } - #[test] fn extract_bearer_token_accepts_case_insensitive_scheme_and_trims() { let mut headers = HeaderMap::new(); @@ -1118,10 +1117,7 @@ mod tests { let mut headers = HeaderMap::new(); let oversized = "x".repeat(TOKEN_MAX_LEN + 1); let auth = format!("Bearer {oversized}"); - headers.insert( - header::AUTHORIZATION, - HeaderValue::from_str(&auth).unwrap(), - ); + headers.insert(header::AUTHORIZATION, HeaderValue::from_str(&auth).unwrap()); assert!(extract_bearer_token(&headers).is_none()); } diff --git a/clients/agent-runtime/src/lib.rs b/clients/agent-runtime/src/lib.rs index 99e3e51f9..b4a9c5323 100755 --- a/clients/agent-runtime/src/lib.rs +++ b/clients/agent-runtime/src/lib.rs @@ -35,7 +35,7 @@ dead_code )] -use clap::Subcommand; +use clap::{Subcommand, ValueEnum}; use serde::{Deserialize, Serialize}; pub mod agent; @@ -71,13 +71,26 @@ pub mod util; pub use config::Config; +#[derive(Debug, Clone, Copy, ValueEnum, Serialize, Deserialize, PartialEq, Eq)] +pub enum ServiceLingerMode { + Keep, + On, + Off, +} + /// Service management subcommands #[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ServiceCommands { /// Install daemon service unit for auto-start and restart - Install, + Install { + /// Linux only: keep user service active without an interactive session + #[arg(long, value_enum, default_value_t = ServiceLingerMode::Keep)] + linger: ServiceLingerMode, + }, /// Start daemon service Start, + /// Restart daemon service + Restart, /// Stop daemon service Stop, /// Check daemon service status diff --git a/clients/agent-runtime/src/main.rs b/clients/agent-runtime/src/main.rs index 091ed3b79..b49d232f1 100644 --- a/clients/agent-runtime/src/main.rs +++ b/clients/agent-runtime/src/main.rs @@ -33,7 +33,7 @@ )] use anyhow::{bail, Result}; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use dialoguer::{Input, Password}; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; @@ -70,6 +70,7 @@ mod skillforge; mod skills; mod tools; mod tunnel; +mod update; mod util; use config::Config; @@ -88,12 +89,25 @@ struct Cli { command: Commands, } +#[derive(Debug, Clone, Copy, ValueEnum, Serialize, Deserialize, PartialEq, Eq)] +enum ServiceLingerMode { + Keep, + On, + Off, +} + #[derive(Subcommand, Debug)] enum ServiceCommands { /// Install daemon service unit for auto-start and restart - Install, + Install { + /// Linux only: keep user service active without an interactive session + #[arg(long, value_enum, default_value_t = ServiceLingerMode::Keep)] + linger: ServiceLingerMode, + }, /// Start daemon service Start, + /// Restart daemon service + Restart, /// Stop daemon service Stop, /// Check daemon service status @@ -591,9 +605,12 @@ async fn main() -> Result<()> { model, temperature, peripheral, - } => agent::run(config, message, provider, model, temperature, peripheral) - .await - .map(|_| ()), + } => { + update::maybe_print_update_notice(&config).await; + agent::run(config, message, provider, model, temperature, peripheral) + .await + .map(|_| ()) + } Commands::Gateway { port, host } => { let port = port.unwrap_or(config.gateway.port); @@ -607,6 +624,7 @@ async fn main() -> Result<()> { } Commands::Daemon { port, host } => { + update::maybe_print_update_notice(&config).await; let port = port.unwrap_or(config.gateway.port); let host = host.unwrap_or_else(|| config.gateway.host.clone()); if port == 0 { @@ -618,6 +636,7 @@ async fn main() -> Result<()> { } Commands::Status => { + update::maybe_print_update_notice(&config).await; println!("🦀 Corvus Status"); println!(); println!("Version: {}", env!("CARGO_PKG_VERSION")); diff --git a/clients/agent-runtime/src/service/mod.rs b/clients/agent-runtime/src/service/mod.rs index 2e6c27209..9758fd379 100755 --- a/clients/agent-runtime/src/service/mod.rs +++ b/clients/agent-runtime/src/service/mod.rs @@ -13,26 +13,35 @@ fn windows_task_name() -> &'static str { pub fn handle_command(command: &crate::ServiceCommands, config: &Config) -> Result<()> { match command { - crate::ServiceCommands::Install => install(config), + crate::ServiceCommands::Install { linger } => install(config, *linger), crate::ServiceCommands::Start => start(config), + crate::ServiceCommands::Restart => restart(config), crate::ServiceCommands::Stop => stop(config), crate::ServiceCommands::Status => status(config), crate::ServiceCommands::Uninstall => uninstall(config), } } -fn install(config: &Config) -> Result<()> { +fn install(config: &Config, linger: crate::ServiceLingerMode) -> Result<()> { if cfg!(target_os = "macos") { + maybe_warn_unsupported_linger_mode(linger); install_macos(config) } else if cfg!(target_os = "linux") { + apply_linux_linger_mode(linger)?; install_linux(config) } else if cfg!(target_os = "windows") { + maybe_warn_unsupported_linger_mode(linger); install_windows(config) } else { anyhow::bail!("Service management is supported on macOS and Linux only"); } } +fn restart(config: &Config) -> Result<()> { + stop(config)?; + start(config) +} + fn start(config: &Config) -> Result<()> { if cfg!(target_os = "macos") { let plist = macos_service_file()?; @@ -105,6 +114,18 @@ fn status(config: &Config) -> Result<()> { run_capture(Command::new("systemctl").args(["--user", "is-active", "corvus.service"])) .unwrap_or_else(|_| "unknown".into()); println!("Service state: {}", out.trim()); + match linux_linger_state() { + Ok(Some(enabled)) => println!( + "Linger: {}", + if enabled { + "enabled (keeps service alive without login)" + } else { + "disabled (service starts after user login)" + } + ), + Ok(None) => println!("Linger: unknown"), + Err(e) => println!("Linger: unavailable ({e})"), + } println!("Unit: {}", linux_service_file(config)?.display()); return Ok(()); } @@ -137,6 +158,58 @@ fn status(config: &Config) -> Result<()> { anyhow::bail!("Service management is supported on macOS and Linux only") } +fn maybe_warn_unsupported_linger_mode(linger: crate::ServiceLingerMode) { + if !matches!(linger, crate::ServiceLingerMode::Keep) { + println!( + "⚠️ --linger applies only to Linux user services; ignoring requested mode on this OS." + ); + } +} + +fn current_username() -> Result { + std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .context("Could not resolve current username (USER/LOGNAME)") +} + +fn apply_linux_linger_mode(linger: crate::ServiceLingerMode) -> Result<()> { + let user = current_username()?; + match linger { + crate::ServiceLingerMode::Keep => Ok(()), + crate::ServiceLingerMode::On => { + run_checked(Command::new("loginctl").args(["enable-linger", &user]))?; + println!("✅ Enabled linger for user '{user}'"); + Ok(()) + } + crate::ServiceLingerMode::Off => { + run_checked(Command::new("loginctl").args(["disable-linger", &user]))?; + println!("✅ Disabled linger for user '{user}'"); + Ok(()) + } + } +} + +fn linux_linger_state() -> Result> { + if !cfg!(target_os = "linux") { + return Ok(None); + } + + let user = current_username()?; + let out = run_capture(Command::new("loginctl").args([ + "show-user", + &user, + "-p", + "Linger", + "--value", + ]))?; + let value = out.trim(); + if value.is_empty() { + return Ok(None); + } + + Ok(Some(value.eq_ignore_ascii_case("yes"))) +} + fn uninstall(config: &Config) -> Result<()> { stop(config)?; diff --git a/clients/agent-runtime/src/tools/http_request.rs b/clients/agent-runtime/src/tools/http_request.rs index c9511654d..9866a17e9 100755 --- a/clients/agent-runtime/src/tools/http_request.rs +++ b/clients/agent-runtime/src/tools/http_request.rs @@ -777,7 +777,6 @@ mod tests { .any(|(k, v)| k == "Content-Type" && v == "application/json")); } - #[test] fn parse_headers_rejects_hop_by_hop_headers() { let tool = test_tool(vec!["example.com"]); @@ -807,7 +806,7 @@ mod tests { let tool = test_tool(vec!["example.com"]); let headers = json!({ "X-Test": "value -malicious" + malicious" }); let err = tool.parse_headers(&headers).unwrap_err().to_string(); diff --git a/clients/agent-runtime/src/update/mod.rs b/clients/agent-runtime/src/update/mod.rs new file mode 100644 index 000000000..45d79abb8 --- /dev/null +++ b/clients/agent-runtime/src/update/mod.rs @@ -0,0 +1,335 @@ +use crate::config::Config; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const VERSION_CHECK_FILE: &str = "version_check.json"; +const VERSION_CHECK_TTL_SECS: u64 = 24 * 60 * 60; +const VERSION_CHECK_TIMEOUT_SECS: u64 = 2; +const UPDATE_CHECK_DISABLE_ENV: &str = "CORVUS_DISABLE_UPDATE_CHECK"; +const INSTALL_SCRIPT_URL: &str = "https://profiletailors.com/install"; +const PACKAGE_NAME: &str = "@dallay/corvus"; +const RELEASE_ENDPOINTS: [&str; 2] = [ + "https://api.github.com/repos/profiletailors/corvus/releases/latest", + "https://api.github.com/repos/dallay/corvus/releases/latest", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct VersionCheckState { + latest_version: String, + checked_at_unix: u64, + update_available: bool, +} + +#[derive(Debug, Deserialize)] +struct LatestReleaseResponse { + tag_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UpdateNotice { + current_version: String, + latest_version: String, +} + +pub async fn maybe_print_update_notice(config: &Config) { + if is_update_check_disabled() { + return; + } + + if let Some(notice) = check_for_update(config, env!("CARGO_PKG_VERSION")).await { + println!(); + println!( + "⬆️ Update available: v{} (current v{})", + notice.latest_version, notice.current_version + ); + println!( + " If installed via script/binary: curl -fsSL {} | bash", + INSTALL_SCRIPT_URL + ); + println!( + " If installed via package manager: npm i -g {}@latest (or pnpm/yarn/bun)", + PACKAGE_NAME + ); + } +} + +async fn check_for_update(config: &Config, current_version: &str) -> Option { + let current = normalize_version(current_version)?; + let state_path = version_check_path(&config.workspace_dir); + let cached_state = load_state(&state_path).ok().flatten(); + + if let Some(cached) = cached_state.as_ref().filter(|state| !is_stale(state)) { + return notice_from_state(current.clone(), cached); + } + + let fetched = tokio::time::timeout( + Duration::from_secs(VERSION_CHECK_TIMEOUT_SECS), + fetch_latest_release_version(), + ) + .await + .ok() + .and_then(Result::ok); + + if let Some(latest_version) = fetched { + let update_available = + compare_semverish(&latest_version, ¤t).is_some_and(|ordering| ordering.is_gt()); + let state = VersionCheckState { + latest_version, + checked_at_unix: now_unix_secs(), + update_available, + }; + + let _ = save_state(&state_path, &state); + return notice_from_state(current, &state); + } + + cached_state + .as_ref() + .and_then(|state| notice_from_state(current, state)) +} + +fn notice_from_state(current_version: String, state: &VersionCheckState) -> Option { + if !state.update_available { + return None; + } + + if compare_semverish(&state.latest_version, ¤t_version) + .is_some_and(|ordering| ordering.is_gt()) + { + Some(UpdateNotice { + current_version, + latest_version: state.latest_version.clone(), + }) + } else { + None + } +} + +fn is_update_check_disabled() -> bool { + std::env::var(UPDATE_CHECK_DISABLE_ENV) + .ok() + .is_some_and(|raw| { + matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) + }) +} + +fn version_check_path(workspace_dir: &Path) -> PathBuf { + workspace_dir.join("state").join(VERSION_CHECK_FILE) +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) +} + +fn is_stale(state: &VersionCheckState) -> bool { + now_unix_secs().saturating_sub(state.checked_at_unix) > VERSION_CHECK_TTL_SECS +} + +fn load_state(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + + let raw = fs::read_to_string(path) + .with_context(|| format!("failed to read version check state at {}", path.display()))?; + let state = serde_json::from_str::(&raw) + .context("failed to parse version check state")?; + Ok(Some(state)) +} + +fn save_state(path: &Path, state: &VersionCheckState) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create version check state directory {}", + parent.display() + ) + })?; + } + + let body = serde_json::to_vec_pretty(state).context("failed to serialize version state")?; + fs::write(path, body) + .with_context(|| format!("failed to write version check state at {}", path.display())) +} + +async fn fetch_latest_release_version() -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(VERSION_CHECK_TIMEOUT_SECS)) + .build() + .context("failed to build update-check client")?; + + for endpoint in RELEASE_ENDPOINTS { + let response = client + .get(endpoint) + .header(reqwest::header::USER_AGENT, "corvus-update-check") + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .await; + + let Ok(response) = response else { + continue; + }; + + let Ok(response) = response.error_for_status() else { + continue; + }; + + let payload: LatestReleaseResponse = response + .json() + .await + .context("failed to parse release metadata")?; + + if let Some(normalized) = normalize_version(&payload.tag_name) { + return Ok(normalized); + } + } + + anyhow::bail!("failed to resolve latest release version from release endpoints") +} + +fn normalize_version(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let normalized = trimmed + .strip_prefix('v') + .or_else(|| trimmed.strip_prefix('V')) + .unwrap_or(trimmed) + .to_string(); + + parse_semverish(&normalized).map(|_| normalized) +} + +fn compare_semverish(left: &str, right: &str) -> Option { + let left_parsed = parse_semverish(left)?; + let right_parsed = parse_semverish(right)?; + Some(left_parsed.cmp(&right_parsed)) +} + +fn parse_semverish(version: &str) -> Option<(u64, u64, u64)> { + let core = version + .split(['-', '+']) + .next() + .map(str::trim) + .filter(|value| !value.is_empty())?; + + let mut parts = core.split('.'); + + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + if parts.next().is_some() { + return None; + } + + Some((major, minor, patch)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_version_accepts_with_or_without_v_prefix() { + assert_eq!(normalize_version("v0.1.7"), Some("0.1.7".to_string())); + assert_eq!(normalize_version("0.1.7"), Some("0.1.7".to_string())); + assert_eq!(normalize_version("V1.2.3"), Some("1.2.3".to_string())); + } + + #[test] + fn normalize_version_rejects_invalid_values() { + assert_eq!(normalize_version("latest"), None); + assert_eq!(normalize_version("v1"), None); + assert_eq!(normalize_version(""), None); + } + + #[test] + fn compare_semverish_orders_versions() { + assert_eq!( + compare_semverish("0.1.8", "0.1.7"), + Some(std::cmp::Ordering::Greater) + ); + assert_eq!( + compare_semverish("0.1.7", "0.1.7"), + Some(std::cmp::Ordering::Equal) + ); + assert_eq!( + compare_semverish("0.1.6", "0.1.7"), + Some(std::cmp::Ordering::Less) + ); + } + + #[test] + fn parse_semverish_accepts_pre_release_patch_suffix() { + assert_eq!(parse_semverish("1.2.3-beta.1"), Some((1, 2, 3))); + } + + #[test] + fn stale_cache_detection_works() { + let now = now_unix_secs(); + let fresh = VersionCheckState { + latest_version: "0.1.8".into(), + checked_at_unix: now.saturating_sub(30), + update_available: true, + }; + let stale = VersionCheckState { + latest_version: "0.1.8".into(), + checked_at_unix: now.saturating_sub(VERSION_CHECK_TTL_SECS + 10), + update_available: true, + }; + + assert!(!is_stale(&fresh)); + assert!(is_stale(&stale)); + } + + #[test] + fn notice_requires_newer_latest_version() { + let update = VersionCheckState { + latest_version: "0.1.8".into(), + checked_at_unix: now_unix_secs(), + update_available: true, + }; + let no_update_same = VersionCheckState { + latest_version: "0.1.7".into(), + checked_at_unix: now_unix_secs(), + update_available: true, + }; + let no_update_flag = VersionCheckState { + latest_version: "0.1.8".into(), + checked_at_unix: now_unix_secs(), + update_available: false, + }; + + assert!(notice_from_state("0.1.7".into(), &update).is_some()); + assert!(notice_from_state("0.1.7".into(), &no_update_same).is_none()); + assert!(notice_from_state("0.1.7".into(), &no_update_flag).is_none()); + } + + #[test] + fn save_and_load_state_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state").join("version_check.json"); + let state = VersionCheckState { + latest_version: "0.1.9".into(), + checked_at_unix: 123, + update_available: true, + }; + + save_state(&path, &state).unwrap(); + let loaded = load_state(&path).unwrap().unwrap(); + + assert_eq!(loaded.latest_version, "0.1.9"); + assert_eq!(loaded.checked_at_unix, 123); + assert!(loaded.update_available); + } +} From ce9d4caf74299e1513b87f151978a542b125f60e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:28:56 +0100 Subject: [PATCH 2/5] feat(http): enhance response header handling and improve username resolution --- clients/agent-runtime/src/daemon/mod.rs | 7 +- clients/agent-runtime/src/main.rs | 40 ++---- clients/agent-runtime/src/service/mod.rs | 22 ++- .../agent-runtime/src/tools/http_request.rs | 4 +- clients/agent-runtime/src/update/mod.rs | 129 ++++++++++++++---- 5 files changed, 134 insertions(+), 68 deletions(-) diff --git a/clients/agent-runtime/src/daemon/mod.rs b/clients/agent-runtime/src/daemon/mod.rs index 98abb1af7..d67e4728f 100755 --- a/clients/agent-runtime/src/daemon/mod.rs +++ b/clients/agent-runtime/src/daemon/mod.rs @@ -160,9 +160,10 @@ where backoff = initial_backoff_secs.max(1); } Err(e) => { - crate::health::mark_component_error(name, e.to_string()); - tracing::error!("Daemon component '{name}' failed: {e}"); - if name == "gateway" && e.to_string().contains("Address already in use") { + let err_str = e.to_string(); + crate::health::mark_component_error(name, err_str.clone()); + tracing::error!("Daemon component '{name}' failed: {err_str}"); + if name == "gateway" && err_str.contains("Address already in use") { tracing::warn!( "Gateway port is already in use. This usually means another daemon/gateway instance is already running. If this happened after an upgrade, run `corvus service restart` instead of starting a second daemon process." ); diff --git a/clients/agent-runtime/src/main.rs b/clients/agent-runtime/src/main.rs index b49d232f1..b2272dea5 100644 --- a/clients/agent-runtime/src/main.rs +++ b/clients/agent-runtime/src/main.rs @@ -33,7 +33,7 @@ )] use anyhow::{bail, Result}; -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{Parser, Subcommand}; use dialoguer::{Input, Password}; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; @@ -75,8 +75,10 @@ mod util; use config::Config; -// Re-export so binary's hardware/peripherals modules can use crate::HardwareCommands etc. -pub use corvus::{HardwareCommands, PeripheralCommands}; +// Re-export so binary modules can use crate::...Commands from the library crate. +pub use corvus::{ + HardwareCommands, PeripheralCommands, ServiceCommands, ServiceLingerMode, +}; /// `Corvus` - Zero overhead. Zero compromise. 100% Rust. #[derive(Parser, Debug)] @@ -89,33 +91,6 @@ struct Cli { command: Commands, } -#[derive(Debug, Clone, Copy, ValueEnum, Serialize, Deserialize, PartialEq, Eq)] -enum ServiceLingerMode { - Keep, - On, - Off, -} - -#[derive(Subcommand, Debug)] -enum ServiceCommands { - /// Install daemon service unit for auto-start and restart - Install { - /// Linux only: keep user service active without an interactive session - #[arg(long, value_enum, default_value_t = ServiceLingerMode::Keep)] - linger: ServiceLingerMode, - }, - /// Start daemon service - Start, - /// Restart daemon service - Restart, - /// Stop daemon service - Stop, - /// Check daemon service status - Status, - /// Uninstall daemon service unit - Uninstall, -} - #[derive(Subcommand, Debug)] enum Commands { /// Initialize your workspace and configuration @@ -606,7 +581,10 @@ async fn main() -> Result<()> { temperature, peripheral, } => { - update::maybe_print_update_notice(&config).await; + let update_config = config.clone(); + tokio::spawn(async move { + update::maybe_print_update_notice(&update_config).await; + }); agent::run(config, message, provider, model, temperature, peripheral) .await .map(|_| ()) diff --git a/clients/agent-runtime/src/service/mod.rs b/clients/agent-runtime/src/service/mod.rs index 9758fd379..e0df4bd1c 100755 --- a/clients/agent-runtime/src/service/mod.rs +++ b/clients/agent-runtime/src/service/mod.rs @@ -167,9 +167,25 @@ fn maybe_warn_unsupported_linger_mode(linger: crate::ServiceLingerMode) { } fn current_username() -> Result { - std::env::var("USER") - .or_else(|_| std::env::var("LOGNAME")) - .context("Could not resolve current username (USER/LOGNAME)") + let env_username = std::env::var("USER") + .ok() + .or_else(|| std::env::var("LOGNAME").ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + if let Some(username) = env_username { + return Ok(username); + } + + let fallback_username = Command::new("whoami") + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + fallback_username.context("Could not resolve current username (USER/LOGNAME)") } fn apply_linux_linger_mode(linger: crate::ServiceLingerMode) -> Result<()> { diff --git a/clients/agent-runtime/src/tools/http_request.rs b/clients/agent-runtime/src/tools/http_request.rs index 9866a17e9..0953ed453 100755 --- a/clients/agent-runtime/src/tools/http_request.rs +++ b/clients/agent-runtime/src/tools/http_request.rs @@ -302,12 +302,12 @@ impl Tool for HttpRequestTool { // Get response headers (redact sensitive ones) let response_headers = response.headers().iter(); let headers_text = response_headers - .map(|(k, _)| { + .map(|(k, v)| { let is_sensitive = k.as_str().to_lowercase().contains("set-cookie"); if is_sensitive { format!("{}: ***REDACTED***", k.as_str()) } else { - format!("{}: {:?}", k.as_str(), k.as_str()) + format!("{}: {:?}", k.as_str(), v) } }) .collect::>() diff --git a/clients/agent-runtime/src/update/mod.rs b/clients/agent-runtime/src/update/mod.rs index 45d79abb8..1afb4aa53 100644 --- a/clients/agent-runtime/src/update/mod.rs +++ b/clients/agent-runtime/src/update/mod.rs @@ -1,7 +1,6 @@ use crate::config::Config; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -59,19 +58,13 @@ pub async fn maybe_print_update_notice(config: &Config) { async fn check_for_update(config: &Config, current_version: &str) -> Option { let current = normalize_version(current_version)?; let state_path = version_check_path(&config.workspace_dir); - let cached_state = load_state(&state_path).ok().flatten(); + let cached_state = load_state(&state_path).await.ok().flatten(); if let Some(cached) = cached_state.as_ref().filter(|state| !is_stale(state)) { return notice_from_state(current.clone(), cached); } - let fetched = tokio::time::timeout( - Duration::from_secs(VERSION_CHECK_TIMEOUT_SECS), - fetch_latest_release_version(), - ) - .await - .ok() - .and_then(Result::ok); + let fetched = fetch_latest_release_version().await.ok(); if let Some(latest_version) = fetched { let update_available = @@ -82,7 +75,7 @@ async fn check_for_update(config: &Config, current_version: &str) -> Option bool { now_unix_secs().saturating_sub(state.checked_at_unix) > VERSION_CHECK_TTL_SECS } -fn load_state(path: &Path) -> Result> { - if !path.exists() { +async fn load_state(path: &Path) -> Result> { + if !tokio::fs::try_exists(path) + .await + .with_context(|| format!("failed to check version check state at {}", path.display()))? + { return Ok(None); } - let raw = fs::read_to_string(path) + let raw = tokio::fs::read_to_string(path) + .await .with_context(|| format!("failed to read version check state at {}", path.display()))?; let state = serde_json::from_str::(&raw) .context("failed to parse version check state")?; Ok(Some(state)) } -fn save_state(path: &Path, state: &VersionCheckState) -> Result<()> { +async fn save_state(path: &Path, state: &VersionCheckState) -> Result<()> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { + tokio::fs::create_dir_all(parent).await.with_context(|| { format!( "failed to create version check state directory {}", parent.display() @@ -156,7 +153,8 @@ fn save_state(path: &Path, state: &VersionCheckState) -> Result<()> { } let body = serde_json::to_vec_pretty(state).context("failed to serialize version state")?; - fs::write(path, body) + tokio::fs::write(path, body) + .await .with_context(|| format!("failed to write version check state at {}", path.display())) } @@ -213,15 +211,69 @@ fn normalize_version(raw: &str) -> Option { fn compare_semverish(left: &str, right: &str) -> Option { let left_parsed = parse_semverish(left)?; let right_parsed = parse_semverish(right)?; - Some(left_parsed.cmp(&right_parsed)) + + let core_ordering = left_parsed + .0 + .cmp(&right_parsed.0) + .then(left_parsed.1.cmp(&right_parsed.1)) + .then(left_parsed.2.cmp(&right_parsed.2)); + if !core_ordering.is_eq() { + return Some(core_ordering); + } + + Some(compare_prerelease( + left_parsed.3.as_deref(), + right_parsed.3.as_deref(), + )) +} + +fn compare_prerelease(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering { + match (left, right) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Greater, + (Some(_), None) => std::cmp::Ordering::Less, + (Some(left), Some(right)) => { + let left_parts: Vec<&str> = left.split('.').collect(); + let right_parts: Vec<&str> = right.split('.').collect(); + + for (l, r) in left_parts.iter().zip(right_parts.iter()) { + let left_numeric = l.parse::(); + let right_numeric = r.parse::(); + + let ordering = match (left_numeric, right_numeric) { + (Ok(a), Ok(b)) => a.cmp(&b), + (Ok(_), Err(_)) => std::cmp::Ordering::Less, + (Err(_), Ok(_)) => std::cmp::Ordering::Greater, + (Err(_), Err(_)) => l.cmp(r), + }; + + if !ordering.is_eq() { + return ordering; + } + } + + left_parts.len().cmp(&right_parts.len()) + } + } } -fn parse_semverish(version: &str) -> Option<(u64, u64, u64)> { - let core = version - .split(['-', '+']) - .next() - .map(str::trim) - .filter(|value| !value.is_empty())?; +fn parse_semverish(version: &str) -> Option<(u64, u64, u64, Option)> { + let version = version.trim(); + if version.is_empty() { + return None; + } + + let without_build = version.split_once('+').map_or(version, |(core, _)| core); + let (core, prerelease_raw) = without_build + .split_once('-') + .map_or((without_build, None), |(core, prerelease)| { + (core, Some(prerelease.trim())) + }); + + let core = core.trim(); + if core.is_empty() { + return None; + } let mut parts = core.split('.'); @@ -232,7 +284,11 @@ fn parse_semverish(version: &str) -> Option<(u64, u64, u64)> { return None; } - Some((major, minor, patch)) + let prerelease = prerelease_raw + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + Some((major, minor, patch, prerelease)) } #[cfg(test)] @@ -271,7 +327,22 @@ mod tests { #[test] fn parse_semverish_accepts_pre_release_patch_suffix() { - assert_eq!(parse_semverish("1.2.3-beta.1"), Some((1, 2, 3))); + assert_eq!( + parse_semverish("1.2.3-beta.1"), + Some((1, 2, 3, Some("beta.1".to_string()))) + ); + } + + #[test] + fn compare_semverish_treats_prerelease_as_lower_precedence() { + assert_eq!( + compare_semverish("1.0.0", "1.0.0-beta.1"), + Some(std::cmp::Ordering::Greater) + ); + assert_eq!( + compare_semverish("1.0.0-beta.1", "1.0.0"), + Some(std::cmp::Ordering::Less) + ); } #[test] @@ -315,8 +386,8 @@ mod tests { assert!(notice_from_state("0.1.7".into(), &no_update_flag).is_none()); } - #[test] - fn save_and_load_state_round_trip() { + #[tokio::test] + async fn save_and_load_state_round_trip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("state").join("version_check.json"); let state = VersionCheckState { @@ -325,8 +396,8 @@ mod tests { update_available: true, }; - save_state(&path, &state).unwrap(); - let loaded = load_state(&path).unwrap().unwrap(); + save_state(&path, &state).await.unwrap(); + let loaded = load_state(&path).await.unwrap().unwrap(); assert_eq!(loaded.latest_version, "0.1.9"); assert_eq!(loaded.checked_at_unix, 123); From cf709fcbb57eec3ac3b8517f7d00209791d6f7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:27:25 +0100 Subject: [PATCH 3/5] feat(command): implement bounded update notice for linger command --- clients/agent-runtime/src/daemon/mod.rs | 2 +- clients/agent-runtime/src/main.rs | 25 ++++-- clients/agent-runtime/src/service/mod.rs | 103 ++++++++++++++++++++--- 3 files changed, 113 insertions(+), 17 deletions(-) diff --git a/clients/agent-runtime/src/daemon/mod.rs b/clients/agent-runtime/src/daemon/mod.rs index d67e4728f..c40b88348 100755 --- a/clients/agent-runtime/src/daemon/mod.rs +++ b/clients/agent-runtime/src/daemon/mod.rs @@ -161,7 +161,7 @@ where } Err(e) => { let err_str = e.to_string(); - crate::health::mark_component_error(name, err_str.clone()); + crate::health::mark_component_error(name, &err_str); tracing::error!("Daemon component '{name}' failed: {err_str}"); if name == "gateway" && err_str.contains("Address already in use") { tracing::warn!( diff --git a/clients/agent-runtime/src/main.rs b/clients/agent-runtime/src/main.rs index b2272dea5..1e861ba9c 100644 --- a/clients/agent-runtime/src/main.rs +++ b/clients/agent-runtime/src/main.rs @@ -36,6 +36,7 @@ use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; use dialoguer::{Input, Password}; use serde::{Deserialize, Serialize}; +use std::time::Duration; use tracing::{info, warn}; use tracing_subscriber::{fmt, EnvFilter}; @@ -581,10 +582,7 @@ async fn main() -> Result<()> { temperature, peripheral, } => { - let update_config = config.clone(); - tokio::spawn(async move { - update::maybe_print_update_notice(&update_config).await; - }); + maybe_print_update_notice_bounded(&config).await; agent::run(config, message, provider, model, temperature, peripheral) .await .map(|_| ()) @@ -602,7 +600,10 @@ async fn main() -> Result<()> { } Commands::Daemon { port, host } => { - update::maybe_print_update_notice(&config).await; + let update_config = config.clone(); + tokio::spawn(async move { + update::maybe_print_update_notice(&update_config).await; + }); let port = port.unwrap_or(config.gateway.port); let host = host.unwrap_or_else(|| config.gateway.host.clone()); if port == 0 { @@ -614,7 +615,7 @@ async fn main() -> Result<()> { } Commands::Status => { - update::maybe_print_update_notice(&config).await; + maybe_print_update_notice_bounded(&config).await; println!("🦀 Corvus Status"); println!(); println!("Version: {}", env!("CARGO_PKG_VERSION")); @@ -878,6 +879,18 @@ async fn main() -> Result<()> { } } +async fn maybe_print_update_notice_bounded(config: &Config) { + if tokio::time::timeout( + Duration::from_millis(500), + update::maybe_print_update_notice(config), + ) + .await + .is_err() + { + tracing::debug!("Update notice check timed out after 500ms"); + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct PendingOpenAiLogin { profile: String, diff --git a/clients/agent-runtime/src/service/mod.rs b/clients/agent-runtime/src/service/mod.rs index e0df4bd1c..8826afe3d 100755 --- a/clients/agent-runtime/src/service/mod.rs +++ b/clients/agent-runtime/src/service/mod.rs @@ -27,8 +27,8 @@ fn install(config: &Config, linger: crate::ServiceLingerMode) -> Result<()> { maybe_warn_unsupported_linger_mode(linger); install_macos(config) } else if cfg!(target_os = "linux") { - apply_linux_linger_mode(linger)?; - install_linux(config) + install_linux(config)?; + apply_linux_linger_mode(linger) } else if cfg!(target_os = "windows") { maybe_warn_unsupported_linger_mode(linger); install_windows(config) @@ -211,14 +211,20 @@ fn linux_linger_state() -> Result> { } let user = current_username()?; - let out = run_capture(Command::new("loginctl").args([ - "show-user", - &user, - "-p", - "Linger", - "--value", - ]))?; - let value = out.trim(); + let output = Command::new("loginctl") + .args(["show-user", &user, "-p", "Linger"]) + .output() + .context("Failed to spawn command")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Command failed: {}", stderr.trim()); + } + + let raw = String::from_utf8_lossy(&output.stdout); + let value = raw + .trim() + .split_once('=') + .map_or(raw.trim(), |(_, rhs)| rhs.trim()); if value.is_empty() { return Ok(None); } @@ -442,6 +448,19 @@ fn xml_escape(raw: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn set_env(key: &str, value: &str) { + // SAFETY: tests take ENV_LOCK to serialize process-wide env mutation. + unsafe { std::env::set_var(key, value) }; + } + + fn remove_env(key: &str) { + // SAFETY: tests take ENV_LOCK to serialize process-wide env mutation. + unsafe { std::env::remove_var(key) }; + } #[test] fn xml_escape_escapes_reserved_chars() { @@ -486,6 +505,70 @@ mod tests { assert_eq!(windows_task_name(), "Corvus Daemon"); } + #[test] + fn current_username_prefers_user_env() { + let _guard = ENV_LOCK.lock().unwrap(); + let old_user = std::env::var("USER").ok(); + let old_logname = std::env::var("LOGNAME").ok(); + + set_env("USER", "testuser"); + set_env("LOGNAME", "loguser"); + + let result = current_username().unwrap(); + + if let Some(value) = old_user { + set_env("USER", &value); + } else { + remove_env("USER"); + } + if let Some(value) = old_logname { + set_env("LOGNAME", &value); + } else { + remove_env("LOGNAME"); + } + + assert_eq!(result, "testuser"); + } + + #[test] + fn current_username_uses_logname_when_user_missing() { + let _guard = ENV_LOCK.lock().unwrap(); + let old_user = std::env::var("USER").ok(); + let old_logname = std::env::var("LOGNAME").ok(); + + remove_env("USER"); + set_env("LOGNAME", "loguser"); + + let result = current_username().unwrap(); + + if let Some(value) = old_user { + set_env("USER", &value); + } else { + remove_env("USER"); + } + if let Some(value) = old_logname { + set_env("LOGNAME", &value); + } else { + remove_env("LOGNAME"); + } + + assert_eq!(result, "loguser"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_helpers_compile_and_are_callable() { + let _ = apply_linux_linger_mode(crate::ServiceLingerMode::Keep); + let _ = linux_linger_state(); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn linux_helpers_compile_on_non_linux() { + let _apply: fn(crate::ServiceLingerMode) -> Result<()> = apply_linux_linger_mode; + let _state: fn() -> Result> = linux_linger_state; + } + #[cfg(target_os = "windows")] #[test] fn run_capture_reads_stdout_windows() { From ee8e1db6d98611569454838ffb7ddd2696f4a24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:32:06 +0100 Subject: [PATCH 4/5] feat(command): enhance gateway error logging and add linger command user handling --- clients/agent-runtime/src/daemon/mod.rs | 36 +++++++++++++++++++++++- clients/agent-runtime/src/service/mod.rs | 3 +- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/clients/agent-runtime/src/daemon/mod.rs b/clients/agent-runtime/src/daemon/mod.rs index c40b88348..ead2b84b3 100755 --- a/clients/agent-runtime/src/daemon/mod.rs +++ b/clients/agent-runtime/src/daemon/mod.rs @@ -149,6 +149,7 @@ where tokio::spawn(async move { let mut backoff = initial_backoff_secs.max(1); let max_backoff = max_backoff_secs.max(backoff); + let mut gateway_port_conflict_logged = false; loop { crate::health::mark_component_ok(name); @@ -160,10 +161,13 @@ where backoff = initial_backoff_secs.max(1); } Err(e) => { + let is_gateway_addr_in_use = + name == "gateway" && is_addr_in_use_error(&e); let err_str = e.to_string(); crate::health::mark_component_error(name, &err_str); tracing::error!("Daemon component '{name}' failed: {err_str}"); - if name == "gateway" && err_str.contains("Address already in use") { + if is_gateway_addr_in_use && !gateway_port_conflict_logged { + gateway_port_conflict_logged = true; tracing::warn!( "Gateway port is already in use. This usually means another daemon/gateway instance is already running. If this happened after an upgrade, run `corvus service restart` instead of starting a second daemon process." ); @@ -179,6 +183,13 @@ where }) } +fn is_addr_in_use_error(error: &anyhow::Error) -> bool { + error + .chain() + .filter_map(|cause| cause.downcast_ref::()) + .any(|io_error| io_error.kind() == std::io::ErrorKind::AddrInUse) +} + async fn run_heartbeat_worker(config: Config) -> Result<()> { let observer: std::sync::Arc = std::sync::Arc::from(crate::observability::create_observer(&config.observability)); @@ -290,6 +301,29 @@ mod tests { .contains("component exited unexpectedly")); } + #[tokio::test] + async fn supervisor_logs_hint_on_gateway_addr_in_use() { + let handle = spawn_component_supervisor("gateway", 1, 1, || async { + Err(anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "Address already in use", + ))) + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + handle.abort(); + let _ = handle.await; + + let snapshot = crate::health::snapshot_json(); + let component = &snapshot["components"]["gateway"]; + assert_eq!(component["status"], "error"); + assert!(component["restart_count"].as_u64().unwrap_or(0) >= 1); + assert!(component["last_error"] + .as_str() + .unwrap_or("") + .contains("Address already in use")); + } + #[test] fn detects_no_supervised_channels() { let config = Config::default(); diff --git a/clients/agent-runtime/src/service/mod.rs b/clients/agent-runtime/src/service/mod.rs index 8826afe3d..cfc95f475 100755 --- a/clients/agent-runtime/src/service/mod.rs +++ b/clients/agent-runtime/src/service/mod.rs @@ -189,15 +189,16 @@ fn current_username() -> Result { } fn apply_linux_linger_mode(linger: crate::ServiceLingerMode) -> Result<()> { - let user = current_username()?; match linger { crate::ServiceLingerMode::Keep => Ok(()), crate::ServiceLingerMode::On => { + let user = current_username()?; run_checked(Command::new("loginctl").args(["enable-linger", &user]))?; println!("✅ Enabled linger for user '{user}'"); Ok(()) } crate::ServiceLingerMode::Off => { + let user = current_username()?; run_checked(Command::new("loginctl").args(["disable-linger", &user]))?; println!("✅ Disabled linger for user '{user}'"); Ok(()) From 1fedaffb944fd1c7124d95cfd1a3ed682c195972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:17:42 +0100 Subject: [PATCH 5/5] feat(command): introduce HealthComponentGuard and EnvGuard for improved resource management --- clients/agent-runtime/src/daemon/mod.rs | 18 ++++++ clients/agent-runtime/src/health/mod.rs | 5 ++ clients/agent-runtime/src/service/mod.rs | 76 ++++++++++++++---------- 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/clients/agent-runtime/src/daemon/mod.rs b/clients/agent-runtime/src/daemon/mod.rs index ead2b84b3..50da1e25b 100755 --- a/clients/agent-runtime/src/daemon/mod.rs +++ b/clients/agent-runtime/src/daemon/mod.rs @@ -244,6 +244,23 @@ mod tests { use super::*; use tempfile::TempDir; + struct HealthComponentGuard { + name: &'static str, + } + + impl HealthComponentGuard { + fn new(name: &'static str) -> Self { + crate::health::clear_component(name); + Self { name } + } + } + + impl Drop for HealthComponentGuard { + fn drop(&mut self) { + crate::health::clear_component(self.name); + } + } + fn test_config(tmp: &TempDir) -> Config { let config = Config { workspace_dir: tmp.path().join("workspace"), @@ -303,6 +320,7 @@ mod tests { #[tokio::test] async fn supervisor_logs_hint_on_gateway_addr_in_use() { + let _health_guard = HealthComponentGuard::new("gateway"); let handle = spawn_component_supervisor("gateway", 1, 1, || async { Err(anyhow::Error::new(std::io::Error::new( std::io::ErrorKind::AddrInUse, diff --git a/clients/agent-runtime/src/health/mod.rs b/clients/agent-runtime/src/health/mod.rs index 2926c213f..0870776ae 100755 --- a/clients/agent-runtime/src/health/mod.rs +++ b/clients/agent-runtime/src/health/mod.rs @@ -82,6 +82,11 @@ pub fn bump_component_restart(component: &str) { }); } +pub fn clear_component(component: &str) { + let mut map = registry().components.lock(); + map.remove(component); +} + pub fn snapshot() -> HealthSnapshot { let components = registry().components.lock().clone(); diff --git a/clients/agent-runtime/src/service/mod.rs b/clients/agent-runtime/src/service/mod.rs index cfc95f475..cbeb3be2f 100755 --- a/clients/agent-runtime/src/service/mod.rs +++ b/clients/agent-runtime/src/service/mod.rs @@ -38,6 +38,15 @@ fn install(config: &Config, linger: crate::ServiceLingerMode) -> Result<()> { } fn restart(config: &Config) -> Result<()> { + if cfg!(target_os = "linux") { + if run_checked(Command::new("systemctl").args(["--user", "restart", "corvus.service"])) + .is_ok() + { + println!("✅ Service restarted"); + return Ok(()); + } + } + stop(config)?; start(config) } @@ -160,7 +169,7 @@ fn status(config: &Config) -> Result<()> { fn maybe_warn_unsupported_linger_mode(linger: crate::ServiceLingerMode) { if !matches!(linger, crate::ServiceLingerMode::Keep) { - println!( + eprintln!( "⚠️ --linger applies only to Linux user services; ignoring requested mode on this OS." ); } @@ -463,6 +472,35 @@ mod tests { unsafe { std::env::remove_var(key) }; } + struct EnvGuard { + key: &'static str, + original: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var(key).ok(); + set_env(key, value); + Self { key, original } + } + + fn remove(key: &'static str) -> Self { + let original = std::env::var(key).ok(); + remove_env(key); + Self { key, original } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.original { + set_env(self.key, value); + } else { + remove_env(self.key); + } + } + } + #[test] fn xml_escape_escapes_reserved_chars() { let escaped = xml_escape("<&>\"' and text"); @@ -509,50 +547,22 @@ mod tests { #[test] fn current_username_prefers_user_env() { let _guard = ENV_LOCK.lock().unwrap(); - let old_user = std::env::var("USER").ok(); - let old_logname = std::env::var("LOGNAME").ok(); - - set_env("USER", "testuser"); - set_env("LOGNAME", "loguser"); + let _user = EnvGuard::set("USER", "testuser"); + let _logname = EnvGuard::set("LOGNAME", "loguser"); let result = current_username().unwrap(); - if let Some(value) = old_user { - set_env("USER", &value); - } else { - remove_env("USER"); - } - if let Some(value) = old_logname { - set_env("LOGNAME", &value); - } else { - remove_env("LOGNAME"); - } - assert_eq!(result, "testuser"); } #[test] fn current_username_uses_logname_when_user_missing() { let _guard = ENV_LOCK.lock().unwrap(); - let old_user = std::env::var("USER").ok(); - let old_logname = std::env::var("LOGNAME").ok(); - - remove_env("USER"); - set_env("LOGNAME", "loguser"); + let _user = EnvGuard::remove("USER"); + let _logname = EnvGuard::set("LOGNAME", "loguser"); let result = current_username().unwrap(); - if let Some(value) = old_user { - set_env("USER", &value); - } else { - remove_env("USER"); - } - if let Some(value) = old_logname { - set_env("LOGNAME", &value); - } else { - remove_env("LOGNAME"); - } - assert_eq!(result, "loguser"); }