Skip to content

Commit 6264818

Browse files
committed
feat: watch tunnel progress at runtime and fail fast on mid-session breakage
Setup-time progress checks aren't enough. Today's failure mode: a tunnel came up cleanly, ran for ~9 minutes, then the iroh DNS controller re-reconciled and flipped IrohDNSPublished from True back to False because a deleted Connector's DNS claim was never cleaned up server-side. The data plane went dark while the CLI kept reporting healthy — Ready was still True, the heartbeat was still renewing the lease, and there was no client-side signal that anything had changed. Poll progress every 10s alongside the existing login-state watch. When terminal_failure() trips (currently IrohDNSPublished=False with reason DeferredToOwner), print the same message the setup path emits and break out of the run loop cleanly so the operator gets disable+cleanup instead of a silent zombie. Tunnel-deleted-from-under-us also breaks out; transient poll errors only warn and retry. Factor the failure message into format_terminal_failure() so setup-time and runtime emit identical wording — the user shouldn't have to learn two error shapes for the same diagnosis.
1 parent 90a7ab3 commit 6264818

1 file changed

Lines changed: 69 additions & 12 deletions

File tree

cli/src/main.rs

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,18 @@ async fn main() -> n0_error::Result<()> {
671671
// not just buried in tracing output.
672672
let mut login_rx = datum.auth().login_state_watch();
673673
let mut last_state = *login_rx.borrow();
674+
// Also poll the server-side progress every 10s. Setup-time
675+
// checks aren't enough: conditions can flip back to a
676+
// terminal failure later (e.g. the iroh DNS controller
677+
// re-reconciling and re-emerging a stale owner), and the
678+
// data plane silently drops in the meantime. When that
679+
// happens, surface it and break out so the operator sees
680+
// the same actionable message they'd have seen at setup.
681+
let mut runtime_poll = tokio::time::interval(RUNTIME_POLL_INTERVAL);
682+
runtime_poll.set_missed_tick_behavior(
683+
tokio::time::MissedTickBehavior::Delay,
684+
);
685+
runtime_poll.tick().await; // consume the immediate first tick
674686
loop {
675687
tokio::select! {
676688
res = tokio::signal::ctrl_c() => {
@@ -697,6 +709,32 @@ async fn main() -> n0_error::Result<()> {
697709
}
698710
last_state = new_state;
699711
}
712+
_ = runtime_poll.tick() => {
713+
match service.get_active_progress(&tunnel_id).await {
714+
Ok(Some(progress)) => {
715+
if let Some(fail) = progress.terminal_failure() {
716+
eprintln!();
717+
eprintln!("================================================================");
718+
eprintln!(" Tunnel is no longer reachable from the edge.");
719+
eprintln!();
720+
eprintln!(" {}", format_terminal_failure(fail));
721+
eprintln!("================================================================");
722+
eprintln!();
723+
break;
724+
}
725+
}
726+
Ok(None) => {
727+
eprintln!();
728+
eprintln!("Tunnel {} no longer exists on the server. Stopping.", tunnel_id);
729+
break;
730+
}
731+
Err(err) => {
732+
// Transient query failure (network blip, token mid-refresh,
733+
// etc.) — log and keep going; the next tick will retry.
734+
tracing::warn!("watch: progress poll failed: {err:#}");
735+
}
736+
}
737+
}
700738
}
701739
}
702740
println!();
@@ -836,10 +874,38 @@ struct SetupResult {
836874
/// issuance, edge programming) while still flagging genuine wedges.
837875
const PROGRESS_STUCK_WARN: std::time::Duration = std::time::Duration::from_secs(30);
838876

839-
/// Poll cadence. Fast enough that step transitions feel responsive;
840-
/// the actual server-side reconcile latency dominates wall-clock anyway.
877+
/// Poll cadence during setup. Fast enough that step transitions feel
878+
/// responsive; the actual server-side reconcile latency dominates
879+
/// wall-clock anyway.
841880
const PROGRESS_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(750);
842881

882+
/// Poll cadence during the steady-state watch after setup is done. A live
883+
/// terminal failure (e.g. a stale iroh DNS owner re-emerging on a
884+
/// controller re-reconcile) needs to be surfaced within a minute or so,
885+
/// but we don't need sub-second resolution — the tunnel either works or
886+
/// it doesn't.
887+
const RUNTIME_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
888+
889+
/// Format the user-facing message for a terminal progress failure. Shared
890+
/// between setup-time (bail!) and runtime watch (eprintln!) so the user
891+
/// sees the same diagnosis regardless of when the condition trips.
892+
fn format_terminal_failure(fail: &lib::ProgressStep) -> String {
893+
let owner = fail
894+
.message
895+
.as_deref()
896+
.unwrap_or("(controller did not provide a message)");
897+
format!(
898+
"✗ {}: iroh DNS record is owned by another Connector ({}). \
899+
Another project on this machine — or a stale Connector that was never \
900+
cleaned up — claimed the same iroh identity. Remove the listen_key for \
901+
this project (under <repo>/projects/<project_id>/listen_key with the \
902+
per-project layout, or the flat <repo>/listen_key otherwise) and rerun, \
903+
or delete the offending Connector.",
904+
fail.kind.label(),
905+
owner,
906+
)
907+
}
908+
843909
/// Drive a tunnel through its setup conditions, printing a checklist as
844910
/// each one transitions to Ready. Bails fast on terminal failure
845911
/// (e.g. iroh DNS deferred to another project's Connector — waiting won't
@@ -904,16 +970,7 @@ async fn await_tunnel_progress(
904970
}
905971

906972
if let Some(fail) = progress.terminal_failure() {
907-
let owner = fail.message.as_deref().unwrap_or("unknown owner");
908-
n0_error::bail_any!(
909-
"✗ {}: iroh DNS record is owned by another Connector ({}). \
910-
Another project on this machine registered the same iroh identity. \
911-
With per-project keys, delete the per-project listen_key under this project's \
912-
directory in your Datum repo (or the offending Connector in the other project) \
913-
and rerun.",
914-
fail.kind.label(),
915-
owner,
916-
);
973+
n0_error::bail_any!("{}", format_terminal_failure(fail));
917974
}
918975

919976
if progress.all_ready() && !progress.hostnames.is_empty() {

0 commit comments

Comments
 (0)