| 1 | //! daemon bridge: two `Client` connections off the ui thread — a reader |
| 2 | //! thread forwarding subscription events, a control thread running |
| 3 | //! request/response ops fed by a `ControlCmd` channel. |
| 4 | //! |
| 5 | //! initial rpcs run on the calling thread so a dead daemon fails fast |
| 6 | //! before any window opens. both workers survive the daemon going away |
| 7 | //! (shutdown / crash / `systemctl restart`): the reader reconnects with |
| 8 | //! capped backoff and pushes a fresh [`Snapshot`] so the ui re-seeds |
| 9 | //! (node ids change across a restart); the control thread reconnects |
| 10 | //! lazily with a one-shot retry across a dropped connection. |
| 11 | |
| 12 | use std::path::{Path, PathBuf}; |
| 13 | use std::thread; |
| 14 | use std::time::Duration; |
| 15 | |
| 16 | use anyhow::{Context as _, Result}; |
| 17 | use crossbeam_channel::{unbounded, Receiver, Sender}; |
| 18 | use headroom_client::{Client, ClientError}; |
| 19 | use headroom_ipc::{Event, LayerASnapshot, ProfileInfo, Route, RouteList, Status, Topic}; |
| 20 | |
| 21 | /// topics the monitor subscribes to. shared by the initial connect and |
| 22 | /// every reconnect. |
| 23 | const TOPICS: [Topic; 4] = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon]; |
| 24 | |
| 25 | /// reconnect backoff: first retry after the min, doubling to the cap. |
| 26 | const RECONNECT_MIN_DELAY: Duration = Duration::from_millis(500); |
| 27 | const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(3); |
| 28 | |
| 29 | /// a control op the ui asks the control thread to run; each maps to one |
| 30 | /// `Client` request/response call. |
| 31 | pub enum ControlCmd { |
| 32 | /// `bypass.set` |
| 33 | SetBypass(bool), |
| 34 | /// `per-app.master` |
| 35 | SetPerAppMaster(bool), |
| 36 | /// `route.stream` — one-shot reroute of a live stream by node id |
| 37 | /// (doesn't survive the stream being torn down). |
| 38 | RouteStream { node_id: u32, to: Route }, |
| 39 | /// `route.set` — persistent per-app rule, keyed by app label (matches |
| 40 | /// `application.name` / `process.binary`). survives the app recreating |
| 41 | /// its stream between tracks. |
| 42 | SetRoute { app: String, to: Route }, |
| 43 | /// `per-app.set` |
| 44 | SetPerApp { app: String, enabled: bool }, |
| 45 | /// `per-app.reset` |
| 46 | ResetLayerA { node_id: u32 }, |
| 47 | /// `profile.use` |
| 48 | UseProfile(String), |
| 49 | /// `setting.reset` — clear all overlay setting overrides. |
| 50 | ClearOverrides, |
| 51 | /// `per-app.list` → [`AppMsg::LayerASnapshots`]. |
| 52 | RefreshLayerA, |
| 53 | /// `profile.list` → [`AppMsg::Profiles`]. |
| 54 | RefreshProfiles, |
| 55 | } |
| 56 | |
| 57 | /// the daemon snapshot the ui seeds (and re-seeds) from; taken at connect |
| 58 | /// and again on every reconnect. |
| 59 | pub struct Snapshot { |
| 60 | pub status: Status, |
| 61 | pub route_list: RouteList, |
| 62 | /// seeds the switcher. |
| 63 | pub profiles: Vec<ProfileInfo>, |
| 64 | } |
| 65 | |
| 66 | /// a message from a worker thread to the ui. |
| 67 | pub enum AppMsg { |
| 68 | /// a subscription event off the wire (reader thread). |
| 69 | Event(Event), |
| 70 | /// the event stream ended; carries a reason for the footer banner. |
| 71 | /// the reader starts reconnecting immediately; [`AppMsg::Reconnected`] |
| 72 | /// follows once it succeeds. |
| 73 | Disconnected(String), |
| 74 | /// the reader reconnected after a drop. carries a fresh snapshot so |
| 75 | /// the ui re-seeds (node ids/uptime/profile change across a restart). |
| 76 | /// boxed to keep the enum small. |
| 77 | Reconnected(Box<Snapshot>), |
| 78 | /// a control op failed; surfaced in the footer. |
| 79 | ControlError(String), |
| 80 | /// fresh `profile.list` snapshot. |
| 81 | Profiles(Vec<ProfileInfo>), |
| 82 | /// fresh `per-app.list` snapshot (managed / ceiling / deferred). |
| 83 | LayerASnapshots(Vec<LayerASnapshot>), |
| 84 | } |
| 85 | |
| 86 | /// everything `main` needs to seed the window. |
| 87 | pub struct Bootstrap { |
| 88 | pub snapshot: Snapshot, |
| 89 | /// live event + worker channel, drained each tick. |
| 90 | pub rx: Receiver<AppMsg>, |
| 91 | /// hands control ops to the control thread. |
| 92 | pub cmd_tx: Sender<ControlCmd>, |
| 93 | } |
| 94 | |
| 95 | /// connect, subscribe, fetch the initial snapshot, open a second control |
| 96 | /// connection, then spawn the reader + control threads. |
| 97 | /// |
| 98 | /// errors here are returned to the caller (fail fast before any window). |
| 99 | /// once the threads run, a stream failure is reported in-band as |
| 100 | /// [`AppMsg::Disconnected`] / [`AppMsg::Reconnected`]; control failures |
| 101 | /// as [`AppMsg::ControlError`]. |
| 102 | pub fn start(socket: Option<PathBuf>) -> Result<Bootstrap> { |
| 103 | let mut client = match socket.as_deref() { |
| 104 | Some(p) => Client::connect_at(p) |
| 105 | .with_context(|| format!("connecting to headroom daemon at {}", p.display()))?, |
| 106 | None => Client::connect().context("connecting to headroom daemon (is it running?)")?, |
| 107 | }; |
| 108 | |
| 109 | // subscribe + initial snapshot before the client moves to the reader. |
| 110 | // a dead/half-broken daemon surfaces here. |
| 111 | let snapshot = fetch_snapshot(&mut client).context("initial daemon fetch")?; |
| 112 | |
| 113 | // the resolved socket path drives reconnects on both worker threads. |
| 114 | let socket_path = client.socket_path().to_path_buf(); |
| 115 | |
| 116 | // second connection for control, on the same resolved socket. |
| 117 | let control = Client::connect_at(&socket_path).context("opening control connection")?; |
| 118 | |
| 119 | let (tx, rx) = unbounded::<AppMsg>(); |
| 120 | let (cmd_tx, cmd_rx) = unbounded::<ControlCmd>(); |
| 121 | |
| 122 | let reader_tx = tx.clone(); |
| 123 | let reader_socket = socket_path.clone(); |
| 124 | thread::Builder::new() |
| 125 | .name("headroom-gui-rx".into()) |
| 126 | .spawn(move || reader_loop(reader_socket, client, reader_tx)) |
| 127 | .context("spawning reader thread")?; |
| 128 | |
| 129 | thread::Builder::new() |
| 130 | .name("headroom-gui-ctl".into()) |
| 131 | .spawn(move || control_loop(socket_path, control, cmd_rx, tx)) |
| 132 | .context("spawning control thread")?; |
| 133 | |
| 134 | Ok(Bootstrap { |
| 135 | snapshot, |
| 136 | rx, |
| 137 | cmd_tx, |
| 138 | }) |
| 139 | } |
| 140 | |
| 141 | /// subscribe to [`TOPICS`] and pull `status` + `route.list` + |
| 142 | /// `profile.list`. the `Client` correlates rpc responses while a |
| 143 | /// subscription is active (buffers interleaved events), so doing this on |
| 144 | /// the event connection is fine — one path seeds connect + every reconnect. |
| 145 | fn fetch_snapshot(client: &mut Client) -> Result<Snapshot, ClientError> { |
| 146 | client.subscribe(&TOPICS)?; |
| 147 | let status = client.status()?; |
| 148 | let route_list = client.route_list()?; |
| 149 | let profiles = client.profile_list()?; |
| 150 | Ok(Snapshot { |
| 151 | status, |
| 152 | route_list, |
| 153 | profiles, |
| 154 | }) |
| 155 | } |
| 156 | |
| 157 | /// forward each event to the ui. on stream failure, announce the drop, |
| 158 | /// reconnect (with backoff), and resume — returning only when the ui |
| 159 | /// channel closes (window gone). |
| 160 | fn reader_loop(socket: PathBuf, mut client: Client, tx: Sender<AppMsg>) { |
| 161 | loop { |
| 162 | match client.next_event() { |
| 163 | Ok(ev) => { |
| 164 | if tx.send(AppMsg::Event(ev)).is_err() { |
| 165 | return; // ui gone. |
| 166 | } |
| 167 | } |
| 168 | Err(e) => { |
| 169 | tracing::warn!(error = %e, "event stream lost; reconnecting to daemon"); |
| 170 | if tx.send(AppMsg::Disconnected(e.to_string())).is_err() { |
| 171 | return; |
| 172 | } |
| 173 | match reconnect_event_client(&socket, &tx) { |
| 174 | Some(c) => { |
| 175 | tracing::info!("reconnected to daemon; re-seeded UI snapshot"); |
| 176 | client = c; |
| 177 | } |
| 178 | None => return, // ui gone while reconnecting. |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /// reconnect the event stream after a drop. retries `connect_at` with |
| 186 | /// capped backoff until the daemon answers with a fresh snapshot, then |
| 187 | /// sends [`AppMsg::Reconnected`] and returns the live client. a daemon |
| 188 | /// that's up but can't complete the snapshot (half-open / mid-restart) |
| 189 | /// counts as a failed attempt. returns `None` only if the ui closed. |
| 190 | fn reconnect_event_client(socket: &Path, tx: &Sender<AppMsg>) -> Option<Client> { |
| 191 | let mut delay = RECONNECT_MIN_DELAY; |
| 192 | loop { |
| 193 | if let Ok(mut client) = Client::connect_at(socket) { |
| 194 | if let Ok(snapshot) = fetch_snapshot(&mut client) { |
| 195 | if tx.send(AppMsg::Reconnected(Box::new(snapshot))).is_err() { |
| 196 | return None; |
| 197 | } |
| 198 | return Some(client); |
| 199 | } |
| 200 | } |
| 201 | thread::sleep(delay); |
| 202 | delay = (delay * 2).min(RECONNECT_MAX_DELAY); |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | /// block on the command channel, dispatching each [`ControlCmd`] to its |
| 207 | /// `Client` call. refresh ops reply with a snapshot `AppMsg`; failures |
| 208 | /// become [`AppMsg::ControlError`]. returns when either channel closes. |
| 209 | /// |
| 210 | /// the control connection reconnects lazily: if torn down (daemon |
| 211 | /// restart) the next command opens a fresh one, and a command that fails |
| 212 | /// mid-flight on a connection error is retried once before being reported. |
| 213 | fn control_loop(socket: PathBuf, initial: Client, cmd_rx: Receiver<ControlCmd>, tx: Sender<AppMsg>) { |
| 214 | let mut client: Option<Client> = Some(initial); |
| 215 | while let Ok(cmd) = cmd_rx.recv() { |
| 216 | // reconnect if a prior op tore the connection down. |
| 217 | if client.is_none() { |
| 218 | client = Client::connect_at(&socket).ok(); |
| 219 | } |
| 220 | let Some(c) = client.as_mut() else { |
| 221 | if tx |
| 222 | .send(AppMsg::ControlError("daemon unreachable".into())) |
| 223 | .is_err() |
| 224 | { |
| 225 | return; |
| 226 | } |
| 227 | continue; |
| 228 | }; |
| 229 | |
| 230 | let mut result = run_cmd(c, &cmd); |
| 231 | // one retry across a dropped connection — the daemon may have |
| 232 | // restarted since the last op left the socket stale. |
| 233 | if matches!(&result, Err(e) if is_connection_error(e)) { |
| 234 | client = Client::connect_at(&socket).ok(); |
| 235 | if let Some(c) = client.as_mut() { |
| 236 | result = run_cmd(c, &cmd); |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | let msg = match result { |
| 241 | Ok(Some(reply)) => reply, |
| 242 | Ok(None) => continue, |
| 243 | Err(e) => { |
| 244 | // drop a dead connection so the next command reconnects. |
| 245 | if is_connection_error(&e) { |
| 246 | client = None; |
| 247 | } |
| 248 | AppMsg::ControlError(e.to_string()) |
| 249 | } |
| 250 | }; |
| 251 | if tx.send(msg).is_err() { |
| 252 | return; |
| 253 | } |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | /// run one [`ControlCmd`] against `client`. borrows the command so the |
| 258 | /// caller can retry on a fresh connection. `Ok(Some(_))` for refreshes |
| 259 | /// that reply, `Ok(None)` for fire-and-forget toggles. |
| 260 | fn run_cmd(client: &mut Client, cmd: &ControlCmd) -> Result<Option<AppMsg>, ClientError> { |
| 261 | match cmd { |
| 262 | ControlCmd::SetBypass(enabled) => client.bypass_set(*enabled).map(|()| None), |
| 263 | ControlCmd::SetPerAppMaster(enabled) => client.per_app_master(*enabled).map(|()| None), |
| 264 | ControlCmd::RouteStream { node_id, to } => { |
| 265 | client.route_stream(*node_id, *to).map(|()| None) |
| 266 | } |
| 267 | ControlCmd::SetRoute { app, to } => client.route_set(app, *to).map(|()| None), |
| 268 | ControlCmd::SetPerApp { app, enabled } => client.per_app_set(app, *enabled).map(|()| None), |
| 269 | ControlCmd::ResetLayerA { node_id } => client.layer_a_reset(*node_id).map(|()| None), |
| 270 | ControlCmd::UseProfile(name) => client.profile_use(name).map(|_| None), |
| 271 | ControlCmd::ClearOverrides => client.setting_reset().map(|_| None), |
| 272 | ControlCmd::RefreshLayerA => client.layer_a_list().map(|l| Some(AppMsg::LayerASnapshots(l))), |
| 273 | ControlCmd::RefreshProfiles => client.profile_list().map(|p| Some(AppMsg::Profiles(p))), |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | /// whether an error means the socket is gone (vs a server-side rejection |
| 278 | /// of a delivered op). only the former warrants a reconnect; a `Protocol` |
| 279 | /// error is a logical failure to surface as-is. |
| 280 | fn is_connection_error(e: &ClientError) -> bool { |
| 281 | matches!(e, ClientError::Ipc(_)) |
| 282 | } |