tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

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:
Mcrates/tangle/src/lib.rs | 16+++++++---------
Mcrates/tangle/src/main.rs | 9+++------
Mcrates/tangle/tests/version.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/tangle_runtime/src/lib.rs | 54------------------------------------------------------
Mcrates/tangle_runtime/src/runtime.rs | 12+-----------
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") );