Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions crates/cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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;
}
Expand Down
5 changes: 4 additions & 1 deletion crates/cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down
27 changes: 27 additions & 0 deletions crates/control-plane-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ pub fn parse_npipe_endpoint(endpoint: &str) -> Result<Option<String>> {
bail!("invalid named-pipe endpoint {endpoint}; expected npipe://./pipe/<name>")
}

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::<std::io::Error>()
.is_some_and(|io_error| io_error.raw_os_error() == Some(5))
})
}

fn pipe_path_from_name(name: &str) -> Result<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 4 additions & 13 deletions crates/tray/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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::<std::io::Error>()
.is_some_and(|io_error| io_error.raw_os_error() == Some(5))
})
}

async fn pair_nearby_request_code(
endpoint: &str,
host: String,
Expand Down
Loading