atagen

headroom

public
Star0
Watch0
c454b10854660e0f55220e43b21a60913b505716
1
ssh://brewery@pls.lobotomise.me:33/atagen/headroom.git
crates/headroom-gui/src/io.rsRust282 lines11.2 KB
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
}