1use 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
19const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
23
24#[derive(Deserialize)]
27struct Request {
28 cmd: String,
29}
30
31#[derive(Debug)]
38pub struct ControlServer {
39 path: PathBuf,
40 shutdown: Arc<AtomicBool>,
41 thread: Option<std::thread::JoinHandle<()>>,
42}
43
44impl ControlServer {
45 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 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
64pub 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
83pub 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
125fn 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 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
171fn 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 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 #[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 #[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 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 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 #[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 #[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 #[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}