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