atagen

headroom

public
Star0
Watch0
ssh://brewery@pls.lobotomise.me:33/atagen/headroom.git

Blame: crates/headroom-gui/src/io.rs

c19702ae09beatagen2026-05-27 15:49 UTC1
//! daemon bridge: two `Client` connections off the ui thread — a reader
c19702ae09beatagen2026-05-27 15:49 UTC2
//! thread forwarding subscription events, a control thread running
c19702ae09beatagen2026-05-27 15:49 UTC3
//! request/response ops fed by a `ControlCmd` channel.
c19702ae09beatagen2026-05-27 15:49 UTC4
//!
c19702ae09beatagen2026-05-27 15:49 UTC5
//! initial rpcs run on the calling thread so a dead daemon fails fast
c19702ae09beatagen2026-05-27 15:49 UTC6
//! before any window opens. both workers survive the daemon going away
c19702ae09beatagen2026-05-27 15:49 UTC7
//! (shutdown / crash / `systemctl restart`): the reader reconnects with
c19702ae09beatagen2026-05-27 15:49 UTC8
//! capped backoff and pushes a fresh [`Snapshot`] so the ui re-seeds
c19702ae09beatagen2026-05-27 15:49 UTC9
//! (node ids change across a restart); the control thread reconnects
c19702ae09beatagen2026-05-27 15:49 UTC10
//! lazily with a one-shot retry across a dropped connection.
c19702ae09beatagen2026-05-27 15:49 UTC11
c19702ae09beatagen2026-05-27 15:49 UTC12
use std::path::{Path, PathBuf};
c19702ae09beatagen2026-05-27 15:49 UTC13
use std::thread;
c19702ae09beatagen2026-05-27 15:49 UTC14
use std::time::Duration;
c19702ae09beatagen2026-05-27 15:49 UTC15
c19702ae09beatagen2026-05-27 15:49 UTC16
use anyhow::{Context as _, Result};
c19702ae09beatagen2026-05-27 15:49 UTC17
use crossbeam_channel::{unbounded, Receiver, Sender};
c19702ae09beatagen2026-05-27 15:49 UTC18
use headroom_client::{Client, ClientError};
c19702ae09beatagen2026-05-27 15:49 UTC19
use headroom_ipc::{Event, LayerASnapshot, ProfileInfo, Route, RouteList, Status, Topic};
c19702ae09beatagen2026-05-27 15:49 UTC20
c19702ae09beatagen2026-05-27 15:49 UTC21
/// topics the monitor subscribes to. shared by the initial connect and
c19702ae09beatagen2026-05-27 15:49 UTC22
/// every reconnect.
c19702ae09beatagen2026-05-27 15:49 UTC23
const TOPICS: [Topic; 4] = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon];
c19702ae09beatagen2026-05-27 15:49 UTC24
c19702ae09beatagen2026-05-27 15:49 UTC25
/// reconnect backoff: first retry after the min, doubling to the cap.
c19702ae09beatagen2026-05-27 15:49 UTC26
const RECONNECT_MIN_DELAY: Duration = Duration::from_millis(500);
c19702ae09beatagen2026-05-27 15:49 UTC27
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(3);
c19702ae09beatagen2026-05-27 15:49 UTC28
c19702ae09beatagen2026-05-27 15:49 UTC29
/// a control op the ui asks the control thread to run; each maps to one
c19702ae09beatagen2026-05-27 15:49 UTC30
/// `Client` request/response call.
c19702ae09beatagen2026-05-27 15:49 UTC31
pub enum ControlCmd {
c19702ae09beatagen2026-05-27 15:49 UTC32
    /// `bypass.set`
c19702ae09beatagen2026-05-27 15:49 UTC33
    SetBypass(bool),
c19702ae09beatagen2026-05-27 15:49 UTC34
    /// `per-app.master`
c19702ae09beatagen2026-05-27 15:49 UTC35
    SetPerAppMaster(bool),
c19702ae09beatagen2026-05-27 15:49 UTC36
    /// `route.stream` — one-shot reroute of a live stream by node id
c19702ae09beatagen2026-05-27 15:49 UTC37
    /// (doesn't survive the stream being torn down).
c19702ae09beatagen2026-05-27 15:49 UTC38
    RouteStream { node_id: u32, to: Route },
c19702ae09beatagen2026-05-27 15:49 UTC39
    /// `route.set` — persistent per-app rule, keyed by app label (matches
c19702ae09beatagen2026-05-27 15:49 UTC40
    /// `application.name` / `process.binary`). survives the app recreating
c19702ae09beatagen2026-05-27 15:49 UTC41
    /// its stream between tracks.
c19702ae09beatagen2026-05-27 15:49 UTC42
    SetRoute { app: String, to: Route },
c19702ae09beatagen2026-05-27 15:49 UTC43
    /// `per-app.set`
c19702ae09beatagen2026-05-27 15:49 UTC44
    SetPerApp { app: String, enabled: bool },
c19702ae09beatagen2026-05-27 15:49 UTC45
    /// `per-app.reset`
c19702ae09beatagen2026-05-27 15:49 UTC46
    ResetLayerA { node_id: u32 },
c19702ae09beatagen2026-05-27 15:49 UTC47
    /// `profile.use`
c19702ae09beatagen2026-05-27 15:49 UTC48
    UseProfile(String),
c19702ae09beatagen2026-05-27 15:49 UTC49
    /// `setting.reset` — clear all overlay setting overrides.
c19702ae09beatagen2026-05-27 15:49 UTC50
    ClearOverrides,
c19702ae09beatagen2026-05-27 15:49 UTC51
    /// `per-app.list` → [`AppMsg::LayerASnapshots`].
c19702ae09beatagen2026-05-27 15:49 UTC52
    RefreshLayerA,
c19702ae09beatagen2026-05-27 15:49 UTC53
    /// `profile.list` → [`AppMsg::Profiles`].
c19702ae09beatagen2026-05-27 15:49 UTC54
    RefreshProfiles,
c19702ae09beatagen2026-05-27 15:49 UTC55
}
c19702ae09beatagen2026-05-27 15:49 UTC56
c19702ae09beatagen2026-05-27 15:49 UTC57
/// the daemon snapshot the ui seeds (and re-seeds) from; taken at connect
c19702ae09beatagen2026-05-27 15:49 UTC58
/// and again on every reconnect.
c19702ae09beatagen2026-05-27 15:49 UTC59
pub struct Snapshot {
c19702ae09beatagen2026-05-27 15:49 UTC60
    pub status: Status,
c19702ae09beatagen2026-05-27 15:49 UTC61
    pub route_list: RouteList,
c19702ae09beatagen2026-05-27 15:49 UTC62
    /// seeds the switcher.
c19702ae09beatagen2026-05-27 15:49 UTC63
    pub profiles: Vec<ProfileInfo>,
c19702ae09beatagen2026-05-27 15:49 UTC64
}
c19702ae09beatagen2026-05-27 15:49 UTC65
c19702ae09beatagen2026-05-27 15:49 UTC66
/// a message from a worker thread to the ui.
c19702ae09beatagen2026-05-27 15:49 UTC67
pub enum AppMsg {
c19702ae09beatagen2026-05-27 15:49 UTC68
    /// a subscription event off the wire (reader thread).
c19702ae09beatagen2026-05-27 15:49 UTC69
    Event(Event),
c19702ae09beatagen2026-05-27 15:49 UTC70
    /// the event stream ended; carries a reason for the footer banner.
c19702ae09beatagen2026-05-27 15:49 UTC71
    /// the reader starts reconnecting immediately; [`AppMsg::Reconnected`]
c19702ae09beatagen2026-05-27 15:49 UTC72
    /// follows once it succeeds.
c19702ae09beatagen2026-05-27 15:49 UTC73
    Disconnected(String),
c19702ae09beatagen2026-05-27 15:49 UTC74
    /// the reader reconnected after a drop. carries a fresh snapshot so
c19702ae09beatagen2026-05-27 15:49 UTC75
    /// the ui re-seeds (node ids/uptime/profile change across a restart).
c19702ae09beatagen2026-05-27 15:49 UTC76
    /// boxed to keep the enum small.
c19702ae09beatagen2026-05-27 15:49 UTC77
    Reconnected(Box<Snapshot>),
c19702ae09beatagen2026-05-27 15:49 UTC78
    /// a control op failed; surfaced in the footer.
c19702ae09beatagen2026-05-27 15:49 UTC79
    ControlError(String),
c19702ae09beatagen2026-05-27 15:49 UTC80
    /// fresh `profile.list` snapshot.
c19702ae09beatagen2026-05-27 15:49 UTC81
    Profiles(Vec<ProfileInfo>),
c19702ae09beatagen2026-05-27 15:49 UTC82
    /// fresh `per-app.list` snapshot (managed / ceiling / deferred).
c19702ae09beatagen2026-05-27 15:49 UTC83
    LayerASnapshots(Vec<LayerASnapshot>),
c19702ae09beatagen2026-05-27 15:49 UTC84
}
c19702ae09beatagen2026-05-27 15:49 UTC85
c19702ae09beatagen2026-05-27 15:49 UTC86
/// everything `main` needs to seed the window.
c19702ae09beatagen2026-05-27 15:49 UTC87
pub struct Bootstrap {
c19702ae09beatagen2026-05-27 15:49 UTC88
    pub snapshot: Snapshot,
c19702ae09beatagen2026-05-27 15:49 UTC89
    /// live event + worker channel, drained each tick.
c19702ae09beatagen2026-05-27 15:49 UTC90
    pub rx: Receiver<AppMsg>,
c19702ae09beatagen2026-05-27 15:49 UTC91
    /// hands control ops to the control thread.
c19702ae09beatagen2026-05-27 15:49 UTC92
    pub cmd_tx: Sender<ControlCmd>,
c19702ae09beatagen2026-05-27 15:49 UTC93
}
c19702ae09beatagen2026-05-27 15:49 UTC94
c19702ae09beatagen2026-05-27 15:49 UTC95
/// connect, subscribe, fetch the initial snapshot, open a second control
c19702ae09beatagen2026-05-27 15:49 UTC96
/// connection, then spawn the reader + control threads.
c19702ae09beatagen2026-05-27 15:49 UTC97
///
c19702ae09beatagen2026-05-27 15:49 UTC98
/// errors here are returned to the caller (fail fast before any window).
c19702ae09beatagen2026-05-27 15:49 UTC99
/// once the threads run, a stream failure is reported in-band as
c19702ae09beatagen2026-05-27 15:49 UTC100
/// [`AppMsg::Disconnected`] / [`AppMsg::Reconnected`]; control failures
c19702ae09beatagen2026-05-27 15:49 UTC101
/// as [`AppMsg::ControlError`].
c19702ae09beatagen2026-05-27 15:49 UTC102
pub fn start(socket: Option<PathBuf>) -> Result<Bootstrap> {
c19702ae09beatagen2026-05-27 15:49 UTC103
    let mut client = match socket.as_deref() {
c19702ae09beatagen2026-05-27 15:49 UTC104
        Some(p) => Client::connect_at(p)
c19702ae09beatagen2026-05-27 15:49 UTC105
            .with_context(|| format!("connecting to headroom daemon at {}", p.display()))?,
c19702ae09beatagen2026-05-27 15:49 UTC106
        None => Client::connect().context("connecting to headroom daemon (is it running?)")?,
c19702ae09beatagen2026-05-27 15:49 UTC107
    };
c19702ae09beatagen2026-05-27 15:49 UTC108
c19702ae09beatagen2026-05-27 15:49 UTC109
    // subscribe + initial snapshot before the client moves to the reader.
c19702ae09beatagen2026-05-27 15:49 UTC110
    // a dead/half-broken daemon surfaces here.
c19702ae09beatagen2026-05-27 15:49 UTC111
    let snapshot = fetch_snapshot(&mut client).context("initial daemon fetch")?;
c19702ae09beatagen2026-05-27 15:49 UTC112
c19702ae09beatagen2026-05-27 15:49 UTC113
    // the resolved socket path drives reconnects on both worker threads.
c19702ae09beatagen2026-05-27 15:49 UTC114
    let socket_path = client.socket_path().to_path_buf();
c19702ae09beatagen2026-05-27 15:49 UTC115
c19702ae09beatagen2026-05-27 15:49 UTC116
    // second connection for control, on the same resolved socket.
c19702ae09beatagen2026-05-27 15:49 UTC117
    let control = Client::connect_at(&socket_path).context("opening control connection")?;
c19702ae09beatagen2026-05-27 15:49 UTC118
c19702ae09beatagen2026-05-27 15:49 UTC119
    let (tx, rx) = unbounded::<AppMsg>();
c19702ae09beatagen2026-05-27 15:49 UTC120
    let (cmd_tx, cmd_rx) = unbounded::<ControlCmd>();
c19702ae09beatagen2026-05-27 15:49 UTC121
c19702ae09beatagen2026-05-27 15:49 UTC122
    let reader_tx = tx.clone();
c19702ae09beatagen2026-05-27 15:49 UTC123
    let reader_socket = socket_path.clone();
c19702ae09beatagen2026-05-27 15:49 UTC124
    thread::Builder::new()
c19702ae09beatagen2026-05-27 15:49 UTC125
        .name("headroom-gui-rx".into())
c19702ae09beatagen2026-05-27 15:49 UTC126
        .spawn(move || reader_loop(reader_socket, client, reader_tx))
c19702ae09beatagen2026-05-27 15:49 UTC127
        .context("spawning reader thread")?;
c19702ae09beatagen2026-05-27 15:49 UTC128
c19702ae09beatagen2026-05-27 15:49 UTC129
    thread::Builder::new()
c19702ae09beatagen2026-05-27 15:49 UTC130
        .name("headroom-gui-ctl".into())
c19702ae09beatagen2026-05-27 15:49 UTC131
        .spawn(move || control_loop(socket_path, control, cmd_rx, tx))
c19702ae09beatagen2026-05-27 15:49 UTC132
        .context("spawning control thread")?;
c19702ae09beatagen2026-05-27 15:49 UTC133
c19702ae09beatagen2026-05-27 15:49 UTC134
    Ok(Bootstrap {
c19702ae09beatagen2026-05-27 15:49 UTC135
        snapshot,
c19702ae09beatagen2026-05-27 15:49 UTC136
        rx,
c19702ae09beatagen2026-05-27 15:49 UTC137
        cmd_tx,
c19702ae09beatagen2026-05-27 15:49 UTC138
    })
c19702ae09beatagen2026-05-27 15:49 UTC139
}
c19702ae09beatagen2026-05-27 15:49 UTC140
c19702ae09beatagen2026-05-27 15:49 UTC141
/// subscribe to [`TOPICS`] and pull `status` + `route.list` +
c19702ae09beatagen2026-05-27 15:49 UTC142
/// `profile.list`. the `Client` correlates rpc responses while a
c19702ae09beatagen2026-05-27 15:49 UTC143
/// subscription is active (buffers interleaved events), so doing this on
c19702ae09beatagen2026-05-27 15:49 UTC144
/// the event connection is fine — one path seeds connect + every reconnect.
c19702ae09beatagen2026-05-27 15:49 UTC145
fn fetch_snapshot(client: &mut Client) -> Result<Snapshot, ClientError> {
c19702ae09beatagen2026-05-27 15:49 UTC146
    client.subscribe(&TOPICS)?;
c19702ae09beatagen2026-05-27 15:49 UTC147
    let status = client.status()?;
c19702ae09beatagen2026-05-27 15:49 UTC148
    let route_list = client.route_list()?;
c19702ae09beatagen2026-05-27 15:49 UTC149
    let profiles = client.profile_list()?;
c19702ae09beatagen2026-05-27 15:49 UTC150
    Ok(Snapshot {
c19702ae09beatagen2026-05-27 15:49 UTC151
        status,
c19702ae09beatagen2026-05-27 15:49 UTC152
        route_list,
c19702ae09beatagen2026-05-27 15:49 UTC153
        profiles,
c19702ae09beatagen2026-05-27 15:49 UTC154
    })
c19702ae09beatagen2026-05-27 15:49 UTC155
}
c19702ae09beatagen2026-05-27 15:49 UTC156
c19702ae09beatagen2026-05-27 15:49 UTC157
/// forward each event to the ui. on stream failure, announce the drop,
c19702ae09beatagen2026-05-27 15:49 UTC158
/// reconnect (with backoff), and resume — returning only when the ui
c19702ae09beatagen2026-05-27 15:49 UTC159
/// channel closes (window gone).
c19702ae09beatagen2026-05-27 15:49 UTC160
fn reader_loop(socket: PathBuf, mut client: Client, tx: Sender<AppMsg>) {
c19702ae09beatagen2026-05-27 15:49 UTC161
    loop {
c19702ae09beatagen2026-05-27 15:49 UTC162
        match client.next_event() {
c19702ae09beatagen2026-05-27 15:49 UTC163
            Ok(ev) => {
c19702ae09beatagen2026-05-27 15:49 UTC164
                if tx.send(AppMsg::Event(ev)).is_err() {
c19702ae09beatagen2026-05-27 15:49 UTC165
                    return; // ui gone.
c19702ae09beatagen2026-05-27 15:49 UTC166
                }
c19702ae09beatagen2026-05-27 15:49 UTC167
            }
c19702ae09beatagen2026-05-27 15:49 UTC168
            Err(e) => {
c19702ae09beatagen2026-05-27 15:49 UTC169
                tracing::warn!(error = %e, "event stream lost; reconnecting to daemon");
c19702ae09beatagen2026-05-27 15:49 UTC170
                if tx.send(AppMsg::Disconnected(e.to_string())).is_err() {
c19702ae09beatagen2026-05-27 15:49 UTC171
                    return;
c19702ae09beatagen2026-05-27 15:49 UTC172
                }
c19702ae09beatagen2026-05-27 15:49 UTC173
                match reconnect_event_client(&socket, &tx) {
c19702ae09beatagen2026-05-27 15:49 UTC174
                    Some(c) => {
c19702ae09beatagen2026-05-27 15:49 UTC175
                        tracing::info!("reconnected to daemon; re-seeded UI snapshot");
c19702ae09beatagen2026-05-27 15:49 UTC176
                        client = c;
c19702ae09beatagen2026-05-27 15:49 UTC177
                    }
c19702ae09beatagen2026-05-27 15:49 UTC178
                    None => return, // ui gone while reconnecting.
c19702ae09beatagen2026-05-27 15:49 UTC179
                }
c19702ae09beatagen2026-05-27 15:49 UTC180
            }
c19702ae09beatagen2026-05-27 15:49 UTC181
        }
c19702ae09beatagen2026-05-27 15:49 UTC182
    }
c19702ae09beatagen2026-05-27 15:49 UTC183
}
c19702ae09beatagen2026-05-27 15:49 UTC184
c19702ae09beatagen2026-05-27 15:49 UTC185
/// reconnect the event stream after a drop. retries `connect_at` with
c19702ae09beatagen2026-05-27 15:49 UTC186
/// capped backoff until the daemon answers with a fresh snapshot, then
c19702ae09beatagen2026-05-27 15:49 UTC187
/// sends [`AppMsg::Reconnected`] and returns the live client. a daemon
c19702ae09beatagen2026-05-27 15:49 UTC188
/// that's up but can't complete the snapshot (half-open / mid-restart)
c19702ae09beatagen2026-05-27 15:49 UTC189
/// counts as a failed attempt. returns `None` only if the ui closed.
c19702ae09beatagen2026-05-27 15:49 UTC190
fn reconnect_event_client(socket: &Path, tx: &Sender<AppMsg>) -> Option<Client> {
c19702ae09beatagen2026-05-27 15:49 UTC191
    let mut delay = RECONNECT_MIN_DELAY;
c19702ae09beatagen2026-05-27 15:49 UTC192
    loop {
c19702ae09beatagen2026-05-27 15:49 UTC193
        if let Ok(mut client) = Client::connect_at(socket) {
c19702ae09beatagen2026-05-27 15:49 UTC194
            if let Ok(snapshot) = fetch_snapshot(&mut client) {
c19702ae09beatagen2026-05-27 15:49 UTC195
                if tx.send(AppMsg::Reconnected(Box::new(snapshot))).is_err() {
c19702ae09beatagen2026-05-27 15:49 UTC196
                    return None;
c19702ae09beatagen2026-05-27 15:49 UTC197
                }
c19702ae09beatagen2026-05-27 15:49 UTC198
                return Some(client);
c19702ae09beatagen2026-05-27 15:49 UTC199
            }
c19702ae09beatagen2026-05-27 15:49 UTC200
        }
c19702ae09beatagen2026-05-27 15:49 UTC201
        thread::sleep(delay);
c19702ae09beatagen2026-05-27 15:49 UTC202
        delay = (delay * 2).min(RECONNECT_MAX_DELAY);
c19702ae09beatagen2026-05-27 15:49 UTC203
    }
c19702ae09beatagen2026-05-27 15:49 UTC204
}
c19702ae09beatagen2026-05-27 15:49 UTC205
c19702ae09beatagen2026-05-27 15:49 UTC206
/// block on the command channel, dispatching each [`ControlCmd`] to its
c19702ae09beatagen2026-05-27 15:49 UTC207
/// `Client` call. refresh ops reply with a snapshot `AppMsg`; failures
c19702ae09beatagen2026-05-27 15:49 UTC208
/// become [`AppMsg::ControlError`]. returns when either channel closes.
c19702ae09beatagen2026-05-27 15:49 UTC209
///
c19702ae09beatagen2026-05-27 15:49 UTC210
/// the control connection reconnects lazily: if torn down (daemon
c19702ae09beatagen2026-05-27 15:49 UTC211
/// restart) the next command opens a fresh one, and a command that fails
c19702ae09beatagen2026-05-27 15:49 UTC212
/// mid-flight on a connection error is retried once before being reported.
c19702ae09beatagen2026-05-27 15:49 UTC213
fn control_loop(socket: PathBuf, initial: Client, cmd_rx: Receiver<ControlCmd>, tx: Sender<AppMsg>) {
c19702ae09beatagen2026-05-27 15:49 UTC214
    let mut client: Option<Client> = Some(initial);
c19702ae09beatagen2026-05-27 15:49 UTC215
    while let Ok(cmd) = cmd_rx.recv() {
c19702ae09beatagen2026-05-27 15:49 UTC216
        // reconnect if a prior op tore the connection down.
c19702ae09beatagen2026-05-27 15:49 UTC217
        if client.is_none() {
c19702ae09beatagen2026-05-27 15:49 UTC218
            client = Client::connect_at(&socket).ok();
c19702ae09beatagen2026-05-27 15:49 UTC219
        }
c19702ae09beatagen2026-05-27 15:49 UTC220
        let Some(c) = client.as_mut() else {
c19702ae09beatagen2026-05-27 15:49 UTC221
            if tx
c19702ae09beatagen2026-05-27 15:49 UTC222
                .send(AppMsg::ControlError("daemon unreachable".into()))
c19702ae09beatagen2026-05-27 15:49 UTC223
                .is_err()
c19702ae09beatagen2026-05-27 15:49 UTC224
            {
c19702ae09beatagen2026-05-27 15:49 UTC225
                return;
c19702ae09beatagen2026-05-27 15:49 UTC226
            }
c19702ae09beatagen2026-05-27 15:49 UTC227
            continue;
c19702ae09beatagen2026-05-27 15:49 UTC228
        };
c19702ae09beatagen2026-05-27 15:49 UTC229
c19702ae09beatagen2026-05-27 15:49 UTC230
        let mut result = run_cmd(c, &cmd);
c19702ae09beatagen2026-05-27 15:49 UTC231
        // one retry across a dropped connection — the daemon may have
c19702ae09beatagen2026-05-27 15:49 UTC232
        // restarted since the last op left the socket stale.
c19702ae09beatagen2026-05-27 15:49 UTC233
        if matches!(&result, Err(e) if is_connection_error(e)) {
c19702ae09beatagen2026-05-27 15:49 UTC234
            client = Client::connect_at(&socket).ok();
c19702ae09beatagen2026-05-27 15:49 UTC235
            if let Some(c) = client.as_mut() {
c19702ae09beatagen2026-05-27 15:49 UTC236
                result = run_cmd(c, &cmd);
c19702ae09beatagen2026-05-27 15:49 UTC237
            }
c19702ae09beatagen2026-05-27 15:49 UTC238
        }
c19702ae09beatagen2026-05-27 15:49 UTC239
c19702ae09beatagen2026-05-27 15:49 UTC240
        let msg = match result {
c19702ae09beatagen2026-05-27 15:49 UTC241
            Ok(Some(reply)) => reply,
c19702ae09beatagen2026-05-27 15:49 UTC242
            Ok(None) => continue,
c19702ae09beatagen2026-05-27 15:49 UTC243
            Err(e) => {
c19702ae09beatagen2026-05-27 15:49 UTC244
                // drop a dead connection so the next command reconnects.
c19702ae09beatagen2026-05-27 15:49 UTC245
                if is_connection_error(&e) {
c19702ae09beatagen2026-05-27 15:49 UTC246
                    client = None;
c19702ae09beatagen2026-05-27 15:49 UTC247
                }
c19702ae09beatagen2026-05-27 15:49 UTC248
                AppMsg::ControlError(e.to_string())
c19702ae09beatagen2026-05-27 15:49 UTC249
            }
c19702ae09beatagen2026-05-27 15:49 UTC250
        };
c19702ae09beatagen2026-05-27 15:49 UTC251
        if tx.send(msg).is_err() {
c19702ae09beatagen2026-05-27 15:49 UTC252
            return;
c19702ae09beatagen2026-05-27 15:49 UTC253
        }
c19702ae09beatagen2026-05-27 15:49 UTC254
    }
c19702ae09beatagen2026-05-27 15:49 UTC255
}
c19702ae09beatagen2026-05-27 15:49 UTC256
c19702ae09beatagen2026-05-27 15:49 UTC257
/// run one [`ControlCmd`] against `client`. borrows the command so the
c19702ae09beatagen2026-05-27 15:49 UTC258
/// caller can retry on a fresh connection. `Ok(Some(_))` for refreshes
c19702ae09beatagen2026-05-27 15:49 UTC259
/// that reply, `Ok(None)` for fire-and-forget toggles.
c19702ae09beatagen2026-05-27 15:49 UTC260
fn run_cmd(client: &mut Client, cmd: &ControlCmd) -> Result<Option<AppMsg>, ClientError> {
c19702ae09beatagen2026-05-27 15:49 UTC261
    match cmd {
c19702ae09beatagen2026-05-27 15:49 UTC262
        ControlCmd::SetBypass(enabled) => client.bypass_set(*enabled).map(|()| None),
c19702ae09beatagen2026-05-27 15:49 UTC263
        ControlCmd::SetPerAppMaster(enabled) => client.per_app_master(*enabled).map(|()| None),
c19702ae09beatagen2026-05-27 15:49 UTC264
        ControlCmd::RouteStream { node_id, to } => {
c19702ae09beatagen2026-05-27 15:49 UTC265
            client.route_stream(*node_id, *to).map(|()| None)
c19702ae09beatagen2026-05-27 15:49 UTC266
        }
c19702ae09beatagen2026-05-27 15:49 UTC267
        ControlCmd::SetRoute { app, to } => client.route_set(app, *to).map(|()| None),
c19702ae09beatagen2026-05-27 15:49 UTC268
        ControlCmd::SetPerApp { app, enabled } => client.per_app_set(app, *enabled).map(|()| None),
c19702ae09beatagen2026-05-27 15:49 UTC269
        ControlCmd::ResetLayerA { node_id } => client.layer_a_reset(*node_id).map(|()| None),
c19702ae09beatagen2026-05-27 15:49 UTC270
        ControlCmd::UseProfile(name) => client.profile_use(name).map(|_| None),
c19702ae09beatagen2026-05-27 15:49 UTC271
        ControlCmd::ClearOverrides => client.setting_reset().map(|_| None),
c19702ae09beatagen2026-05-27 15:49 UTC272
        ControlCmd::RefreshLayerA => client.layer_a_list().map(|l| Some(AppMsg::LayerASnapshots(l))),
c19702ae09beatagen2026-05-27 15:49 UTC273
        ControlCmd::RefreshProfiles => client.profile_list().map(|p| Some(AppMsg::Profiles(p))),
c19702ae09beatagen2026-05-27 15:49 UTC274
    }
c19702ae09beatagen2026-05-27 15:49 UTC275
}
c19702ae09beatagen2026-05-27 15:49 UTC276
c19702ae09beatagen2026-05-27 15:49 UTC277
/// whether an error means the socket is gone (vs a server-side rejection
c19702ae09beatagen2026-05-27 15:49 UTC278
/// of a delivered op). only the former warrants a reconnect; a `Protocol`
c19702ae09beatagen2026-05-27 15:49 UTC279
/// error is a logical failure to surface as-is.
c19702ae09beatagen2026-05-27 15:49 UTC280
fn is_connection_error(e: &ClientError) -> bool {
c19702ae09beatagen2026-05-27 15:49 UTC281
    matches!(e, ClientError::Ipc(_))
c19702ae09beatagen2026-05-27 15:49 UTC282
}