Skip to main content

scx_mlfq/
webui.rs

1// SPDX-License-Identifier: GPL-2.0
2//
3// Copyright (c) 2026 Galih Tama <galpt@v.recipes>
4//
5// This software may be used and distributed according to the terms of the GNU
6// General Public License version 2.
7
8//! Loopback web UI: live scheduler metrics as a small HTTP server.
9//!
10//! The server binds `[::1]:50005` first and falls back to
11//! `127.0.0.1:50005`. When both TCP binds fail (for example when the
12//! loader sandbox denies inet sockets), the same routes are served over
13//! the unix socket `/tmp/scx_mlfq.sock` with a minimal hand-rolled
14//! HTTP/1.1 responder. The socket is created mode 0600, so only root
15//! can connect to it (socat needs sudo), matching the loopback trust
16//! boundary of the TCP path. There is no authentication: the loopback
17//! address is the trust boundary, and the counters are already
18//! world-readable through the stats server. `--no-webui` disables the
19//! thread entirely (see main.rs), so no bind is attempted.
20//!
21//! The loader's network sandbox is seccomp-based: the restriction is a
22//! per-process filter inherited by scheduler children, so a running
23//! scheduler cannot lift its own. When both TCP binds fail with a
24//! seccomp-style errno, this thread therefore writes the scheduler's own
25//! runtime drop-in under `/run/systemd/system` so the *next* loader
26//! start lifts the sandbox for the web UI (the current run serves the
27//! unix socket), and `main` removes the drop-in again on exit. The
28//! lifecycle is implemented in `try_unblock_loader_sandbox`,
29//! `restore_loader_sandbox` and the pure classification helpers below.
30//!
31//! The metrics pipeline is push-based: the run loop sends one `WebMetrics`
32//! snapshot per iteration over a small bounded channel (capacity 16).
33//! `try_send` drops a frame when the buffer is full, instead of
34//! stalling the
35//! scheduler or growing the buffer), and this thread keeps the newest
36//! snapshot behind a mutex for the HTTP handlers. The thread exits when
37//! the shared shutdown flag is set.
38
39use std::io::{BufRead, BufReader, Write};
40use std::os::unix::fs::FileTypeExt;
41use std::os::unix::net::UnixListener;
42use std::sync::atomic::{AtomicBool, Ordering};
43use std::sync::{Arc, Mutex};
44use std::time::Duration;
45
46use serde_json::json;
47use tiny_http::{Header, Response, Server};
48
49use crate::stats::WebMetrics;
50
51const PORT: u16 = 50005;
52const UNIX_SOCKET_PATH: &str = "/tmp/scx_mlfq.sock";
53const POLL_INTERVAL: Duration = Duration::from_millis(200);
54
55/// systemd's runtime unit directory. The root-owned tree under `/run`
56/// (tmpfs) that PID 1 maintains for the current boot; runtime drop-ins
57/// written below it are picked up by `systemctl daemon-reload` and
58/// disappear on reboot.
59const RUNTIME_SYSTEM_DIR: &str = "/run/systemd/system";
60
61/// Runtime drop-in directory for the loader unit, where the scheduler
62/// writes its own per-boot network-sandbox unblock. This is separate
63/// from the installer's persistent `/etc/systemd/system` drop-in, which
64/// the scheduler never touches.
65const RUNTIME_DROPIN_DIR: &str = "/run/systemd/system/scx_loader.service.d";
66
67/// The runtime drop-in file the scheduler owns for the current boot.
68const RUNTIME_DROPIN: &str = "/run/systemd/system/scx_loader.service.d/mlfq-webui.conf";
69
70/*
71 * Linux errno values used to classify a TCP bind failure. The loader's
72 * seccomp filters surface as EPERM (SocketBindDeny), EAFNOSUPPORT
73 * (RestrictAddressFamilies) or EACCES; a taken port is EADDRINUSE, the
74 * common bind failure that must never trigger the unblock.
75 */
76const EPERM: i32 = 1;
77const EAFNOSUPPORT: i32 = 97;
78const EACCES: i32 = 13;
79
80/// Set once this run actually wrote the runtime drop-in (not merely
81/// attempted it), so the exit path knows a sandbox restore is owed. The
82/// webui thread stores it; `main` reads it after the run loop ends.
83/// SeqCst orders the drop-in write before the main-thread restore
84/// decision regardless of which core each ran on.
85static UNBLOCK_WRITTEN: AtomicBool = AtomicBool::new(false);
86
87/// Latest metrics snapshot, kept behind a mutex for the HTTP handlers.
88/// The snapshot already carries the per-CPU current frequencies,
89/// refreshed in the run loop, so serving never touches sysfs.
90struct WebState {
91    metrics: WebMetrics,
92}
93
94/// Serve one unix-socket client with a minimal HTTP/1.1 response. The
95/// two routes mirror the tiny_http server: `/` serves the embedded HTML
96/// (no-store), `/api/stats` the live metrics JSON, everything else a
97/// 404. A malformed request is dropped silently.
98fn unix_handle_client(
99    mut stream: std::os::unix::net::UnixStream,
100    state: &Arc<Mutex<WebState>>,
101    html: &str,
102) {
103    let clone = match stream.try_clone() {
104        Ok(c) => c,
105        Err(_) => return,
106    };
107    let mut reader = BufReader::new(clone);
108    let mut request_line = String::new();
109    if reader.read_line(&mut request_line).is_err() {
110        return;
111    }
112
113    let parts: Vec<&str> = request_line.split_whitespace().collect();
114    if parts.len() < 2 {
115        return;
116    }
117    let path = parts[1];
118
119    let metrics = {
120        let st = match state.lock() {
121            Ok(s) => s,
122            Err(_) => return,
123        };
124        st.metrics.clone()
125    };
126
127    let (body, content_type) = match path {
128        "/" => (html.as_bytes().to_vec(), "text/html; charset=utf-8"),
129        "/api/stats" => {
130            let stats = serde_json::to_value(&metrics.stats).unwrap_or_default();
131            let per_cpu = serde_json::to_value(&metrics.per_cpu).unwrap_or_default();
132            let merged = json!({
133                "stats": stats,
134                "per_cpu": per_cpu,
135                "queue_runnable": metrics.queue_runnable,
136                "llc_runnable": metrics.llc_runnable,
137                "gpu_submit_total": metrics.gpu_submit_total,
138                "gpu_trace_mask": metrics.gpu_trace_mask,
139            });
140            let j = serde_json::to_string(&merged).unwrap_or_else(|_| "{}".into());
141            (j.into_bytes(), "application/json")
142        }
143        _ => {
144            let _ = write!(
145                stream,
146                "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"
147            );
148            return;
149        }
150    };
151    let len = body.len();
152    let _ = write!(
153        stream,
154        "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
155        content_type, len
156    );
157    let _ = stream.write_all(&body);
158    let _ = stream.flush();
159}
160
161/// The exact bytes the scheduler owns for its runtime (per-boot)
162/// network-sandbox unblock. Mirrors the installer's `/etc` drop-in
163/// shape. An `[Service]` section whose empty assignments reset the
164/// loader's `RestrictAddressFamilies=`/`SocketBindDeny=` for the next
165/// start carries the scheduler's own marker, so the restore path can
166/// prove a file is ours byte-for-byte before removing it.
167fn runtime_dropin_content() -> String {
168    "# scx_mlfq Web UI: runtime unblock of the loader network sandbox.\n\
169     # Owned by the scx_mlfq scheduler; removed on exit.\n\
170     # This run stays on the unix socket; the unblock serves the next start.\n\
171     [Service]\n\
172     RestrictAddressFamilies=\n\
173     SocketBindDeny=\n"
174        .to_string()
175}
176
177/// True when `content` is byte-identical to the scheduler's own runtime
178/// drop-in. Pure, so the ownership decision is unit-tested without a
179/// filesystem.
180fn dropin_matches(content: &str) -> bool {
181    content == runtime_dropin_content()
182}
183
184/// Classify a TCP bind failure. Only a seccomp-style errno means the
185/// loader sandbox is in effect. A taken port (EADDRINUSE) is a plain
186/// "something else owns the port" and must never trigger the runtime
187/// unblock, and an unclassified error is conservatively treated as not
188/// a sandbox failure.
189fn sandbox_failure(err: &std::io::Error) -> bool {
190    matches!(
191        err.raw_os_error(),
192        Some(EPERM) | Some(EAFNOSUPPORT) | Some(EACCES)
193    )
194}
195
196/// Recover the errno-bearing `io::Error` behind the boxed error tiny_http
197/// reports for a failed `Server::http` bind. tiny_http surfaces the
198/// `TcpListener::bind` `io::Error` itself (its `?` boxes it directly), so
199/// the top-level downcast is the real path. The source walk guards
200/// against a future wrapper. `io::Error`'s `source()` skips a custom
201/// payload (the payload is the error, not its cause), so an errno hidden
202/// under a wrapper is still found when the wrapper exposes it through its
203/// own `source()` chain.
204fn boxed_io_error<'a>(
205    err: &'a (dyn std::error::Error + Send + Sync + 'static),
206) -> Option<&'a std::io::Error> {
207    let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(err);
208    while let Some(e) = cur {
209        if let Some(ioe) = e.downcast_ref::<std::io::Error>() {
210            if ioe.raw_os_error().is_some() {
211                return Some(ioe);
212            }
213        }
214        cur = e.source();
215    }
216    None
217}
218
219/// Try to lift the loader's network sandbox for the *next* scheduler
220/// start by writing the scheduler's own runtime drop-in under
221/// `/run/systemd/system`.
222///
223/// The seccomp filter the loader installed is per-process and
224/// inherited: this process cannot lift its own, but systemd loads the
225/// runtime drop-in at the next daemon-reload and unit start, so the
226/// next loader-spawned scheduler gets the TCP dashboard while this run
227/// serves the unix socket. Returns true when the drop-in was actually
228/// written (the exit path then restores it).
229fn try_unblock_loader_sandbox() -> bool {
230    // The systemd runtime tree must already exist: it is root-owned and
231    // maintained by PID 1, so without it no runtime unit manager would
232    // ever load a drop-in written below it. Everything after this point
233    // needs root, so the create/write failures below also serve as the
234    // effective-uid guard for a non-root run.
235    if !std::path::Path::new(RUNTIME_SYSTEM_DIR).is_dir() {
236        log::warn!(
237            "Web UI: {} is not a directory; runtime loader-sandbox unblock skipped",
238            RUNTIME_SYSTEM_DIR
239        );
240        return false;
241    }
242    if let Err(e) = std::fs::create_dir_all(RUNTIME_DROPIN_DIR) {
243        log::warn!(
244            "Web UI: cannot create {}: {e}; runtime loader-sandbox unblock skipped (are we root?)",
245            RUNTIME_DROPIN_DIR
246        );
247        return false;
248    }
249
250    // Atomic write: a temp file in the target directory, chmod 0644,
251    // then rename over the final path. A leftover tmp file (on failure)
252    // is removed; systemd ignores non-.conf files in the drop-in dir
253    // anyway.
254    let content = runtime_dropin_content();
255    let tmp = format!("{RUNTIME_DROPIN}.tmp.{}", std::process::id());
256    if let Err(e) = std::fs::write(&tmp, &content) {
257        log::warn!(
258            "Web UI: cannot write {}: {e}; runtime loader-sandbox unblock skipped",
259            tmp
260        );
261        let _ = std::fs::remove_file(&tmp);
262        return false;
263    }
264    if let Err(e) =
265        std::fs::set_permissions(&tmp, std::os::unix::fs::PermissionsExt::from_mode(0o644))
266    {
267        log::warn!(
268            "Web UI: cannot chmod {}: {e}; runtime loader-sandbox unblock skipped",
269            tmp
270        );
271        let _ = std::fs::remove_file(&tmp);
272        return false;
273    }
274    if let Err(e) = std::fs::rename(&tmp, RUNTIME_DROPIN) {
275        log::warn!(
276            "Web UI: cannot install {}: {e}; runtime loader-sandbox unblock skipped",
277            RUNTIME_DROPIN
278        );
279        let _ = std::fs::remove_file(&tmp);
280        return false;
281    }
282
283    // Best-effort reload: the file itself is the state, and systemd
284    // picks it up at the next daemon-reload or boot even when the
285    // reload below fails. systemctl talks to PID 1 over a unix socket,
286    // which the sandbox keeps available; the absolute path sidesteps a
287    // minimal loader PATH.
288    match std::process::Command::new("/usr/bin/systemctl")
289        .arg("daemon-reload")
290        .status()
291    {
292        Ok(st) if st.success() => {}
293        Ok(st) => log::warn!(
294            "Web UI: systemctl daemon-reload exited with {st}; the runtime unblock applies at the next daemon-reload or boot"
295        ),
296        Err(e) => log::warn!(
297            "Web UI: cannot run systemctl daemon-reload: {e}; the runtime unblock applies at the next daemon-reload or boot"
298        ),
299    }
300
301    log::warn!(
302        "Web UI: wrote {} — the loader network sandbox is lifted for the NEXT scheduler start; this run stays on the unix socket because the seccomp filter cannot be lifted in-place",
303        RUNTIME_DROPIN
304    );
305    true
306}
307
308/// Restore the loader's network sandbox after a run that wrote the
309/// runtime unblock. Called once from `main` after the run loop ends.
310/// Idempotent (safe to call twice).
311///
312/// Only the byte-identical file this process wrote is ever removed: a
313/// foreign or user-edited drop-in is logged and left untouched. `/run`
314/// is tmpfs, so even an exit that skips this restore self-heals at the
315/// next reboot.
316pub fn restore_loader_sandbox() {
317    if !UNBLOCK_WRITTEN.load(Ordering::SeqCst) {
318        return;
319    }
320
321    let content = match std::fs::read_to_string(RUNTIME_DROPIN) {
322        Ok(c) => c,
323        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
324            log::info!(
325                "Web UI: {} is already absent; the loader network restriction is already restored",
326                RUNTIME_DROPIN
327            );
328            return;
329        }
330        Err(e) => {
331            log::warn!(
332                "Web UI: cannot read {}: {e}; leaving it in place",
333                RUNTIME_DROPIN
334            );
335            return;
336        }
337    };
338    if !dropin_matches(&content) {
339        log::warn!(
340            "Web UI: {} differs from the file this run wrote; leaving the foreign edit untouched",
341            RUNTIME_DROPIN
342        );
343        return;
344    }
345    if let Err(e) = std::fs::remove_file(RUNTIME_DROPIN) {
346        log::warn!(
347            "Web UI: cannot remove {}: {e}; the runtime unblock stays in effect",
348            RUNTIME_DROPIN
349        );
350        return;
351    }
352
353    // Best-effort reload, as in the unblock path: the removal is the
354    // state, and systemd applies it at the next daemon-reload or boot.
355    match std::process::Command::new("/usr/bin/systemctl")
356        .arg("daemon-reload")
357        .status()
358    {
359        Ok(st) if st.success() => {}
360        Ok(st) => log::warn!(
361            "Web UI: systemctl daemon-reload exited with {st}; the restore applies at the next daemon-reload or boot"
362        ),
363        Err(e) => log::warn!(
364            "Web UI: cannot run systemctl daemon-reload: {e}; the restore applies at the next daemon-reload or boot"
365        ),
366    }
367
368    log::info!(
369        "Web UI: removed {} — the loader network restriction is restored for the next scheduler start",
370        RUNTIME_DROPIN
371    );
372    UNBLOCK_WRITTEN.store(false, Ordering::SeqCst);
373}
374
375/// Start the web UI thread. Consumes the metrics channel and exits when
376/// the shared shutdown flag is set (or the channel is closed).
377pub fn start(metrics_rx: crossbeam::channel::Receiver<WebMetrics>, shutdown: Arc<AtomicBool>) {
378    log::info!("Web UI thread started");
379
380    let html = include_str!("../ui/index.html").to_string();
381    let state = Arc::new(Mutex::new(WebState {
382        metrics: WebMetrics::default(),
383    }));
384
385    // The metrics consumer: keep the newest snapshot behind the mutex.
386    // A timeout keeps the loop parked for at most POLL_INTERVAL, so the
387    // shutdown flag is observed within that budget.
388    let state_clone = state.clone();
389    let shutdown_clone = shutdown.clone();
390    std::thread::spawn(move || {
391        while !shutdown_clone.load(Ordering::Relaxed) {
392            match metrics_rx.recv_timeout(POLL_INTERVAL) {
393                Ok(m) => {
394                    if let Ok(mut st) = state_clone.lock() {
395                        st.metrics = m;
396                    }
397                }
398                Err(crossbeam::channel::RecvTimeoutError::Timeout) => {}
399                Err(_) => break,
400            }
401        }
402    });
403
404    let html_for_unix = html.to_owned();
405    let mut server: Option<tiny_http::Server> = None;
406    let mut tcp_addr = String::new();
407
408    // Keep the last TCP bind error: when both binds fail, its errno
409    // decides whether the loader sandbox caused it (and the runtime
410    // unblock may help) or whether the port is simply taken.
411    let mut bind_err: Option<Box<dyn std::error::Error + Send + Sync + 'static>> = None;
412
413    match Server::http(format!("[::1]:{}", PORT)) {
414        Ok(s) => {
415            tcp_addr = format!("[::1]:{}", PORT);
416            server = Some(s);
417        }
418        Err(e) => bind_err = Some(e),
419    }
420
421    if server.is_none() {
422        match Server::http(format!("127.0.0.1:{}", PORT)) {
423            Ok(s) => {
424                tcp_addr = format!("127.0.0.1:{}", PORT);
425                server = Some(s);
426            }
427            Err(e) => bind_err = Some(e),
428        }
429    }
430
431    if let Some(server) = server {
432        log::info!(
433            "Web UI listening on http://{}/ — disable with --no-webui",
434            tcp_addr
435        );
436
437        let no_cache = Header::from_bytes("Cache-Control", "no-store").unwrap();
438        let html_type = Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap();
439        let json_type = Header::from_bytes("Content-Type", "application/json").unwrap();
440
441        while !shutdown.load(Ordering::Relaxed) {
442            if let Ok(Some(request)) = server.recv_timeout(Duration::from_millis(200)) {
443                let metrics = {
444                    let st = match state.lock() {
445                        Ok(s) => s,
446                        Err(_) => continue,
447                    };
448                    st.metrics.clone()
449                };
450                match request.url() {
451                    "/" => {
452                        let resp = Response::from_string(&html)
453                            .with_header(html_type.clone())
454                            .with_header(no_cache.clone());
455                        let _ = request.respond(resp);
456                    }
457                    "/api/stats" => {
458                        let stats = serde_json::to_value(&metrics.stats).unwrap_or_default();
459                        let per_cpu = serde_json::to_value(&metrics.per_cpu).unwrap_or_default();
460                        let merged = json!({
461                            "stats": stats,
462                            "per_cpu": per_cpu,
463                            "queue_runnable": metrics.queue_runnable,
464                            "llc_runnable": metrics.llc_runnable,
465                            "gpu_submit_total": metrics.gpu_submit_total,
466                            "gpu_trace_mask": metrics.gpu_trace_mask,
467                        });
468                        let json = serde_json::to_string(&merged).unwrap_or_else(|_| "{}".into());
469                        let resp = Response::from_string(json)
470                            .with_header(json_type.clone())
471                            .with_header(no_cache.clone());
472                        let _ = request.respond(resp);
473                    }
474                    _ => {
475                        let _ = request.respond(Response::empty(404));
476                    }
477                }
478            }
479        }
480    } else {
481        // TCP is blocked (the loader sandbox denies inet sockets), so
482        // serve the same routes over the unix socket, which AF_UNIX
483        // keeps available. Before the fallback, classify the last bind
484        // error: only a seccomp-style errno (a sandbox denial, not a
485        // busy port) earns the runtime unblock for the NEXT scheduler
486        // start. This run stays on the unix socket. The seccomp filter
487        // is per-process and inherited, so it cannot be lifted in place,
488        // and the drop-in takes effect when the loader next starts the
489        // unit.
490        let sandboxed = bind_err
491            .as_deref()
492            .and_then(boxed_io_error)
493            .is_some_and(sandbox_failure);
494        if sandboxed && try_unblock_loader_sandbox() {
495            UNBLOCK_WRITTEN.store(true, Ordering::SeqCst);
496        }
497
498        log::warn!(
499            "Web UI: TCP blocked (spawned by scx_loader?), falling back to {}",
500            UNIX_SOCKET_PATH
501        );
502        // Remove a stale socket file left by a previous run before
503        // binding, so the bind cannot fail on the leftover path.
504        if let Ok(meta) = std::fs::symlink_metadata(UNIX_SOCKET_PATH) {
505            if meta.file_type().is_socket() {
506                let _ = std::fs::remove_file(UNIX_SOCKET_PATH);
507            }
508        }
509
510        let listener = match UnixListener::bind(UNIX_SOCKET_PATH) {
511            Ok(l) => l,
512            Err(e) => {
513                log::warn!("Web UI: Unix socket bind failed: {}", e);
514                log::warn!("Web UI disabled. Use --no-webui to silence.");
515                return;
516            }
517        };
518
519        // Root-only connect: the socket is the same trust boundary as
520        // the loopback TCP binds, so only root (or whatever root lets
521        // in) may read the scheduler's metrics through it.
522        if let Err(e) = std::fs::set_permissions(
523            UNIX_SOCKET_PATH,
524            std::os::unix::fs::PermissionsExt::from_mode(0o600),
525        ) {
526            log::warn!("Web UI: failed to set the unix socket mode to 0600: {e}");
527        }
528
529        log::info!(
530            "Web UI listening on unix:{} (mode 0600, root-only) — access via: sudo socat TCP-LISTEN:{} UNIX-CONNECT:{}",
531            UNIX_SOCKET_PATH,
532            PORT,
533            UNIX_SOCKET_PATH
534        );
535
536        if let Err(e) = listener.set_nonblocking(true) {
537            // Nonblocking accept is required by the poll loop below.
538            // Without it the thread could not observe the shutdown flag
539            // while idle. Log and exit the serving thread gracefully.
540            // The UI simply shows disconnected.
541            log::error!("Web UI: failed to set the unix socket nonblocking: {e}");
542            log::warn!("Web UI disabled. Use --no-webui to silence.");
543            return;
544        }
545        while !shutdown.load(Ordering::Relaxed) {
546            match listener.accept() {
547                Ok((stream, _)) => {
548                    // Bound the first read: a client that connects and
549                    // sends nothing must not hold a handler thread
550                    // forever, so the 5 s read timeout on the request
551                    // line ends the handler (the error path in
552                    // unix_handle_client drops the connection).
553                    if let Err(e) = stream.set_read_timeout(Some(Duration::from_secs(5))) {
554                        log::warn!("Web UI: failed to set the unix-socket read timeout: {e}");
555                    }
556                    let state = state.clone();
557                    let html = html_for_unix.clone();
558                    std::thread::spawn(move || unix_handle_client(stream, &state, &html));
559                }
560                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
561                    std::thread::sleep(Duration::from_millis(100));
562                }
563                Err(e) => {
564                    // A transient accept failure (for example a file
565                    // descriptor shortage) must not end the dashboard
566                    // for the rest of the run. The loop retries at a
567                    // bounded rate and only the shutdown flag exits it.
568                    log::warn!("Web UI: unix-socket accept failed: {e}");
569                    std::thread::sleep(Duration::from_millis(100));
570                }
571            }
572        }
573    }
574
575    log::info!("Web UI stopped");
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn runtime_dropin_content_bytes() {
584        let content = runtime_dropin_content();
585
586        // The scheduler's own marker: ownership and the remove-on-exit
587        // contract are stated in the file itself.
588        assert!(content.contains("Owned by the scx_mlfq scheduler"));
589        assert!(content.contains("removed on exit"));
590
591        // The [Service] section and the two empty assignments that reset
592        // the loader's RestrictAddressFamilies=AF_UNIX and
593        // SocketBindDeny=... for the next start, as a contiguous block.
594        assert!(content.contains("[Service]\n"));
595        assert!(content.contains("[Service]\nRestrictAddressFamilies=\nSocketBindDeny=\n"));
596
597        // The file ends with a newline, like the installer's drop-in.
598        assert!(content.ends_with('\n'));
599    }
600
601    #[test]
602    fn sandbox_failure_classification_table() {
603        // Sandbox-like errnos: EPERM (SocketBindDeny), EAFNOSUPPORT
604        // (RestrictAddressFamilies) and EACCES.
605        assert!(sandbox_failure(&std::io::Error::from_raw_os_error(EPERM)));
606        assert!(sandbox_failure(&std::io::Error::from_raw_os_error(
607            EAFNOSUPPORT
608        )));
609        assert!(sandbox_failure(&std::io::Error::from_raw_os_error(EACCES)));
610
611        // A busy port (EADDRINUSE) must never trigger the unblock.
612        assert!(!sandbox_failure(&std::io::Error::from_raw_os_error(98)));
613
614        // Any other or errno-less error is conservatively not a sandbox
615        // failure.
616        assert!(!sandbox_failure(&std::io::Error::from_raw_os_error(110)));
617        assert!(!sandbox_failure(&std::io::Error::new(
618            std::io::ErrorKind::Other,
619            "no errno"
620        )));
621    }
622
623    #[test]
624    fn dropin_matches_is_byte_exact() {
625        assert!(dropin_matches(&runtime_dropin_content()));
626
627        // Any deviation — an empty file, a reset kept but a marker
628        // edited, a restriction left in place — breaks the byte match,
629        // so a foreign edit is never removed by the restore path.
630        assert!(!dropin_matches(""));
631        assert!(!dropin_matches(&runtime_dropin_content().replace(
632            "RestrictAddressFamilies=",
633            "RestrictAddressFamilies=AF_UNIX"
634        )));
635        let foreign_marker = runtime_dropin_content().replace("removed on exit", "edited by admin");
636        assert!(!dropin_matches(&foreign_marker));
637    }
638
639    #[test]
640    fn boxed_io_error_walks_source_chain() {
641        // The real path: tiny_http surfaces the TcpListener::bind
642        // io::Error directly, so the top-level downcast recovers it.
643        let direct: Box<dyn std::error::Error + Send + Sync + 'static> =
644            Box::new(std::io::Error::from_raw_os_error(EPERM));
645        let ioe = boxed_io_error(direct.as_ref()).expect("the direct io::Error is recovered");
646        assert!(sandbox_failure(ioe));
647
648        // A non-io wrapper that exposes the errno-bearing io::Error
649        // through its source chain still classifies, so a future
650        // tiny_http error change cannot silently disable the unblock.
651        #[derive(Debug)]
652        struct Wrapper(Box<dyn std::error::Error + Send + Sync>);
653        impl std::fmt::Display for Wrapper {
654            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655                write!(f, "wraps: {}", self.0)
656            }
657        }
658        impl std::error::Error for Wrapper {
659            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
660                Some(self.0.as_ref())
661            }
662        }
663        let wrapped: Box<dyn std::error::Error + Send + Sync + 'static> = Box::new(Wrapper(
664            Box::new(std::io::Error::from_raw_os_error(EAFNOSUPPORT)),
665        ));
666        let ioe = boxed_io_error(wrapped.as_ref()).expect("the wrapped io::Error is recovered");
667        assert!(sandbox_failure(ioe));
668
669        // An io::Error without an errno (a custom error payload) yields
670        // None, so the classification stays off rather than guessing.
671        let no_errno: Box<dyn std::error::Error + Send + Sync + 'static> =
672            Box::new(std::io::Error::new(std::io::ErrorKind::Other, "no errno"));
673        assert!(boxed_io_error(no_errno.as_ref()).is_none());
674
675        // A non-io error with no io::Error in the source chain yields
676        // None.
677        #[derive(Debug)]
678        struct PlainErr;
679        impl std::fmt::Display for PlainErr {
680            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
681                write!(f, "plain error")
682            }
683        }
684        impl std::error::Error for PlainErr {}
685        let unrelated: Box<dyn std::error::Error + Send + Sync + 'static> = Box::new(PlainErr);
686        assert!(boxed_io_error(unrelated.as_ref()).is_none());
687    }
688}