Skip to main content

procctl/
serve.rs

1//! The producer half: bind the control socket, answer `status`.
2//!
3//! std-only and thread-based on purpose. The motivating adopter is a winit GUI
4//! with no async runtime at all (noisetable's `cargo run -p dev`), and asking a
5//! workload to grow a tokio runtime to answer one JSON line would make the
6//! channel cost more than the log-grepping it replaces.
7
8use std::io::{BufRead, BufReader, Write};
9use std::os::unix::net::{UnixListener, UnixStream};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use serde::Deserialize;
16
17use crate::{clear_stale_socket, control_sock_path, ProcStatus, STATUS_CMD};
18
19/// A connected client that never sends its request line holds the listener
20/// thread. Bounded so a stuck peer costs one request's latency, not the
21/// channel.
22const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
23
24/// The request line. Only `cmd` is defined; unknown fields are ignored so the
25/// verb set can grow without breaking older producers.
26#[derive(Deserialize)]
27struct Request {
28    cmd: String,
29}
30
31/// A live control channel. Answers `status` until dropped.
32///
33/// Dropping it stops the listener thread and unlinks the socket file, so the
34/// next run of the same process binds cleanly. Leaking it (`std::mem::forget`,
35/// or a `let _ = ` binding that drops immediately — use `let _guard =`) leaves
36/// a stale socket file behind, which the next [`serve_at`] will clear.
37#[derive(Debug)]
38pub struct ControlServer {
39    path: PathBuf,
40    shutdown: Arc<AtomicBool>,
41    thread: Option<std::thread::JoinHandle<()>>,
42}
43
44impl ControlServer {
45    /// The socket this server is bound to.
46    pub fn path(&self) -> &Path {
47        &self.path
48    }
49}
50
51impl Drop for ControlServer {
52    fn drop(&mut self) {
53        self.shutdown.store(true, Ordering::Release);
54        // The listener thread is parked in a blocking `accept()`. Connecting to
55        // ourselves is what wakes it; it then sees the flag and returns.
56        let _ = UnixStream::connect(&self.path);
57        if let Some(t) = self.thread.take() {
58            let _ = t.join();
59        }
60        let _ = std::fs::remove_file(&self.path);
61    }
62}
63
64/// Bind `$YAH_CONTROL_SOCK` and answer `status` from `status_fn`.
65///
66/// `Ok(None)` when the variable is unset or empty — the process is not running
67/// under a supervisor that wants a channel, which is not an error. That is the
68/// contract that lets the same binary run unchanged outside a camp.
69///
70/// The closure runs on the listener thread, once per request. Keep it cheap and
71/// keep it panic-free: a panic there takes the channel down, which the
72/// supervisor reads as a process that stopped answering.
73pub fn serve_env<F>(status_fn: F) -> std::io::Result<Option<ControlServer>>
74where
75    F: Fn() -> ProcStatus + Send + 'static,
76{
77    match control_sock_path() {
78        Some(path) => serve_at(path, status_fn).map(Some),
79        None => Ok(None),
80    }
81}
82
83/// Bind an explicit path and answer `status` from `status_fn`.
84///
85/// Creates the parent directory if needed, and clears a *dead* socket file left
86/// by a predecessor (a live one is refused rather than unlinked — see
87/// [`crate::clear_stale_socket`]).
88pub fn serve_at<F>(path: impl Into<PathBuf>, status_fn: F) -> std::io::Result<ControlServer>
89where
90    F: Fn() -> ProcStatus + Send + 'static,
91{
92    let path = path.into();
93    if let Some(parent) = path.parent() {
94        std::fs::create_dir_all(parent)?;
95    }
96    clear_stale_socket(&path)?;
97    let listener = UnixListener::bind(&path)?;
98
99    let shutdown = Arc::new(AtomicBool::new(false));
100    let started = Instant::now();
101    let pid = std::process::id();
102
103    let thread = {
104        let shutdown = shutdown.clone();
105        std::thread::Builder::new()
106            .name("procctl-control".into())
107            .spawn(move || {
108                for stream in listener.incoming() {
109                    if shutdown.load(Ordering::Acquire) {
110                        return;
111                    }
112                    let Ok(stream) = stream else { continue };
113                    serve_connection(stream, &status_fn, pid, started);
114                }
115            })?
116    };
117
118    Ok(ControlServer {
119        path,
120        shutdown,
121        thread: Some(thread),
122    })
123}
124
125/// One connection: read request lines, answer each with one document line.
126///
127/// Multiple requests per connection are supported even though the reference
128/// client opens a fresh connection per poll — reading to EOF is the same three
129/// lines either way, and it makes an interactive `nc` session work.
130fn serve_connection<F>(stream: UnixStream, status_fn: &F, pid: u32, started: Instant)
131where
132    F: Fn() -> ProcStatus,
133{
134    let _ = stream.set_read_timeout(Some(REQUEST_TIMEOUT));
135    let _ = stream.set_write_timeout(Some(REQUEST_TIMEOUT));
136    let Ok(mut out) = stream.try_clone() else {
137        return;
138    };
139    let mut lines = BufReader::new(stream).lines();
140    while let Some(Ok(line)) = lines.next() {
141        if line.trim().is_empty() {
142            continue;
143        }
144        let reply = match serde_json::from_str::<Request>(&line) {
145            Ok(req) if req.cmd == STATUS_CMD => {
146                let mut status = status_fn();
147                // Stamp what the helper knows for certain, when the producer
148                // did not answer it itself.
149                status.pid.get_or_insert(pid);
150                status
151                    .uptime_secs
152                    .get_or_insert_with(|| started.elapsed().as_secs());
153                serde_json::to_string(&status)
154                    .unwrap_or_else(|e| error_line(&format!("status not serializable: {e}")))
155            }
156            Ok(req) => error_line(&format!(
157                "unknown command {:?}; this process implements only {STATUS_CMD:?}",
158                req.cmd
159            )),
160            Err(e) => error_line(&format!("unparseable request: {e}")),
161        };
162        if out.write_all(reply.as_bytes()).is_err()
163            || out.write_all(b"\n").is_err()
164            || out.flush().is_err()
165        {
166            return;
167        }
168    }
169}
170
171/// An error reply is still one JSON line. A consumer parsing for `state` fails
172/// to find it and treats the endpoint as unreachable, which is the right read:
173/// this process did not tell it anything about its state.
174fn error_line(message: &str) -> String {
175    serde_json::json!({ "error": message }).to_string()
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::{ProcState, CONTROL_SOCK_ENV};
182
183    /// The consumer side of one poll, spelled out rather than imported — this
184    /// is also the twenty-line proof that the protocol needs no library.
185    fn ask(path: &Path, line: &str) -> String {
186        let mut stream = UnixStream::connect(path).unwrap();
187        stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
188        stream.write_all(line.as_bytes()).unwrap();
189        stream.write_all(b"\n").unwrap();
190        stream.flush().unwrap();
191        let mut reply = String::new();
192        BufReader::new(stream).read_line(&mut reply).unwrap();
193        reply.trim().to_string()
194    }
195
196    fn ask_status(path: &Path) -> ProcStatus {
197        serde_json::from_str(&ask(path, r#"{"cmd":"status"}"#)).unwrap()
198    }
199
200    #[test]
201    fn a_two_line_producer_answers_status() {
202        let tmp = tempfile::tempdir().unwrap();
203        let sock = tmp.path().join("control.sock");
204        let server = serve_at(&sock, || {
205            ProcStatus::new(ProcState::Running).with_detail("3 windows open")
206        })
207        .unwrap();
208
209        let got = ask_status(server.path());
210        assert_eq!(got.state, ProcState::Running);
211        assert_eq!(got.detail.as_deref(), Some("3 windows open"));
212    }
213
214    /// The helper knows its own pid and start time; a producer that answers
215    /// only `state` still gets a document a supervisor can use.
216    #[test]
217    fn pid_and_uptime_are_stamped_when_the_producer_omits_them() {
218        let tmp = tempfile::tempdir().unwrap();
219        let sock = tmp.path().join("control.sock");
220        let server = serve_at(&sock, || ProcStatus::new(ProcState::Starting)).unwrap();
221
222        let got = ask_status(server.path());
223        assert_eq!(got.pid, Some(std::process::id()));
224        assert!(got.uptime_secs.is_some());
225    }
226
227    #[test]
228    fn a_producer_supplied_pid_is_not_overwritten() {
229        let tmp = tempfile::tempdir().unwrap();
230        let sock = tmp.path().join("control.sock");
231        let server = serve_at(&sock, || {
232            ProcStatus::new(ProcState::Running).with_pid(4242)
233        })
234        .unwrap();
235
236        assert_eq!(ask_status(server.path()).pid, Some(4242));
237    }
238
239    /// The closure is re-run per request — that is what makes the channel a
240    /// live signal rather than a snapshot taken at bind time.
241    #[test]
242    fn the_status_closure_runs_once_per_request() {
243        use std::sync::atomic::AtomicUsize;
244        let tmp = tempfile::tempdir().unwrap();
245        let sock = tmp.path().join("control.sock");
246        let calls = Arc::new(AtomicUsize::new(0));
247        let server = {
248            let calls = calls.clone();
249            serve_at(&sock, move || {
250                // starting, starting, then running — the shape the readiness
251                // ladder exists to observe.
252                let n = calls.fetch_add(1, Ordering::SeqCst);
253                ProcStatus::new(if n < 2 {
254                    ProcState::Starting
255                } else {
256                    ProcState::Running
257                })
258            })
259            .unwrap()
260        };
261
262        assert_eq!(ask_status(server.path()).state, ProcState::Starting);
263        assert_eq!(ask_status(server.path()).state, ProcState::Starting);
264        assert_eq!(ask_status(server.path()).state, ProcState::Running);
265        assert_eq!(calls.load(Ordering::SeqCst), 3);
266    }
267
268    #[test]
269    fn several_requests_share_one_connection() {
270        let tmp = tempfile::tempdir().unwrap();
271        let sock = tmp.path().join("control.sock");
272        let server = serve_at(&sock, || ProcStatus::new(ProcState::Running)).unwrap();
273
274        let mut stream = UnixStream::connect(server.path()).unwrap();
275        stream.write_all(b"{\"cmd\":\"status\"}\n{\"cmd\":\"status\"}\n").unwrap();
276        stream.flush().unwrap();
277        let mut reader = BufReader::new(stream);
278        for _ in 0..2 {
279            let mut line = String::new();
280            reader.read_line(&mut line).unwrap();
281            let s: ProcStatus = serde_json::from_str(line.trim()).unwrap();
282            assert_eq!(s.state, ProcState::Running);
283        }
284    }
285
286    #[test]
287    fn an_unknown_verb_is_refused_without_killing_the_channel() {
288        let tmp = tempfile::tempdir().unwrap();
289        let sock = tmp.path().join("control.sock");
290        let server = serve_at(&sock, || ProcStatus::new(ProcState::Running)).unwrap();
291
292        let reply = ask(server.path(), r#"{"cmd":"restart"}"#);
293        assert!(reply.contains("unknown command"), "{reply}");
294        assert!(
295            serde_json::from_str::<ProcStatus>(&reply).is_err(),
296            "an error reply must not parse as a status document"
297        );
298        // Still serving.
299        assert_eq!(ask_status(server.path()).state, ProcState::Running);
300    }
301
302    #[test]
303    fn garbage_is_refused_without_killing_the_channel() {
304        let tmp = tempfile::tempdir().unwrap();
305        let sock = tmp.path().join("control.sock");
306        let server = serve_at(&sock, || ProcStatus::new(ProcState::Running)).unwrap();
307
308        assert!(ask(server.path(), "not json at all").contains("unparseable"));
309        assert_eq!(ask_status(server.path()).state, ProcState::Running);
310    }
311
312    #[test]
313    fn dropping_the_server_unlinks_the_socket() {
314        let tmp = tempfile::tempdir().unwrap();
315        let sock = tmp.path().join("control.sock");
316        let server = serve_at(&sock, || ProcStatus::new(ProcState::Running)).unwrap();
317        assert!(sock.exists());
318        drop(server);
319        assert!(!sock.exists(), "a dropped server must leave no socket file");
320    }
321
322    /// A process restarting in the same workspace finds its predecessor's
323    /// socket file. Binding on top of it fails with EADDRINUSE even though
324    /// nothing is listening, so the helper has to clear it.
325    #[test]
326    fn a_stale_socket_file_from_a_dead_predecessor_is_reclaimed() {
327        let tmp = tempfile::tempdir().unwrap();
328        let sock = tmp.path().join("control.sock");
329        {
330            let _dead = UnixListener::bind(&sock).unwrap();
331        }
332        let server = serve_at(&sock, || ProcStatus::new(ProcState::Running)).unwrap();
333        assert_eq!(ask_status(server.path()).state, ProcState::Running);
334    }
335
336    /// Two live processes on one path is a misconfiguration, and the second one
337    /// silently stealing the socket would make the first invisible to its
338    /// supervisor. Refuse instead.
339    #[test]
340    fn a_live_predecessor_is_refused_rather_than_stolen() {
341        let tmp = tempfile::tempdir().unwrap();
342        let sock = tmp.path().join("control.sock");
343        let first = serve_at(&sock, || ProcStatus::new(ProcState::Running)).unwrap();
344        let err = serve_at(&sock, || ProcStatus::new(ProcState::Failed)).unwrap_err();
345        assert_eq!(err.kind(), std::io::ErrorKind::AddrInUse);
346        assert_eq!(ask_status(first.path()).state, ProcState::Running);
347    }
348
349    #[test]
350    fn the_parent_directory_is_created() {
351        let tmp = tempfile::tempdir().unwrap();
352        let sock = tmp.path().join("jit/native/noisetable/control.sock");
353        let server = serve_at(&sock, || ProcStatus::new(ProcState::Running)).unwrap();
354        assert!(sock.exists());
355        assert_eq!(ask_status(server.path()).state, ProcState::Running);
356    }
357
358    /// Outside a camp there is no supervisor asking, and a workload must not
359    /// fail for the variable's absence.
360    #[test]
361    fn serve_env_declines_quietly_when_the_variable_is_unset() {
362        let prev = std::env::var_os(CONTROL_SOCK_ENV);
363        std::env::remove_var(CONTROL_SOCK_ENV);
364        let got = serve_env(|| ProcStatus::new(ProcState::Running)).unwrap();
365        if let Some(v) = prev {
366            std::env::set_var(CONTROL_SOCK_ENV, v);
367        }
368        assert!(got.is_none(), "no variable, no channel, no error");
369    }
370}