cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 7317ad8e30d2369c9d4bb4d1e183ad8926fde289
parent d181c27f55708984f67fbfb7384347ab3aed79bf
Author: triesap <tyson@radroots.org>
Date:   Mon,  6 Apr 2026 23:33:45 +0000

cli: bound myc status timeouts

Diffstat:
Msrc/runtime/myc.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mtests/myc_status.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 163 insertions(+), 7 deletions(-)

diff --git a/src/runtime/myc.rs b/src/runtime/myc.rs @@ -1,4 +1,7 @@ -use std::process::Command; +use std::io::Read; +use std::process::{Child, Command, ExitStatus, Output, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; use radroots_nostr_signer::prelude::RadrootsNostrLocalSignerCapability; use serde::Deserialize; @@ -9,6 +12,9 @@ use crate::domain::runtime::{ }; use crate::runtime::config::MycConfig; +const MYC_STATUS_TIMEOUT: Duration = Duration::from_secs(1); +const MYC_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(10); + pub fn resolve_status(config: &MycConfig) -> MycStatusView { let executable = config.executable.display().to_string(); if config.executable.as_os_str().is_empty() { @@ -19,12 +25,9 @@ pub fn resolve_status(config: &MycConfig) -> MycStatusView { ); } - let output = match Command::new(&config.executable) - .args(["status", "--view", "full"]) - .output() - { + let output = match run_status_command(config) { Ok(output) => output, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Err(MycCommandError::NotFound) => { return unavailable_status( executable, "unavailable", @@ -34,7 +37,7 @@ pub fn resolve_status(config: &MycConfig) -> MycStatusView { ), ); } - Err(error) => { + Err(MycCommandError::Start(error)) => { return unavailable_status( executable, "unavailable", @@ -44,6 +47,26 @@ pub fn resolve_status(config: &MycConfig) -> MycStatusView { ), ); } + Err(MycCommandError::Timeout) => { + return unavailable_status( + executable, + "unavailable", + format!( + "myc status command timed out after {}ms", + MYC_STATUS_TIMEOUT.as_millis() + ), + ); + } + Err(MycCommandError::Wait(error)) | Err(MycCommandError::Read(error)) => { + return unavailable_status( + executable, + "unavailable", + format!( + "failed to capture myc status command output at {}: {error}", + config.executable.display() + ), + ); + } }; if !output.status.success() { @@ -112,6 +135,58 @@ pub fn resolve_status(config: &MycConfig) -> MycStatusView { } } +fn run_status_command(config: &MycConfig) -> Result<Output, MycCommandError> { + let mut child = Command::new(&config.executable) + .args(["status", "--view", "full"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| match error.kind() { + std::io::ErrorKind::NotFound => MycCommandError::NotFound, + _ => MycCommandError::Start(error), + })?; + + let started_at = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return collect_output(child, status), + Ok(None) => { + if started_at.elapsed() >= MYC_STATUS_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + return Err(MycCommandError::Timeout); + } + thread::sleep(MYC_STATUS_POLL_INTERVAL); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(MycCommandError::Wait(error)); + } + } + } +} + +fn collect_output(mut child: Child, status: ExitStatus) -> Result<Output, MycCommandError> { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + if let Some(mut pipe) = child.stdout.take() { + pipe.read_to_end(&mut stdout) + .map_err(MycCommandError::Read)?; + } + if let Some(mut pipe) = child.stderr.take() { + pipe.read_to_end(&mut stderr) + .map_err(MycCommandError::Read)?; + } + + Ok(Output { + status, + stdout, + stderr, + }) +} + fn local_signer_status_view( capability: RadrootsNostrLocalSignerCapability, ) -> LocalSignerStatusView { @@ -154,6 +229,14 @@ fn unavailable_status(executable: String, state: &str, reason: String) -> MycSta } } +enum MycCommandError { + NotFound, + Start(std::io::Error), + Wait(std::io::Error), + Read(std::io::Error), + Timeout, +} + #[derive(Debug, Deserialize)] struct MycStatusPayload { status: String, diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -1,6 +1,7 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use std::process::Command; +use std::sync::{Mutex, MutexGuard, OnceLock}; use assert_cmd::prelude::*; use radroots_identity::RadrootsIdentity; @@ -9,6 +10,7 @@ use tempfile::tempdir; #[test] fn myc_status_reports_ready_for_valid_full_status_payload() { + let _guard = myc_test_guard(); let dir = tempdir().expect("tempdir"); let executable = write_fake_myc( dir.path(), @@ -43,6 +45,7 @@ fn myc_status_reports_ready_for_valid_full_status_payload() { #[test] fn myc_status_reports_unavailable_for_invalid_status_payload() { + let _guard = myc_test_guard(); let dir = tempdir().expect("tempdir"); let executable = write_fake_myc(dir.path(), "#!/bin/sh\nprintf '%s\\n' 'this is not json'\n"); @@ -72,6 +75,7 @@ fn myc_status_reports_unavailable_for_invalid_status_payload() { #[test] fn signer_status_reports_myc_backend_details_when_configured() { + let _guard = myc_test_guard(); let dir = tempdir().expect("tempdir"); let executable = write_fake_myc( dir.path(), @@ -108,6 +112,7 @@ fn signer_status_reports_myc_backend_details_when_configured() { #[test] fn myc_status_reports_unavailable_when_executable_is_missing() { + let _guard = myc_test_guard(); let dir = tempdir().expect("tempdir"); let missing = dir.path().join("missing-myc"); @@ -134,6 +139,67 @@ fn myc_status_reports_unavailable_when_executable_is_missing() { ); } +#[test] +fn myc_status_reports_unavailable_for_non_zero_exit() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let executable = write_fake_myc( + dir.path(), + "#!/bin/sh\nprintf '%s\\n' 'transport unavailable' >&2\nexit 42\n", + ); + + let output = Command::cargo_bin("radroots") + .expect("binary") + .args([ + "--json", + "--myc-executable", + executable.to_str().expect("executable path"), + "myc", + "status", + ]) + .output() + .expect("run myc status"); + + assert_eq!(output.status.code(), Some(4)); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["state"], "unavailable"); + let reason = json["reason"].as_str().expect("reason string"); + assert!(reason.contains("status code 42") || reason.contains("transport unavailable")); +} + +#[test] +fn myc_status_reports_unavailable_for_timeout() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let executable = write_fake_myc( + dir.path(), + "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\nexec sleep 5\n", + ); + + let output = Command::cargo_bin("radroots") + .expect("binary") + .args([ + "--json", + "--myc-executable", + executable.to_str().expect("executable path"), + "myc", + "status", + ]) + .output() + .expect("run myc status"); + + assert_eq!(output.status.code(), Some(4)); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["state"], "unavailable"); + assert!( + json["reason"] + .as_str() + .is_some_and(|value| value.contains("timed out")) + ); +} + fn write_fake_myc(dir: &std::path::Path, script: &str) -> std::path::PathBuf { let path = dir.join("fake-myc"); fs::write(&path, script).expect("write fake myc"); @@ -143,6 +209,13 @@ fn write_fake_myc(dir: &std::path::Path, script: &str) -> std::path::PathBuf { path } +fn myc_test_guard() -> MutexGuard<'static, ()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .expect("lock myc integration tests") +} + fn successful_status_script(payload_json: String) -> String { format!( "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n"