commit 7317ad8e30d2369c9d4bb4d1e183ad8926fde289
parent d181c27f55708984f67fbfb7384347ab3aed79bf
Author: triesap <tyson@radroots.org>
Date: Mon, 6 Apr 2026 23:33:45 +0000
cli: bound myc status timeouts
Diffstat:
| M | src/runtime/myc.rs | | | 97 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
| M | tests/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"