Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions crates/cccc-cli/tests/suite/daemon_self_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,84 @@ use std::path::Path;
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};

// These black-box cases also run unchanged against the stock daemon as the
// missing-feature reference. Only the CLI's public IPC interface is used.
#[test]
fn host_disk_diagnostic_is_sampled_without_any_group() {
with_disk_daemon(|home| {
std::thread::sleep(Duration::from_millis(100));
let diagnostic = wait_disk_sample(home);
assert!(diagnostic["space"]["total_bytes"].as_u64().is_some());
assert!(diagnostic["space"]["available_bytes"].as_u64().is_some());
assert!(diagnostic["sampled_at"].as_str().is_some());
assert_eq!(diagnostic["sample_interval_seconds"], 60);
assert!(
std::fs::read_dir(home.join("groups"))
.expect("group directory")
.next()
.is_none(),
"the host diagnostic must not require a Group"
);
});
}

#[test]
fn host_disk_diagnostic_status_polling_does_not_resample() {
with_disk_daemon(|home| {
let initial = wait_disk_sample(home);
for _ in 0..8 {
let current = disk_ping(home);
assert_eq!(current["attempts"], initial["attempts"]);
assert_eq!(current["sampled_at"], initial["sampled_at"]);
assert_eq!(current["space"], initial["space"]);
}
});
}

fn with_disk_daemon(check: impl FnOnce(&Path)) {
let temp = tempfile::tempdir().expect("home");
let home = temp.path().join("home");
let executable = Path::new(env!("CARGO_BIN_EXE_cccc"));
let start = run(executable, &home, &["daemon", "start"]);
assert!(start.status.success(), "start: {}", detail(&start));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check(&home)));
let stop = run(executable, &home, &["daemon", "stop"]);
wait_for_daemon_exit(&home);
assert!(stop.status.success(), "stop: {}", detail(&stop));
assert_daemon_lock_released(&home);
if let Err(error) = result {
std::panic::resume_unwind(error);
}
}

fn disk_ping(home: &Path) -> serde_json::Value {
let layout = cccc_core::HomeLayout::from_path(home).expect("home layout");
let response = tokio::runtime::Runtime::new()
.expect("RPC runtime")
.block_on(
cccc_client::DaemonClient::new(layout).call(&cccc_contracts::DaemonRequest {
v: 1,
op: "ping".into(),
args: Default::default(),
}),
)
.expect("actual daemon ping");
assert!(response.ok, "ping succeeds");
response.result["diagnostics"]["disk"].clone()
}

fn wait_disk_sample(home: &Path) -> serde_json::Value {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let value = disk_ping(home);
if value["attempts"].as_u64().is_some_and(|count| count > 0) {
return value;
}
assert!(Instant::now() < deadline, "host disk diagnostic is missing");
std::thread::sleep(Duration::from_millis(10));
}
}

#[test]
fn installed_cli_starts_daemon_without_a_sibling_daemon_binary() {
let temp = tempfile::tempdir().expect("tempdir");
Expand Down
3 changes: 2 additions & 1 deletion crates/cccc-daemon/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,15 @@ pub(crate) fn resolve_operation(request: &DaemonRequest) -> Option<Operation> {
})
}

fn ping(_home: &HomeLayout, _request: &DaemonRequest) -> OpResult {
fn ping(home: &HomeLayout, _request: &DaemonRequest) -> OpResult {
object(json!({
"pid": std::process::id(),
"version": env!("CARGO_PKG_VERSION"),
"build": cccc_core::build_info::current(),
"executable": std::env::current_exe().ok(),
"ts": cccc_contracts::utc_now(),
"ipc_v": 1,
"diagnostics": { "disk": crate::server_host_disk::snapshot(home) },
"capabilities": {
"events_stream": true,
"remote_access": true,
Expand Down
1 change: 1 addition & 0 deletions crates/cccc-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod server_automation;
mod server_connection;
mod server_connections;
mod server_events_stream;
mod server_host_disk;
mod server_lifecycle;
mod server_terminal_attach;

Expand Down
3 changes: 3 additions & 0 deletions crates/cccc-daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use crate::server_actor_activity::ActorActivityService;
use crate::server_automation::AutomationScheduler;
use crate::server_connection::spawn_connection;
use crate::server_connections::ConnectionTasks;
use crate::server_host_disk::HostDiskService;
use crate::server_lifecycle::{DaemonLifecycle, cleanup_stale};

type RuntimeRestoreSpawner = fn(HomeLayout, DispatchLocks);
Expand All @@ -40,6 +41,7 @@ async fn run_with_restore(home: HomeLayout, restore: RuntimeRestoreSpawner) -> R
let mut lifecycle = DaemonLifecycle::new(paths, lock);
std::fs::write(&lifecycle.paths.pid, format!("{}\n", std::process::id()))?;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let host_disk = HostDiskService::start(lifecycle.paths.home.clone());
let actor_activity = ActorActivityService::start(lifecycle.paths.home.clone());
let dispatch_locks = DispatchLocks::default();

Expand All @@ -62,6 +64,7 @@ async fn run_with_restore(home: HomeLayout, restore: RuntimeRestoreSpawner) -> R
)
.await
};
host_disk.finish().await;
actor_activity.finish().await;
lifecycle.finish(result)
}
Expand Down
183 changes: 183 additions & 0 deletions crates/cccc-daemon/src/server_host_disk.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//! Passive, process-owned diagnostics for the volume containing CCCC_HOME.
//! No Group, ledger, NoticeState or delivery policy participates in sampling.
use cccc_core::HomeLayout;
use serde::Serialize;
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;

const SAMPLE_INTERVAL: Duration = Duration::from_secs(60);
const LOW_BYTES: u64 = 3 * 1024 * 1024 * 1024;
const RECOVERY_BYTES: u64 = 4 * 1024 * 1024 * 1024;

#[derive(Clone, Debug, Serialize)]
struct Space {
available_bytes: u64,
total_bytes: u64,
}

#[derive(Clone, Debug, Serialize)]
pub(crate) struct Diagnostic {
state: &'static str,
sample_interval_seconds: u64,
low_bytes: u64,
recovery_bytes: u64,
attempts: u64,
last_attempt_at: Option<String>,
sampled_at: Option<String>,
space: Option<Space>,
error: Option<String>,
#[serde(skip)]
low: bool,
}

impl Default for Diagnostic {
fn default() -> Self {
Self {
state: "unknown",
sample_interval_seconds: SAMPLE_INTERVAL.as_secs(),
low_bytes: LOW_BYTES,
recovery_bytes: RECOVERY_BYTES,
attempts: 0,
last_attempt_at: None,
sampled_at: None,
space: None,
error: None,
low: false,
}
}
}

impl Diagnostic {
fn record(&mut self, result: io::Result<Space>) {
let now = cccc_contracts::utc_now();
self.attempts = self.attempts.saturating_add(1);
self.last_attempt_at = Some(now.clone());
match result {
Ok(space) => {
self.low = space.available_bytes
< if self.low {
self.recovery_bytes
} else {
self.low_bytes
};
self.state = if self.low { "low" } else { "healthy" };
self.space = Some(space);
self.sampled_at = Some(now);
self.error = None;
}
Err(error) => {
self.state = "unavailable";
self.error = Some(error.to_string());
// Preserve the prior sample, its timestamp and hysteresis.
// A failed observation is neither recovery nor fresh capacity.
}
}
}
}

type Shared = Arc<Mutex<Diagnostic>>;
type Registry = BTreeMap<PathBuf, Weak<Mutex<Diagnostic>>>;

fn registry() -> &'static Mutex<Registry> {
static REGISTRY: OnceLock<Mutex<Registry>> = OnceLock::new();
REGISTRY.get_or_init(Mutex::default)
}

/// Read the existing sample only. Polling never probes a filesystem.
pub(crate) fn snapshot(home: &HomeLayout) -> Option<Diagnostic> {
let shared = registry()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(home.root())?
.upgrade()?;
let result = shared
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Some(result)
}

fn sample(path: &Path) -> io::Result<Space> {
let usage = fs2::statvfs(path)?;
let space = Space {
available_bytes: usage.available_space(),
total_bytes: usage.total_space(),
};
if space.total_bytes == 0 || space.available_bytes > space.total_bytes {
return Err(io::Error::other("invalid filesystem capacity sample"));
}
Ok(space)
}

pub(crate) struct HostDiskService {
stop: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
}

impl HostDiskService {
pub(crate) fn start(home: HomeLayout) -> Self {
Self::with_interval(home, SAMPLE_INTERVAL)
}

fn with_interval(home: HomeLayout, period: Duration) -> Self {
let shared: Shared = Arc::new(Mutex::new(Diagnostic::default()));
{
let mut entries = registry()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
entries.retain(|_, state| state.strong_count() > 0);
entries.insert(home.root().to_owned(), Arc::downgrade(&shared));
}
let (stop, mut stopped) = oneshot::channel();
let task = tokio::spawn(async move {
let mut interval = tokio::time::interval(period);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = &mut stopped => break,
_ = interval.tick() => {},
}
let path = home.root().to_owned();
let result = tokio::task::spawn_blocking(move || sample(&path))
.await
.unwrap_or_else(|error| Err(io::Error::other(error.to_string())));
shared
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.record(result);
}
});
Self {
stop: Some(stop),
task,
}
}

pub(crate) async fn finish(mut self) {
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
if tokio::time::timeout(Duration::from_millis(500), &mut self.task)
.await
.is_err()
{
self.task.abort();
let _ = (&mut self.task).await;
}
}
}

impl Drop for HostDiskService {
fn drop(&mut self) {
self.task.abort();
}
}

#[cfg(test)]
#[path = "server_host_disk_tests.rs"]
mod tests;
Loading
Loading