commit 9e8468363e641d84102146dfa00049dae75bdd8c
parent cd6fbb8c06d26ba7fbe6ce81cfb78655bf9b366e
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 02:43:13 -0700
runtime: make run serve until shutdown
- route tangle run through the long-lived server runtime
- remove the production startup probe helper and report type
- replace the CLI probe smoke with a live health probe against the child process
- verify formatting, CLI tests, runtime tests, workspace checks, and clippy
Diffstat:
5 files changed, 109 insertions(+), 96 deletions(-)
diff --git a/crates/tangle/src/lib.rs b/crates/tangle/src/lib.rs
@@ -145,16 +145,14 @@ pub fn require_config_path(invocation: &TangleInvocation) -> Result<&str, Tangle
.ok_or(TangleCliError::MissingOptionValue("--config"))
}
-pub fn run_with_config(config_path: &str) -> Result<String, String> {
- let report = tangle_runtime::open_base_relay_from_config_path(config_path)
+pub async fn run_with_config(
+ config_path: &str,
+) -> Result<tangle_runtime::server::TangleServeReport, String> {
+ let runtime = tangle_runtime::open_tangle_runtime_from_config_path(config_path)
.map_err(|error| error.to_string())?;
- Ok(format!(
- "relay url: {}\npocket data directory: {}\ngroups enabled: {}\nreadiness: {}",
- report.relay_url(),
- report.data_directory().display(),
- report.groups_enabled(),
- report.readiness().response().status
- ))
+ tangle_runtime::server::serve_until_shutdown(runtime)
+ .await
+ .map_err(|error| error.to_string())
}
#[cfg(test)]
diff --git a/crates/tangle/src/main.rs b/crates/tangle/src/main.rs
@@ -23,10 +23,7 @@ async fn main() -> ExitCode {
ExitCode::SUCCESS
}
tangle::TangleCommand::Run => match run_server(&invocation).await {
- Ok(output) => {
- println!("{output}");
- ExitCode::SUCCESS
- }
+ Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{error}");
ExitCode::from(2)
@@ -35,9 +32,9 @@ async fn main() -> ExitCode {
}
}
-async fn run_server(invocation: &tangle::TangleInvocation) -> Result<String, String> {
+async fn run_server(invocation: &tangle::TangleInvocation) -> Result<(), String> {
let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?;
- tangle::run_with_config(config_path)
+ tangle::run_with_config(config_path).await.map(|_| ())
}
#[cfg(test)]
diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs
@@ -1,6 +1,12 @@
#![forbid(unsafe_code)]
-use std::process::Command;
+use std::{
+ fmt::Write as _,
+ io::{Read, Write},
+ net::{SocketAddr, TcpListener, TcpStream},
+ process::{Child, Command, Output, Stdio},
+ time::{Duration, Instant},
+};
use tangle_test_support::{FixtureKey, TANGLE_V2_RELAY_SECRET_HEX};
#[test]
@@ -81,17 +87,18 @@ fn tangle_run_reports_missing_config() {
}
#[test]
-fn tangle_run_smoke_opens_v2_config() {
+fn tangle_run_starts_server_and_stays_alive_until_shutdown() {
let root = std::env::temp_dir().join(format!("tangle-cli-run-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("runtime root");
let data_dir = root.join("pocket");
let config_path = root.join("runtime.json");
+ let listen_addr = reserve_loopback_addr();
std::fs::write(
&config_path,
serde_json::json!({
"server": {
- "listen_addr": "127.0.0.1:0",
+ "listen_addr": listen_addr.to_string(),
"relay_url": "wss://relay.radroots.test"
},
"pocket": {
@@ -129,21 +136,96 @@ fn tangle_run_smoke_opens_v2_config() {
)
.expect("write config");
- let output = Command::new(env!("CARGO_BIN_EXE_tangle"))
- .args(["run", "--config"])
- .arg(&config_path)
- .output()
- .expect("run tangle");
+ let mut child = TangleChild::spawn(&config_path);
+ let response = wait_for_http_ok(listen_addr, "/healthz");
- assert!(output.status.success());
- assert_eq!(
- String::from_utf8_lossy(&output.stdout),
- format!(
- "relay url: wss://relay.radroots.test\npocket data directory: {}\ngroups enabled: true\nreadiness: ready\n",
- data_dir.display()
- )
- );
+ assert!(response.contains(r#""status":"ok""#));
+ assert!(child.try_wait().expect("child status").is_none());
+ assert!(data_dir.exists());
+
+ let output = child.stop().expect("stop child");
+
+ assert!(output.stdout.is_empty());
assert!(output.stderr.is_empty());
std::fs::remove_dir_all(&root).expect("remove runtime root");
}
+
+struct TangleChild {
+ child: Option<Child>,
+}
+
+impl TangleChild {
+ fn spawn(config_path: &std::path::Path) -> Self {
+ let child = Command::new(env!("CARGO_BIN_EXE_tangle"))
+ .args(["run", "--config"])
+ .arg(config_path)
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn tangle");
+ Self { child: Some(child) }
+ }
+
+ fn try_wait(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
+ self.child.as_mut().expect("child").try_wait()
+ }
+
+ fn stop(mut self) -> std::io::Result<Output> {
+ let mut child = self.child.take().expect("child");
+ let kill_error = child.kill().err();
+ let output = child.wait_with_output();
+ if let Some(error) = kill_error
+ && error.kind() != std::io::ErrorKind::InvalidInput
+ {
+ return Err(error);
+ }
+ output
+ }
+}
+
+impl Drop for TangleChild {
+ fn drop(&mut self) {
+ if let Some(child) = &mut self.child {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ }
+}
+
+fn reserve_loopback_addr() -> SocketAddr {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("reserve loopback address");
+ listener.local_addr().expect("loopback address")
+}
+
+fn wait_for_http_ok(address: SocketAddr, path: &str) -> String {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ let mut last_error = String::new();
+ while Instant::now() < deadline {
+ match http_get(address, path) {
+ Ok(response) if response.starts_with("HTTP/1.1 200 OK") => return response,
+ Ok(response) => {
+ last_error = response.lines().next().unwrap_or("").to_owned();
+ }
+ Err(error) => {
+ last_error.clear();
+ write!(&mut last_error, "{error}").expect("format error");
+ }
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ panic!("server did not answer {path}: {last_error}");
+}
+
+fn http_get(address: SocketAddr, path: &str) -> std::io::Result<String> {
+ let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(200))?;
+ stream.set_read_timeout(Some(Duration::from_millis(500)))?;
+ stream.set_write_timeout(Some(Duration::from_millis(500)))?;
+ write!(
+ stream,
+ "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"
+ )?;
+ let mut response = String::new();
+ stream.read_to_string(&mut response)?;
+ Ok(response)
+}
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -17,53 +17,12 @@ use std::{fmt, fs, path::Path, path::PathBuf};
use config::{BaseRelayRuntimeConfig, parse_base_relay_runtime_config_json};
use errors::BaseRelayError;
-use ops::BaseRelayReadinessState;
use runtime::TangleRuntime;
pub const TANGLE_SUPPORTED_NIPS: [u16; 6] = [1, 11, 29, 42, 45, 70];
pub const TANGLE_RELAY_SOFTWARE: &str = "https://github.com/radrootslabs/tangle";
pub const TANGLE_RELAY_VERSION: &str = env!("CARGO_PKG_VERSION");
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct TangleRuntimeStartupReport {
- relay_url: String,
- data_directory: PathBuf,
- groups_enabled: bool,
- readiness: BaseRelayReadinessState,
-}
-
-impl TangleRuntimeStartupReport {
- pub(crate) fn new(
- relay_url: impl Into<String>,
- data_directory: PathBuf,
- groups_enabled: bool,
- readiness: BaseRelayReadinessState,
- ) -> Self {
- Self {
- relay_url: relay_url.into(),
- data_directory,
- groups_enabled,
- readiness,
- }
- }
-
- pub fn relay_url(&self) -> &str {
- &self.relay_url
- }
-
- pub fn data_directory(&self) -> &Path {
- &self.data_directory
- }
-
- pub fn groups_enabled(&self) -> bool {
- self.groups_enabled
- }
-
- pub fn readiness(&self) -> &BaseRelayReadinessState {
- &self.readiness
- }
-}
-
#[derive(Debug)]
pub enum TangleRuntimeLoadError {
ReadConfig {
@@ -72,7 +31,6 @@ pub enum TangleRuntimeLoadError {
},
ParseConfig(BaseRelayError),
OpenRelay(BaseRelayError),
- ShutdownRelay(BaseRelayError),
}
impl fmt::Display for TangleRuntimeLoadError {
@@ -87,7 +45,6 @@ impl fmt::Display for TangleRuntimeLoadError {
}
Self::ParseConfig(error) => write!(formatter, "{error}"),
Self::OpenRelay(error) => write!(formatter, "{error}"),
- Self::ShutdownRelay(error) => write!(formatter, "{error}"),
}
}
}
@@ -105,17 +62,6 @@ pub fn load_base_relay_runtime_config(
parse_base_relay_runtime_config_json(&raw).map_err(TangleRuntimeLoadError::ParseConfig)
}
-pub fn open_base_relay_from_config_path(
- path: impl AsRef<Path>,
-) -> Result<TangleRuntimeStartupReport, TangleRuntimeLoadError> {
- let mut runtime = open_tangle_runtime_from_config_path(path)?;
- let report = runtime.startup_report();
- runtime
- .shutdown()
- .map_err(TangleRuntimeLoadError::ShutdownRelay)?;
- Ok(report)
-}
-
pub fn open_tangle_runtime_from_config_path(
path: impl AsRef<Path>,
) -> Result<TangleRuntime, TangleRuntimeLoadError> {
diff --git a/crates/tangle_runtime/src/runtime.rs b/crates/tangle_runtime/src/runtime.rs
@@ -1,7 +1,6 @@
#![forbid(unsafe_code)]
use crate::{
- TangleRuntimeStartupReport,
config::BaseRelayRuntimeConfig,
errors::BaseRelayError,
event_bus::TangleEventBus,
@@ -82,15 +81,6 @@ impl TangleRuntime {
&self.shutdown
}
- pub fn startup_report(&self) -> TangleRuntimeStartupReport {
- TangleRuntimeStartupReport::new(
- self.config.relay_url(),
- self.config.pocket_config().data_directory().to_path_buf(),
- self.config.groups().enabled(),
- self.readiness.clone(),
- )
- }
-
pub fn shutdown(&mut self) -> Result<BaseRelayShutdownReport, BaseRelayError> {
self.shutdown.request_shutdown();
self.relay.shutdown()
@@ -283,7 +273,7 @@ mod tests {
0
);
assert_eq!(
- runtime.startup_report().data_directory(),
+ runtime.config().pocket_config().data_directory(),
Path::new(&root).join("pocket")
);