netget/utils/shutdown.rs
1//! Cooperative shutdown for **blocking** capture loops.
2//!
3//! Most protocols run their accept loop as a Tokio task, so `stop_server` cancelling the
4//! `JoinHandle` registered with [`AppState::register_server_task`] is enough: the task is
5//! dropped at its next await point and the socket goes with it.
6//!
7//! The packet-capture family (`arp`, `datalink`, `icmp`, `isis`) cannot work that way. Their
8//! loops are synchronous — `pcap::Capture::next_packet()` and `socket2::Socket::recv_from()`
9//! block a thread from `spawn_blocking`, and **`JoinHandle::abort()` cannot interrupt a
10//! blocking call**: Tokio has no way to unwind a thread parked in a syscall. Each of the four
11//! also dropped its handle outright and never registered it, so on a host with the privileges
12//! to actually run them (root, `/dev/bpf*` on macOS/BSD, `CAP_NET_RAW` on Linux) `stop_server`
13//! left the capture running until the process exited — still capturing, still feeding the LLM,
14//! while the operator believed it had stopped.
15//!
16//! [`StopSignal`] is the mechanism that fixes that. It is an `AtomicBool` the blocking loop
17//! polls between packets, plus a Tokio task that trips it when cancelled, so the *existing*
18//! `register_server_task` / `remove_server` plumbing drives it with no new API:
19//!
20//! ```ignore
21//! let stop = StopSignal::new();
22//! let stop_in_loop = stop.clone();
23//! tokio::task::spawn_blocking(move || {
24//! loop {
25//! if stop_in_loop.is_stopped() { break; } // ← checked every iteration
26//! match cap.next_packet() { /* … */ }
27//! }
28//! });
29//! // …after the loop reports readiness:
30//! app_state.register_server_task(server_id, stop.park_task()).await;
31//! ```
32//!
33//! # Why the read timeout matters
34//!
35//! A poll between packets only runs when the blocking call returns. The four callers each
36//! bound that:
37//!
38//! * `arp`, `datalink`, `isis` open pcap with `.timeout(1000)`, so `next_packet()` returns
39//! `Error::TimeoutExpired` at least once a second on an idle link — the loop then sees the
40//! flag. **Shutdown latency is therefore up to ~1s.**
41//! * `icmp` puts its raw socket in non-blocking mode and sleeps 10ms on `WouldBlock`, so it
42//! notices within ~10ms.
43//!
44//! Removing the pcap read timeout would make shutdown unbounded again on an idle interface.
45//! Do not.
46
47use std::sync::atomic::{AtomicBool, Ordering};
48use std::sync::Arc;
49
50/// A flag a blocking loop polls to learn that it must stop.
51///
52/// Cloning is cheap and every clone observes the same flag.
53#[derive(Clone, Debug)]
54pub struct StopSignal(Arc<AtomicBool>);
55
56impl StopSignal {
57 /// A fresh signal in the "keep running" state.
58 pub fn new() -> Self {
59 StopSignal(Arc::new(AtomicBool::new(false)))
60 }
61
62 /// Has someone asked the loop to stop?
63 ///
64 /// Call this once per loop iteration, *before* the blocking read as well as after it, so a
65 /// stop that arrives while the loop is between packets is not missed.
66 pub fn is_stopped(&self) -> bool {
67 self.0.load(Ordering::SeqCst)
68 }
69
70 /// Ask the loop to stop. Idempotent.
71 pub fn stop(&self) {
72 self.0.store(true, Ordering::SeqCst);
73 }
74
75 /// A Tokio task that parks forever and trips this signal when it is dropped.
76 ///
77 /// Hand the returned handle to [`AppState::register_server_task`]. `remove_server` /
78 /// `stop_server` abort it, Tokio drops the future, the guard's `Drop` sets the flag, and
79 /// the blocking loop exits at its next poll. That is the whole point: the blocking loop
80 /// becomes stoppable through the same registration every other protocol already uses,
81 /// with no per-protocol shutdown channel and no central knowledge of capture protocols.
82 ///
83 /// Dropping the handle without aborting merely detaches the task, which leaves the signal
84 /// untripped — exactly the behaviour of every other registered accept loop, and the reason
85 /// registering it is not optional.
86 pub fn park_task(&self) -> tokio::task::JoinHandle<()> {
87 let guard = StopGuard(self.0.clone());
88 tokio::spawn(async move {
89 // Moved into the future so cancellation drops it.
90 let _guard = guard;
91 std::future::pending::<()>().await;
92 })
93 }
94}
95
96impl Default for StopSignal {
97 fn default() -> Self {
98 Self::new()
99 }
100}
101
102/// Trips the flag on drop. Lives inside the parked task's future.
103struct StopGuard(Arc<AtomicBool>);
104
105impl Drop for StopGuard {
106 fn drop(&mut self) {
107 self.0.store(true, Ordering::SeqCst);
108 }
109}