use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use ntk_netlink::TableAllocator;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use crate::kernel::config::NtkdConfig;
use crate::node::{status, transport};
const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
const ABORT_REAP_WINDOW: Duration = Duration::from_secs(2);
pub async fn drain_tasks(tasks: &mut JoinSet<()>, timeout: Duration) -> usize {
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
let tick = (deadline - tokio::time::Instant::now()).min(Duration::from_secs(5));
tokio::select! {
joined = tasks.join_next() => match joined {
None => return 0,
Some(result) => {
log_join_result(result);
}
},
() = tokio::time::sleep(tick) => {
tracing::warn!(remaining = tasks.len(), "shutdown: still draining tasks");
}
}
}
if tasks.is_empty() {
return 0;
}
tracing::error!(
outstanding = tasks.len(),
?timeout,
"shutdown: drain timed out with tasks still running; aborting the rest"
);
tasks.abort_all();
let _ = tokio::time::timeout(ABORT_REAP_WINDOW, async {
while let Some(result) = tasks.join_next().await {
log_join_result(result);
}
})
.await;
tasks.len()
}
fn log_join_result(result: Result<(), tokio::task::JoinError>) -> bool {
match result {
Ok(()) => false,
Err(err) if err.is_panic() => {
tracing::error!(%err, "a daemon task panicked");
true
}
Err(_) => false,
}
}
async fn cleanup_kernel(kernel: &ntk_netlink::RealNetlink, nics: &[String], when: &str) {
let table_allocator: TableAllocator<()> = TableAllocator::new();
match ntk_netlink::cleanup(
kernel,
&table_allocator,
&nics
.iter()
.map(ntk_netlink::Interface::name)
.collect::<Vec<_>>(),
)
.await
{
Ok(report) if !report.is_empty() => {
tracing::info!(?report, when, "cleanup removed leftover kernel state");
}
Ok(_) => {}
Err(err) => tracing::warn!(%err, when, "kernel cleanup failed"),
}
if let Err(err) = crate::node::ip_route::cleanup_neighbor_routes(kernel).await {
tracing::warn!(%err, when, "neighbor on-link route cleanup failed");
}
}
pub async fn run(
config_path: PathBuf,
cli_nics: Vec<String>,
log_level: &str,
status_socket: PathBuf,
) -> anyhow::Result<()> {
init_tracing(log_level);
let config = NtkdConfig::load(&config_path)?;
let andna_key_path = config.andna_key_path().map(std::path::Path::to_path_buf);
let nics = if cli_nics.is_empty() {
config.nics().to_vec()
} else {
cli_nics
};
if nics.is_empty() {
tracing::warn!(
config = %config_path.display(),
"no network interfaces configured (empty `nics` in the config and no --nic given): \
this node will never discover a neighbour"
);
}
let root_cancel = CancellationToken::new();
let mut tasks = JoinSet::new();
let startup_kernel = ntk_netlink::RealNetlink::new()?;
cleanup_kernel(&startup_kernel, &nics, "startup").await;
let started = match transport::start(config, &nics, &mut tasks, root_cancel.child_token()).await
{
Ok(started) => started,
Err(err) => {
root_cancel.cancel();
let outstanding = drain_tasks(&mut tasks, SHUTDOWN_DRAIN_TIMEOUT).await;
if outstanding > 0 {
tracing::error!(outstanding, "startup failure: some actors never stopped");
}
cleanup_kernel(&startup_kernel, &nics, "startup failure").await;
return Err(err);
}
};
let running = Arc::new(started.running);
let net = running.net.clone();
let kernel = running.kernel.clone();
let running_for_status = running.clone();
let status_cancel = root_cancel.child_token();
tasks.spawn(async move {
if let Err(err) = status::serve(
status_socket,
running_for_status,
net,
andna_key_path,
status_cancel,
)
.await
{
tracing::warn!(
err = %crate::node::render_error_chain(&err),
"status server exited"
);
}
});
let mut failure: Option<anyhow::Error> = None;
{
let signal = wait_for_shutdown_signal();
tokio::pin!(signal);
loop {
tokio::select! {
biased;
() = &mut signal => {
tracing::info!("shutdown signal received, cancelling every actor");
break;
}
() = running.fatal.cancelled() => {
failure = Some(anyhow::anyhow!("the node can no longer route; shutting down"));
break;
}
joined = tasks.join_next() => match joined {
Some(result) => {
if log_join_result(result) {
failure = Some(anyhow::anyhow!("a daemon task panicked; shutting down"));
break;
}
tracing::warn!("a daemon task exited before shutdown");
}
None => {
failure = Some(anyhow::anyhow!("every daemon task exited; shutting down"));
break;
}
},
}
}
}
let shutdown_qspn = running.generation.borrow().qspn.clone();
if let Err(err) = shutdown_qspn.announce_destroy().await {
tracing::warn!(%err, "qspn: announcing this node's departure failed");
}
root_cancel.cancel();
let outstanding = drain_tasks(&mut tasks, SHUTDOWN_DRAIN_TIMEOUT).await;
if outstanding > 0 {
tracing::error!(
outstanding,
"graceful shutdown incomplete: some actors never stopped"
);
}
if let Err(err) = running.route_installer.lock().await.teardown().await {
tracing::warn!(%err, "graceful route teardown failed");
}
cleanup_kernel(kernel.as_ref(), &nics, "shutdown").await;
failure.map_or(Ok(()), Err)
}
pub async fn status(socket: PathBuf) -> anyhow::Result<()> {
let report = status::query(&socket).await?;
println!("main identity: {}", report.main_identity);
println!("identities: {}", report.identity_count);
println!("hooked: {}", report.hooked);
println!("bootstrapped: {}", report.bootstrapped);
println!("route table: {}", report.route_table);
println!("route destinations: {}", report.route_destinations);
println!("known links: {}", report.known_links);
println!("peer participants: {}", report.peer_participants);
println!(
"coordinator reservations: {}",
report.coordinator_reservations
);
println!(
"andna hosted hostnames: {}",
report.andna_hosted_hostnames
);
println!("arcs:");
for arc in &report.arcs {
println!(
" {} on {} — {} (cost: {}, qspn: {}, hooking: {})",
arc.mac,
arc.dev,
arc.state,
arc.cost.as_deref().unwrap_or("unknown"),
if arc.qspn_linked { "linked" } else { "pending" },
arc.hooking_phase.as_deref().unwrap_or("unknown"),
);
}
Ok(())
}
pub async fn andna_register(socket: PathBuf, hostname: String) -> anyhow::Result<()> {
let reply = status::register_hostname(&socket, &hostname).await?;
println!("hostname: {}", reply.hostname);
println!("outcome: {}", reply.outcome);
match reply.expires_unix {
Some(t) => println!("expires_unix: {t}"),
None => println!("expires_unix: (none)"),
}
Ok(())
}
pub async fn andna_resolve(socket: PathBuf, hostname: String) -> anyhow::Result<()> {
let reply = status::resolve_hostname(&socket, &hostname).await?;
println!("hostname: {}", reply.hostname);
println!("addresses:");
for entry in &reply.addresses {
match entry.ipv4 {
Some(ip) => println!(" {ip} ({})", entry.target),
None => println!(" {}", entry.target),
}
}
match reply.ttl_secs {
Some(t) => println!("ttl_secs: {t}"),
None => println!("ttl_secs: (none)"),
}
Ok(())
}
async fn wait_for_shutdown_signal() {
#[cfg(unix)]
{
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
fn init_tracing(log_level: &str) {
use tracing_subscriber::EnvFilter;
let filter = EnvFilter::try_new(log_level).unwrap_or_else(|_| EnvFilter::new("info"));
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
}
#[cfg(test)]
mod drain_tasks_tests {
use super::drain_tasks;
use std::time::Duration;
use tokio::task::JoinSet;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn drains_cleanly_when_every_task_finishes() {
let mut tasks = JoinSet::new();
for _ in 0..4 {
tasks.spawn(async {
tokio::time::sleep(Duration::from_millis(10)).await;
});
}
let start = tokio::time::Instant::now();
let outstanding = drain_tasks(&mut tasks, Duration::from_secs(5)).await;
assert_eq!(outstanding, 0);
assert!(tasks.is_empty());
assert!(
start.elapsed() < Duration::from_secs(1),
"a clean drain must not wait anywhere near its own timeout, took {:?}",
start.elapsed()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn returns_promptly_when_a_task_never_yields() {
let mut tasks = JoinSet::new();
tasks.spawn(async {
let stop_at = tokio::time::Instant::now() + Duration::from_secs(5);
while tokio::time::Instant::now() < stop_at {
std::hint::spin_loop();
}
});
let start = tokio::time::Instant::now();
let outstanding = drain_tasks(&mut tasks, Duration::from_millis(200)).await;
assert_eq!(
outstanding, 1,
"the never-yielding task must be reported as outstanding"
);
assert!(
start.elapsed() < Duration::from_secs(3),
"drain_tasks must bound its wait to timeout + ABORT_REAP_WINDOW even when a task \
never yields, took {:?}",
start.elapsed()
);
}
}
#[cfg(test)]
mod panic_supervision_tests {
use super::{drain_tasks, log_join_result};
use std::time::Duration;
use tokio::task::JoinSet;
#[tokio::test]
async fn a_panicked_task_is_reported_as_a_panic_but_clean_exits_and_aborts_are_not() {
let mut tasks = JoinSet::new();
tasks.spawn(async { panic!("actor blew up") });
let panicked = tasks.join_next().await.expect("one task");
assert!(log_join_result(panicked), "a panic must be flagged");
tasks.spawn(async {});
let clean = tasks.join_next().await.expect("one task");
assert!(!log_join_result(clean), "a clean exit is not a panic");
let handle = tasks.spawn(std::future::pending::<()>());
handle.abort();
let aborted = tasks.join_next().await.expect("one task");
assert!(!log_join_result(aborted), "an abort is not a panic");
}
#[tokio::test]
async fn draining_continues_past_a_panicked_task() {
let mut tasks = JoinSet::new();
tasks.spawn(async { panic!("actor blew up") });
tasks.spawn(async { tokio::time::sleep(Duration::from_millis(10)).await });
assert_eq!(drain_tasks(&mut tasks, Duration::from_secs(5)).await, 0);
assert!(tasks.is_empty());
}
}