diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index 81d048b..174fb36 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -2,30 +2,59 @@ use super::*; use app_services::desktop::{ build_orientation_matrix, host_and_pairing_port_from_endpoint, is_local_layout_token as is_local_layout_token_shared, parse_layout_matrix, - resolve_boundlessd_candidates, spawn_boundlessd_process, + resolve_boundlessd_candidates, spawn_boundlessd_process, terminate_boundlessd_processes, }; pub(super) async fn ensure_daemon_available(endpoint: &str, start_daemon: bool) -> Result<()> { - if channel(endpoint).await.is_ok() { - return Ok(()); - } + let initial_error = match channel(endpoint).await { + Ok(_) => return Ok(()), + Err(error) => error, + }; if !start_daemon { bail!("daemon is not reachable at {endpoint}; run boundlessd or pass --start-daemon"); } + if is_named_pipe_endpoint(endpoint) && has_access_denied_io_error(&initial_error) { + let launched = recover_stale_named_pipe_owner(endpoint).await?; + println!("daemon_start=spawned path={launched}"); + return Ok(()); + } + let launched = spawn_daemon_process()?; println!("daemon_start=spawned path={launched}"); + wait_for_daemon_ready(endpoint, "start attempt").await +} + +async fn recover_stale_named_pipe_owner(endpoint: &str) -> Result { + let terminated = terminate_boundlessd_processes()?; + tokio::time::sleep(Duration::from_millis(400)).await; + + if channel(endpoint).await.is_ok() { + return Ok("existing daemon became reachable after stale-process cleanup".to_string()); + } + + let launched = spawn_daemon_process()?; + let context = if terminated { + "stale-daemon recovery" + } else { + "named-pipe recovery" + }; + wait_for_daemon_ready(endpoint, context).await?; + Ok(format!( + "{launched} (after clearing stale boundlessd.exe named-pipe owner)" + )) +} + +async fn wait_for_daemon_ready(endpoint: &str, context: &str) -> Result<()> { let deadline = Instant::now() + Duration::from_secs(8); loop { match channel(endpoint).await { Ok(_) => return Ok(()), Err(error) => { if Instant::now() >= deadline { - bail!( - "daemon did not become reachable at {endpoint} after start attempt: {error}" - ); + bail!("daemon did not become reachable at {endpoint} after {context}: {error}"); } tokio::time::sleep(Duration::from_millis(200)).await; } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 987cf57..9354ae5 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,6 +1,9 @@ use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand, ValueEnum}; -use control_plane_client::{channel, connect_control_plane, default_endpoint}; +use control_plane_client::{ + channel, connect_control_plane, default_endpoint, has_access_denied_io_error, + is_named_pipe_endpoint, +}; use core_clipboard::validate_bmp_payload as validate_bmp_bytes; use serde::{Deserialize, Serialize}; use std::{ diff --git a/crates/control-plane-client/src/lib.rs b/crates/control-plane-client/src/lib.rs index bc1ceeb..e37936a 100644 --- a/crates/control-plane-client/src/lib.rs +++ b/crates/control-plane-client/src/lib.rs @@ -54,6 +54,18 @@ pub fn parse_npipe_endpoint(endpoint: &str) -> Result> { bail!("invalid named-pipe endpoint {endpoint}; expected npipe://./pipe/") } +pub fn is_named_pipe_endpoint(endpoint: &str) -> bool { + endpoint.trim().starts_with("npipe://") +} + +pub fn has_access_denied_io_error(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|io_error| io_error.raw_os_error() == Some(5)) + }) +} + fn pipe_path_from_name(name: &str) -> Result { let trimmed = name.trim(); if trimmed.is_empty() { @@ -164,6 +176,21 @@ mod tests { assert!(parsed.is_none()); } + #[test] + fn is_named_pipe_endpoint_detects_scheme_only() { + assert!(is_named_pipe_endpoint(" npipe://./pipe/boundlessd-api")); + assert!(!is_named_pipe_endpoint("http://127.0.0.1:50051")); + } + + #[test] + fn has_access_denied_io_error_matches_raw_os_code() { + let error = anyhow::Error::new(std::io::Error::from_raw_os_error(5)); + assert!(has_access_denied_io_error(&error)); + + let error = anyhow::Error::new(std::io::Error::from_raw_os_error(2)); + assert!(!has_access_denied_io_error(&error)); + } + #[test] fn default_endpoint_uses_named_pipe_on_windows_only() { if cfg!(windows) { diff --git a/crates/tray/src/main.rs b/crates/tray/src/main.rs index 53ec2ae..066d037 100644 --- a/crates/tray/src/main.rs +++ b/crates/tray/src/main.rs @@ -20,7 +20,10 @@ mod windows_app { terminate_boundlessd_processes, validate_layout_before_apply, }; use clap::Parser; - use control_plane_client::{channel, connect_control_plane, default_endpoint}; + use control_plane_client::{ + channel, connect_control_plane, default_endpoint, has_access_denied_io_error, + is_named_pipe_endpoint, + }; use eframe::icon_data; use image::ImageFormat; use ipc_api::boundless::v1::{ @@ -472,18 +475,6 @@ mod windows_app { spawn_boundlessd_process(candidates) } - fn is_named_pipe_endpoint(endpoint: &str) -> bool { - endpoint.trim().starts_with("npipe://") - } - - fn has_access_denied_io_error(error: &anyhow::Error) -> bool { - error.chain().any(|cause| { - cause - .downcast_ref::() - .is_some_and(|io_error| io_error.raw_os_error() == Some(5)) - }) - } - async fn pair_nearby_request_code( endpoint: &str, host: String,