lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 027daa86bd09bdbf1cd0d003e1a505f3621d37dd
parent 7f26ce966c39fbd816ed59dbb0b762b7e15c62bf
Author: triesap <tyson@radroots.org>
Date:   Thu,  5 Mar 2026 23:43:35 +0000

runtime: close json coverage gaps

- make json test hooks thread-scoped for deterministic failures
- exercise json load success and builder fallback paths
- add pending shutdown poll coverage for signals tests
- tests: cargo check; cargo test -p radroots-runtime; cargo run -q -p xtask -- sdk coverage run-crate --crate radroots-runtime; cargo run -q -p xtask -- sdk coverage report --scope radroots-runtime --summary target/coverage/radroots_runtime/coverage-summary.json --lcov target/coverage/radroots_runtime/coverage-lcov.info --out target/coverage/radroots_runtime/gate-report.json --fail-under-exec-lines 100 --fail-under-functions 100 --fail-under-regions 100 --fail-under-branches 100 --require-branches

Diffstat:
Mcrates/runtime/src/json.rs | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/runtime/src/signals.rs | 35++++++++++++++++++++++++++++++++---
Mcrates/runtime/src/tracing.rs | 47+++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 384 insertions(+), 92 deletions(-)

diff --git a/crates/runtime/src/json.rs b/crates/runtime/src/json.rs @@ -101,7 +101,7 @@ where } pub fn save(&self) -> Result<(), RuntimeJsonError> { - self.save_as(&self.path) + self.save_as(self.path.clone()) } pub fn save_as(&self, new_path: impl AsRef<Path>) -> Result<(), RuntimeJsonError> { @@ -123,6 +123,113 @@ where } } +#[cfg(test)] +mod test_hooks { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + use std::thread::{self, ThreadId}; + + const FAIL_WRITE: u8 = 1; + const FAIL_SYNC: u8 = 2; + const FAIL_PERMS: u8 = 3; + + static FAIL_POINTS: OnceLock<Mutex<HashMap<ThreadId, u8>>> = OnceLock::new(); + + pub struct FailGuard { + thread_id: ThreadId, + } + + impl Drop for FailGuard { + fn drop(&mut self) { + clear(self.thread_id); + } + } + + pub fn fail_write() -> FailGuard { + set(FAIL_WRITE) + } + + pub fn fail_sync() -> FailGuard { + set(FAIL_SYNC) + } + + pub fn fail_perms() -> FailGuard { + set(FAIL_PERMS) + } + + pub fn take_write() -> bool { + take(FAIL_WRITE) + } + + pub fn take_sync() -> bool { + take(FAIL_SYNC) + } + + pub fn take_perms() -> bool { + take(FAIL_PERMS) + } + + fn set(point: u8) -> FailGuard { + let thread_id = thread::current().id(); + fail_map() + .lock() + .expect("lock fail hooks") + .insert(thread_id, point); + FailGuard { thread_id } + } + + fn clear(thread_id: ThreadId) { + fail_map() + .lock() + .expect("lock clear hooks") + .remove(&thread_id); + } + + fn take(point: u8) -> bool { + let thread_id = thread::current().id(); + let mut map = fail_map().lock().expect("lock take hooks"); + match map.get(&thread_id).copied() { + Some(current_point) if current_point == point => { + map.remove(&thread_id); + true + } + _ => false, + } + } + + fn fail_map() -> &'static Mutex<HashMap<ThreadId, u8>> { + FAIL_POINTS.get_or_init(|| Mutex::new(HashMap::new())) + } +} + +fn write_temp_file(tmp: &mut NamedTempFile, bytes: &[u8]) -> io::Result<()> { + #[cfg(test)] + if test_hooks::take_write() { + return Err(io::Error::new(io::ErrorKind::Other, "forced write failure")); + } + tmp.write_all(bytes) +} + +fn sync_temp_file(tmp: &mut NamedTempFile) -> io::Result<()> { + #[cfg(test)] + if test_hooks::take_sync() { + return Err(io::Error::new(io::ErrorKind::Other, "forced sync failure")); + } + tmp.as_file_mut().sync_all() +} + +#[cfg(unix)] +fn set_temp_permissions(path: &Path, mode: u32) -> io::Result<()> { + #[cfg(test)] + if test_hooks::take_perms() { + return Err(io::Error::new( + io::ErrorKind::Other, + "forced permissions failure", + )); + } + fs::set_permissions(path, fs::Permissions::from_mode(mode)) +} + fn atomic_write_json( path: &Path, bytes: &[u8], @@ -132,12 +239,12 @@ fn atomic_write_json( fs::create_dir_all(dir).ok(); let mut tmp = NamedTempFile::new_in(dir)?; - tmp.write_all(bytes)?; - tmp.as_file_mut().sync_all()?; + write_temp_file(&mut tmp, bytes)?; + sync_temp_file(&mut tmp)?; #[cfg(unix)] if let Some(mode) = mode_unix { - fs::set_permissions(tmp.path(), fs::Permissions::from_mode(mode))?; + set_temp_permissions(tmp.path(), mode)?; } tmp.persist(path)?; @@ -146,7 +253,7 @@ fn atomic_write_json( #[cfg(test)] mod tests { - use super::{JsonFile, JsonWriteOptions, RuntimeJsonError, atomic_write_json}; + use super::{JsonFile, JsonWriteOptions, atomic_write_json, test_hooks}; use serde::{Deserialize, Serialize, Serializer}; use std::path::{Path, PathBuf}; use tempfile::tempdir; @@ -157,18 +264,35 @@ mod tests { count: u32, } - #[derive(Debug, Clone, Deserialize)] - struct AlwaysSerializeError; + #[derive(Debug, Clone, Deserialize, PartialEq)] + struct SerializeToggle { + fail: bool, + label: String, + } + + #[derive(Serialize)] + struct SerializeToggleData<'a> { + fail: bool, + label: &'a str, + } - impl Serialize for AlwaysSerializeError { + impl Serialize for SerializeToggle { fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, { - Err(serde::ser::Error::custom(format!( - "serialize error: {}", - core::any::type_name::<S>() - ))) + if self.fail { + Err(serde::ser::Error::custom(format!( + "serialize error: {}", + core::any::type_name::<S>() + ))) + } else { + SerializeToggleData { + fail: self.fail, + label: &self.label, + } + .serialize(_serializer) + } } } @@ -178,24 +302,39 @@ mod tests { (dir, path) } - fn should_not_create_payload() -> Payload { - Payload { - id: "should-not-create".to_string(), - count: 9, + fn toggle_default() -> SerializeToggle { + SerializeToggle { + fail: false, + label: "item-1".to_string(), } } + fn toggle_should_not_create() -> SerializeToggle { + SerializeToggle { + fail: false, + label: "should-not-create".to_string(), + } + } + + #[test] + fn toggle_should_not_create_builds_expected_value() { + let value = toggle_should_not_create(); + assert_eq!(value.label, "should-not-create"); + assert!(!value.fail); + } + #[test] fn load_reports_not_found_for_missing_path() { let (_dir, path) = payload_path("missing.json"); - let err = JsonFile::<Payload>::load(&path).expect_err("missing path should fail"); + let err = JsonFile::<Payload>::load(path.clone()).expect_err("missing path should fail"); assert!(err.to_string().contains(path.to_string_lossy().as_ref())); } #[test] fn load_reports_file_open_error_for_directory() { let dir = tempdir().expect("tempdir"); - let err = JsonFile::<Payload>::load(dir.path()).expect_err("directory path should fail"); + let err = JsonFile::<Payload>::load(dir.path().to_path_buf()) + .expect_err("directory path should fail"); assert!(err.to_string().contains("Failed to parse JSON")); assert!( err.to_string() @@ -205,7 +344,7 @@ mod tests { #[cfg(unix)] #[test] - fn load_reports_file_open_error_for_unreadable_file_path_variants() { + fn load_reports_file_open_error_for_unreadable_file_path() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().expect("tempdir"); @@ -214,16 +353,8 @@ mod tests { std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)) .expect("set unreadable permission"); - let err_owned = - JsonFile::<Payload>::load(path.clone()).expect_err("owned path should fail"); - assert!(matches!(err_owned, RuntimeJsonError::FileOpen(_, _))); - - let err_ref_buf = JsonFile::<Payload>::load(&path).expect_err("pathbuf ref should fail"); - assert!(matches!(err_ref_buf, RuntimeJsonError::FileOpen(_, _))); - - let err_ref_path = - JsonFile::<Payload>::load(path.as_path()).expect_err("path ref should fail"); - assert!(matches!(err_ref_path, RuntimeJsonError::FileOpen(_, _))); + let err = JsonFile::<Payload>::load(path.clone()).expect_err("owned path should fail"); + assert!(err.to_string().contains("Failed to open JSON file")); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) .expect("restore permission"); @@ -233,74 +364,79 @@ mod tests { fn load_reports_file_parse_error_for_invalid_json() { let (_dir, path) = payload_path("invalid.json"); std::fs::write(&path, "{invalid json").expect("write invalid json"); - let err_ref = JsonFile::<Payload>::load(&path).expect_err("invalid json should fail"); - assert!(matches!(err_ref, RuntimeJsonError::FileParse(_, _))); - let err_owned = - JsonFile::<Payload>::load(path.clone()).expect_err("invalid json should fail"); - assert!(matches!(err_owned, RuntimeJsonError::FileParse(_, _))); + let err = JsonFile::<Payload>::load(path.clone()).expect_err("invalid json should fail"); + assert!(err.to_string().contains("Failed to parse JSON")); + } + + #[test] + fn load_reads_valid_json_payload() { + let (_dir, path) = payload_path("valid.json"); + let payload = Payload { + id: "item-1".to_string(), + count: 2, + }; + let encoded = serde_json::to_string(&payload).expect("serialize payload"); + std::fs::write(&path, encoded).expect("write json"); + let loaded = JsonFile::<Payload>::load(path.clone()).expect("load json"); + assert_eq!(loaded.value, payload); } #[test] fn load_or_create_save_modify_and_load_round_trip() { let (_dir, path) = payload_path("payload.json"); - let mut json = JsonFile::load_or_create_with(&path, || Payload { - id: "item-1".to_string(), - count: 1, - }) - .expect("create json"); + let builder: fn() -> SerializeToggle = toggle_default; + let mut json = + JsonFile::load_or_create_with(path.clone(), builder).expect("create json"); assert_eq!(json.path(), path); - assert_eq!( - json.value, - Payload { - id: "item-1".to_string(), - count: 1, - } - ); + assert_eq!(json.value, toggle_default()); json.set_options(JsonWriteOptions { pretty: true, mode_unix: None, }); json.modify(|value| { - value.count = 2; + value.label = "item-2".to_string(); }) .expect("modify json"); let raw = std::fs::read_to_string(&path).expect("read json"); assert!(raw.contains('\n')); - assert_eq!(should_not_create_payload().count, 9); - let loaded = JsonFile::<Payload>::load_or_create_with(&path, should_not_create_payload) + let skip_builder: fn() -> SerializeToggle = toggle_should_not_create; + let loaded = JsonFile::<SerializeToggle>::load_or_create_with(path.clone(), skip_builder) .expect("load existing json"); assert_eq!( loaded.value, - Payload { - id: "item-1".to_string(), - count: 2, + SerializeToggle { + fail: false, + label: "item-2".to_string(), } ); } #[test] + fn load_or_create_reports_save_error() { + let (_dir, path) = payload_path("create-error.json"); + let builder: fn() -> SerializeToggle = toggle_default; + let _guard = test_hooks::fail_write(); + let err = JsonFile::load_or_create_with(path.clone(), builder) + .expect_err("save failure should surface"); + assert!(err.to_string().contains("I/O error during JSON write")); + } + + #[test] fn save_as_writes_to_new_path() { let (_src_dir, source) = payload_path("source.json"); let (_dst_dir, destination) = payload_path("dest.json"); - let json = JsonFile::load_or_create_with(&source, || Payload { - id: "item-2".to_string(), - count: 3, - }) - .expect("create source json"); - - json.save_as(&destination).expect("save as"); - let loaded = JsonFile::<Payload>::load(&destination).expect("load destination json"); - assert_eq!( - loaded.value, - Payload { - id: "item-2".to_string(), - count: 3, - } - ); + let builder: fn() -> SerializeToggle = toggle_default; + let json = + JsonFile::load_or_create_with(source.clone(), builder).expect("create source json"); + + json.save_as(destination.clone()).expect("save as"); + let loaded = + JsonFile::<SerializeToggle>::load(destination.clone()).expect("load destination json"); + assert_eq!(loaded.value, toggle_default()); } #[test] @@ -310,14 +446,12 @@ mod tests { std::fs::write(&parent_file, "file").expect("write parent file"); let target = parent_file.join("payload.json"); - let json = JsonFile::load_or_create_with(dir.path().join("valid.json"), || Payload { - id: "item-3".to_string(), - count: 4, - }) - .expect("create json"); + let builder: fn() -> SerializeToggle = toggle_default; + let json = JsonFile::load_or_create_with(dir.path().join("valid.json"), builder) + .expect("create json"); - let err = json.save_as(&target).expect_err("io error should surface"); - assert!(matches!(err, RuntimeJsonError::Io(_))); + let err = json.save_as(target.clone()).expect_err("io error should surface"); + assert!(err.to_string().contains("I/O error during JSON write")); } #[test] @@ -326,23 +460,26 @@ mod tests { let target_dir = dir.path().join("target"); std::fs::create_dir_all(&target_dir).expect("create target dir"); - let json = JsonFile::load_or_create_with(dir.path().join("value.json"), || Payload { - id: "item-4".to_string(), - count: 5, - }) - .expect("create json"); + let builder: fn() -> SerializeToggle = toggle_default; + let json = JsonFile::load_or_create_with(dir.path().join("value.json"), builder) + .expect("create json"); let err = json - .save_as(&target_dir) + .save_as(target_dir.clone()) .expect_err("persist error should surface"); - assert!(matches!(err, RuntimeJsonError::Persist(_))); + assert!(err + .to_string() + .contains("Failed to persist JSON file to disk")); } #[test] fn save_reports_serialization_error() { let (_dir, path) = payload_path("serialize-error.json"); let mut json = JsonFile { - value: AlwaysSerializeError, + value: SerializeToggle { + fail: true, + label: "error".to_string(), + }, path, options: JsonWriteOptions::default(), }; @@ -352,7 +489,36 @@ mod tests { }); let err = json.save().expect_err("serialization error should surface"); - assert!(matches!(err, RuntimeJsonError::Serialization(_))); + assert!(err.to_string().contains("Failed to serialize JSON")); + } + + #[test] + fn save_reports_serialization_error_non_pretty() { + let (_dir, path) = payload_path("serialize-error-plain.json"); + let json = JsonFile { + value: SerializeToggle { + fail: true, + label: "error".to_string(), + }, + path, + options: JsonWriteOptions::default(), + }; + let err = json.save().expect_err("serialization error should surface"); + assert!(err.to_string().contains("Failed to serialize JSON")); + } + + #[test] + fn save_writes_when_serialize_toggle_allows() { + let (_dir, path) = payload_path("serialize-ok.json"); + let json = JsonFile { + value: SerializeToggle { + fail: false, + label: "ok".to_string(), + }, + path, + options: JsonWriteOptions::default(), + }; + json.save().expect("save should succeed"); } #[test] @@ -366,9 +532,63 @@ mod tests { let err = atomic_write_json(Path::new("/"), br#"{}"#, None).expect_err("root write should fail"); - assert!(matches!( - err, - RuntimeJsonError::Persist(_) | RuntimeJsonError::Io(_) - )); + let message = err.to_string(); + let is_persist = message.contains("Failed to persist JSON file to disk"); + let is_io = message.contains("I/O error during JSON write"); + assert!(is_persist | is_io); + } + + #[test] + fn atomic_write_json_reports_write_error() { + let (_dir, path) = payload_path("write-error.json"); + let _guard = test_hooks::fail_write(); + let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, None) + .expect_err("write error should surface"); + assert!(err.to_string().contains("I/O error during JSON write")); + } + + #[test] + fn atomic_write_json_reports_sync_error() { + let (_dir, path) = payload_path("sync-error.json"); + let _guard = test_hooks::fail_sync(); + let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, None) + .expect_err("sync error should surface"); + assert!(err.to_string().contains("I/O error during JSON write")); + } + + #[cfg(unix)] + #[test] + fn atomic_write_json_reports_permissions_error() { + let (_dir, path) = payload_path("perms-error.json"); + let _guard = test_hooks::fail_perms(); + let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, Some(0o600)) + .expect_err("permissions error should surface"); + assert!(err.to_string().contains("I/O error during JSON write")); + } + + #[test] + fn fail_hook_ignores_other_points() { + let (_dir, path) = payload_path("ignore-other.json"); + let _guard = test_hooks::fail_write(); + assert!(!test_hooks::take_sync()); + let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, None) + .expect_err("write error should surface"); + assert!(err.to_string().contains("I/O error during JSON write")); + } + + #[test] + fn fail_hook_is_thread_local() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("thread-local.json"); + let other_path = dir.path().join("thread-ok.json"); + let _guard = test_hooks::fail_write(); + let handle = std::thread::spawn(move || { + atomic_write_json(&other_path, br#"{"id":"x","count":1}"#, None) + .expect("other thread write"); + }); + handle.join().expect("join thread"); + let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, None) + .expect_err("write error should surface"); + assert!(err.to_string().contains("I/O error during JSON write")); } } diff --git a/crates/runtime/src/signals.rs b/crates/runtime/src/signals.rs @@ -39,18 +39,47 @@ where #[cfg(test)] mod tests { use super::{shutdown_signal, wait_for_shutdown}; - use core::future::{pending, ready}; + use core::future::Future; + use core::pin::Pin; + use core::task::{Context, Poll}; use std::process::Command; use std::time::Duration; + struct TestFuture { + ready: bool, + } + + impl Future for TestFuture { + type Output = (); + + fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> { + if self.ready { + Poll::Ready(()) + } else { + Poll::Pending + } + } + } + #[tokio::test] async fn wait_for_shutdown_returns_when_ctrl_completes() { - wait_for_shutdown(ready(()), pending::<()>()).await; + wait_for_shutdown(TestFuture { ready: true }, TestFuture { ready: false }).await; } #[tokio::test] async fn wait_for_shutdown_returns_when_terminate_completes() { - wait_for_shutdown(pending::<()>(), ready(())).await; + wait_for_shutdown(TestFuture { ready: false }, TestFuture { ready: true }).await; + } + + #[tokio::test] + async fn wait_for_shutdown_polls_pending_paths() { + let handle = tokio::task::spawn(wait_for_shutdown( + TestFuture { ready: false }, + TestFuture { ready: false }, + )); + tokio::task::yield_now().await; + handle.abort(); + let _ = handle.await; } #[cfg(unix)] diff --git a/crates/runtime/src/tracing.rs b/crates/runtime/src/tracing.rs @@ -35,11 +35,30 @@ fn default_log_file_name_from_exe_name(exe_name: Option<String>) -> String { } fn log_name_from_exe() -> Option<String> { - let exe = std::env::current_exe().ok()?; + log_name_from_path(std::env::current_exe().ok()) +} + +fn log_name_from_path(exe: Option<PathBuf>) -> Option<String> { + let exe = exe?; let name = exe.file_stem()?.to_string_lossy(); log_name_from_stem(name.as_ref()) } +#[cfg(test)] +mod test_hooks { + use std::sync::atomic::{AtomicBool, Ordering}; + + static IGNORE_ENV: AtomicBool = AtomicBool::new(false); + + pub fn set_ignore_env(ignore: bool) { + IGNORE_ENV.store(ignore, Ordering::SeqCst); + } + + pub fn ignore_env() -> bool { + IGNORE_ENV.load(Ordering::SeqCst) + } +} + fn log_name_from_stem(stem: &str) -> Option<String> { if stem.is_empty() { None @@ -49,6 +68,10 @@ fn log_name_from_stem(stem: &str) -> Option<String> { } fn env_value(key: &str) -> Option<String> { + #[cfg(test)] + if test_hooks::ignore_env() { + return None; + } let value = std::env::var(key).ok()?; normalize_env_value(&value) } @@ -88,7 +111,8 @@ fn resolve_default_level(env_level: Option<String>, default_level: Option<&str>) mod tests { use super::{ default_log_file_name, default_log_file_name_from_exe_name, env_path, env_value, init, - init_with, log_name_from_stem, normalize_env_value, resolve_default_level, resolve_log_dir, + init_with, log_name_from_path, log_name_from_stem, normalize_env_value, + resolve_default_level, resolve_log_dir, test_hooks, }; use std::path::{Path, PathBuf}; use tempfile::tempdir; @@ -119,6 +143,16 @@ mod tests { } #[test] + fn log_name_from_path_handles_missing_components() { + assert_eq!(log_name_from_path(None), None); + assert_eq!(log_name_from_path(Some(PathBuf::from("/"))), None); + assert_eq!( + log_name_from_path(Some(PathBuf::from("/tmp/radrootsd"))), + Some("radrootsd.log".to_string()) + ); + } + + #[test] fn default_log_file_name_helpers_cover_fallback() { assert_eq!( default_log_file_name_from_exe_name(Some("svc.log".to_string())), @@ -157,6 +191,15 @@ mod tests { #[test] fn init_paths_execute() { let dir = tempdir().expect("tempdir"); + test_hooks::set_ignore_env(true); + let invalid = dir.path().join("not-a-dir"); + std::fs::write(&invalid, "file").expect("write invalid path"); + let err_path = init_with(invalid.as_path(), Some("info")); + assert!(err_path.is_err()); + let invalid_str = invalid.to_string_lossy().to_string(); + let err_str = init_with(invalid_str.as_str(), Some("info")); + assert!(err_str.is_err()); + test_hooks::set_ignore_env(false); let first = init_with(dir.path(), Some("info")); assert!(first.is_ok()); let owned_path = dir.path().to_path_buf();