The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`, a module constant. Rebinding one left the other on `ctrl-a`, so the two escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`) moved on one client and not the other. `GET /api/info` now carries `leader`. The web binds that when it arrives and rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command bar render the configured chord; an absent or empty field — an older daemon, or an unreachable `/api/info` — leaves it on `ctrl-a`. Chose the existing `[tui].leader` served as-is over a new shared key: one knob, no precedence rules between two spellings, and no migration for operators who already set it. The name is a wart — it is the leader for every client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md` say so.
345 lines
11 KiB
Rust
345 lines
11 KiB
Rust
//! Liveness and identity of the daemon behind this TUI.
|
|
//!
|
|
//! The status bar renders [`LinkStatus`], and [`LinkState::Down`] puts the
|
|
//! restart overlay up and locks the keyboard. `docs/RESTART.md` is the
|
|
//! contract; this is the TUI half of what `web/src/daemonLink.ts` does.
|
|
|
|
use std::sync::{Arc, RwLock};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use maestro_protocol::{BuildInfo, DaemonClient, DaemonInfo};
|
|
use tokio::sync::Notify;
|
|
|
|
/// How long a lost connection stays quiet before the overlay blocks the UI.
|
|
pub const GRACE: Duration = Duration::from_millis(1200);
|
|
/// Cadence of `GET /api/info` while the daemon is unreachable.
|
|
pub const POLL: Duration = Duration::from_millis(700);
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LinkState {
|
|
/// No probe has landed yet.
|
|
Connecting,
|
|
/// The daemon answered.
|
|
Online,
|
|
/// The events socket dropped, still inside the grace window.
|
|
Reconnecting,
|
|
/// Unreachable past the grace window: the overlay is up.
|
|
Down,
|
|
}
|
|
|
|
/// What happened between two `/api/info` replies.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RestartKind {
|
|
Same,
|
|
Restarted,
|
|
Upgraded,
|
|
}
|
|
|
|
/// Compare two daemon replies. The commit decides first — a different one is
|
|
/// new code. `instance` catches a restart of the same build, and is the only
|
|
/// signal for a daemon built outside a git checkout (empty commit), which must
|
|
/// never be read as an upgrade.
|
|
pub fn compare_identity(before: &DaemonInfo, after: &DaemonInfo) -> RestartKind {
|
|
let a = before.build.commit.as_str();
|
|
let b = after.build.commit.as_str();
|
|
if !a.is_empty() && !b.is_empty() && a != b {
|
|
return RestartKind::Upgraded;
|
|
}
|
|
if !before.instance.is_empty()
|
|
&& !after.instance.is_empty()
|
|
&& before.instance != after.instance
|
|
{
|
|
return RestartKind::Restarted;
|
|
}
|
|
RestartKind::Same
|
|
}
|
|
|
|
/// One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
|
|
/// build carried uncommitted changes.
|
|
pub fn format_release(build: &BuildInfo) -> String {
|
|
if build.commit.is_empty() {
|
|
return "unknown release".to_string();
|
|
}
|
|
let mut out = build.commit.clone();
|
|
if build.commit_date.len() >= 10 {
|
|
out.push_str(" · ");
|
|
out.push_str(&build.commit_date[..10]);
|
|
}
|
|
if build.dirty {
|
|
out.push_str(" (dirty)");
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Snapshot of the link, taken once per frame by the renderer.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LinkStatus {
|
|
pub state: LinkState,
|
|
/// The release the daemon last reported. `None` until the first probe.
|
|
pub build: Option<BuildInfo>,
|
|
/// Failed probes in the current outage — the overlay counts them so a long
|
|
/// restart doesn't read as a hang.
|
|
pub attempts: u32,
|
|
/// Set once the daemon comes back on a different commit. The TUI is its own
|
|
/// binary, so it keeps running against the new daemon and shows this until
|
|
/// the user restarts it onto the new client.
|
|
pub upgraded_to: Option<BuildInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct DaemonLink {
|
|
inner: Arc<Inner>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct Inner {
|
|
state: RwLock<State>,
|
|
notify: Notify,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct State {
|
|
state: LinkState,
|
|
identity: Option<DaemonInfo>,
|
|
attempts: u32,
|
|
/// When the events socket dropped, for the grace window.
|
|
down_since: Option<Instant>,
|
|
upgraded_to: Option<BuildInfo>,
|
|
}
|
|
|
|
impl Default for State {
|
|
fn default() -> Self {
|
|
Self {
|
|
state: LinkState::Connecting,
|
|
identity: None,
|
|
attempts: 0,
|
|
down_since: None,
|
|
upgraded_to: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DaemonLink {
|
|
pub fn status(&self) -> LinkStatus {
|
|
let s = self.read();
|
|
LinkStatus {
|
|
state: s.state,
|
|
build: s.identity.as_ref().map(|i| i.build.clone()),
|
|
attempts: s.attempts,
|
|
upgraded_to: s.upgraded_to.clone(),
|
|
}
|
|
}
|
|
|
|
/// Whether the UI is locked behind the restart overlay.
|
|
pub fn is_blocking(&self) -> bool {
|
|
self.read().state == LinkState::Down
|
|
}
|
|
|
|
/// Wait until anything about the link changes, so the render loop redraws.
|
|
pub async fn changed(&self) {
|
|
self.inner.notify.notified().await;
|
|
}
|
|
|
|
/// The events socket dropped. Starts the grace window.
|
|
pub fn note_disconnected(&self, now: Instant) {
|
|
{
|
|
let mut s = self.write();
|
|
if s.state == LinkState::Down {
|
|
return;
|
|
}
|
|
s.state = LinkState::Reconnecting;
|
|
s.attempts = 0;
|
|
s.down_since = Some(now);
|
|
}
|
|
self.wake();
|
|
}
|
|
|
|
/// The events socket came back. Does not clear the state on its own: the
|
|
/// socket carries no identity, and only a probe can say which daemon
|
|
/// answered.
|
|
pub fn note_connected(&self) {
|
|
self.wake();
|
|
}
|
|
|
|
/// Fold in a successful `/api/info`. A reply on a new commit is recorded as
|
|
/// an upgrade; either way the link is back online.
|
|
pub fn apply_info(&self, info: DaemonInfo) {
|
|
{
|
|
let mut s = self.write();
|
|
if let Some(before) = &s.identity {
|
|
if compare_identity(before, &info) == RestartKind::Upgraded {
|
|
s.upgraded_to = Some(info.build.clone());
|
|
}
|
|
}
|
|
s.identity = Some(info);
|
|
s.state = LinkState::Online;
|
|
s.attempts = 0;
|
|
s.down_since = None;
|
|
}
|
|
self.wake();
|
|
}
|
|
|
|
/// Fold in a failed probe. The overlay goes up once the grace window since
|
|
/// the disconnect has passed.
|
|
pub fn probe_failed(&self, now: Instant) {
|
|
{
|
|
let mut s = self.write();
|
|
s.attempts = s.attempts.saturating_add(1);
|
|
let since = *s.down_since.get_or_insert(now);
|
|
if now.duration_since(since) >= GRACE {
|
|
s.state = LinkState::Down;
|
|
} else if s.state == LinkState::Online {
|
|
s.state = LinkState::Reconnecting;
|
|
}
|
|
}
|
|
self.wake();
|
|
}
|
|
|
|
/// Long-running task: keep [`LinkStatus`] true. Probes on a flat cadence
|
|
/// while the daemon is unreachable and idles while it is up. Never returns.
|
|
pub async fn run_probe_loop(self, daemon: DaemonClient) {
|
|
loop {
|
|
if self.read().state == LinkState::Online {
|
|
// A tick bounds the wait so a disconnect racing the notify
|
|
// costs a second, not the whole outage.
|
|
let _ = tokio::time::timeout(Duration::from_secs(1), self.changed()).await;
|
|
continue;
|
|
}
|
|
match daemon.daemon_info().await {
|
|
Ok(info) => self.apply_info(info),
|
|
Err(_) => {
|
|
self.probe_failed(Instant::now());
|
|
tokio::time::sleep(POLL).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn wake(&self) {
|
|
self.inner.notify.notify_waiters();
|
|
}
|
|
|
|
fn read(&self) -> std::sync::RwLockReadGuard<'_, State> {
|
|
self.inner
|
|
.state
|
|
.read()
|
|
.expect("daemon link rwlock poisoned")
|
|
}
|
|
|
|
fn write(&self) -> std::sync::RwLockWriteGuard<'_, State> {
|
|
self.inner
|
|
.state
|
|
.write()
|
|
.expect("daemon link rwlock poisoned")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn info(commit: &str, instance: &str) -> DaemonInfo {
|
|
DaemonInfo {
|
|
hostname: "konishi".into(),
|
|
version: "0.1.0".into(),
|
|
home_dir: "/home/naps".into(),
|
|
presets: Vec::new(),
|
|
build: BuildInfo {
|
|
commit: commit.into(),
|
|
commit_date: "2026-09-02T14:03:11Z".into(),
|
|
dirty: false,
|
|
},
|
|
instance: instance.into(),
|
|
leader: maestro_config::DEFAULT_LEADER.into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn identity_tells_upgrade_from_restart() {
|
|
assert_eq!(
|
|
compare_identity(&info("aaa", "i1"), &info("bbb", "i2")),
|
|
RestartKind::Upgraded
|
|
);
|
|
assert_eq!(
|
|
compare_identity(&info("aaa", "i1"), &info("aaa", "i2")),
|
|
RestartKind::Restarted
|
|
);
|
|
assert_eq!(
|
|
compare_identity(&info("aaa", "i1"), &info("aaa", "i1")),
|
|
RestartKind::Same
|
|
);
|
|
// No build stamp: the instance still reports the restart, and nothing
|
|
// ever claims a phantom upgrade.
|
|
assert_eq!(
|
|
compare_identity(&info("", "i1"), &info("", "i2")),
|
|
RestartKind::Restarted
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn release_line_pairs_hash_with_date() {
|
|
assert_eq!(
|
|
format_release(&info("f89be4a716c7", "i1").build),
|
|
"f89be4a716c7 · 2026-09-02"
|
|
);
|
|
assert_eq!(
|
|
format_release(&BuildInfo {
|
|
commit: "abc".into(),
|
|
commit_date: String::new(),
|
|
dirty: true,
|
|
}),
|
|
"abc (dirty)"
|
|
);
|
|
assert_eq!(format_release(&BuildInfo::default()), "unknown release");
|
|
}
|
|
|
|
#[test]
|
|
fn grace_window_holds_the_overlay_back() {
|
|
let link = DaemonLink::default();
|
|
link.apply_info(info("aaa", "i1"));
|
|
assert_eq!(link.status().state, LinkState::Online);
|
|
|
|
let t0 = Instant::now();
|
|
link.note_disconnected(t0);
|
|
assert_eq!(link.status().state, LinkState::Reconnecting);
|
|
assert!(!link.is_blocking());
|
|
|
|
link.probe_failed(t0 + Duration::from_millis(400));
|
|
assert_eq!(link.status().state, LinkState::Reconnecting);
|
|
|
|
link.probe_failed(t0 + GRACE);
|
|
assert_eq!(link.status().state, LinkState::Down);
|
|
assert!(link.is_blocking());
|
|
assert_eq!(link.status().attempts, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_plain_restart_unlocks_without_an_upgrade_note() {
|
|
let link = DaemonLink::default();
|
|
link.apply_info(info("aaa", "i1"));
|
|
let t0 = Instant::now();
|
|
link.note_disconnected(t0);
|
|
link.probe_failed(t0 + GRACE);
|
|
assert!(link.is_blocking());
|
|
|
|
link.apply_info(info("aaa", "i2"));
|
|
assert_eq!(link.status().state, LinkState::Online);
|
|
assert!(link.status().upgraded_to.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn an_upgrade_unlocks_and_names_the_new_release() {
|
|
let link = DaemonLink::default();
|
|
link.apply_info(info("aaa", "i1"));
|
|
let t0 = Instant::now();
|
|
link.note_disconnected(t0);
|
|
link.probe_failed(t0 + GRACE);
|
|
|
|
link.apply_info(info("bbb", "i2"));
|
|
let status = link.status();
|
|
assert_eq!(status.state, LinkState::Online);
|
|
assert_eq!(status.upgraded_to.unwrap().commit, "bbb");
|
|
assert!(!link.is_blocking());
|
|
}
|
|
}
|