Skip to main content

ipp_printer_app/
status.rs

1//! Background `printer-state-reasons` poller.
2//!
3//! A single tokio task per server walks the printer registry every poll
4//! interval (default 30 s, override with `IPP_PRINTER_APP_POLL_SECS`) and
5//! asks the backend for fresh status. Backends that don't override
6//! [`DeviceBackend::poll_status`] return `None` and leave the registry
7//! untouched.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use crate::device::DeviceBackend;
13use crate::printer::{PrinterRecord, PrinterRegistry};
14
15/// Lets the status poller pull a printer out of DNS-SD discovery when its
16/// device goes offline, and republish it when the device returns — so a
17/// powered-off printer stops showing up in print dialogs. Implemented by
18/// [`crate::mdns::Advertiser`]; the poller holds it as a trait object so the
19/// poller stays independent of the optional `mdns` feature.
20pub trait AdvertiserControl: Send + Sync {
21    /// Republish the printer's discovery advert (its device came back online).
22    /// Idempotent: a no-op if already advertised.
23    fn publish(&self, rec: &PrinterRecord);
24    /// Withdraw the printer's discovery advert (its device went offline).
25    /// Idempotent: a no-op if not currently advertised.
26    fn withdraw(&self, name: &str);
27    /// Whether this printer is currently advertised.
28    fn is_advertised(&self, name: &str) -> bool;
29}
30
31/// Default cadence (configurable via `IPP_PRINTER_APP_POLL_SECS`).
32const POLL_INTERVAL: Duration = Duration::from_secs(30);
33
34/// Spawn the polling task. Returns immediately. Drop the returned
35/// [`tokio::task::JoinHandle`] to abort the loop on server shutdown.
36pub fn spawn(
37    backend: Arc<dyn DeviceBackend>,
38    registry: PrinterRegistry,
39    advertiser: Option<Arc<dyn AdvertiserControl>>,
40    state_path: std::path::PathBuf,
41) -> tokio::task::JoinHandle<()> {
42    let interval = std::env::var("IPP_PRINTER_APP_POLL_SECS")
43        .ok()
44        .and_then(|s| s.parse().ok())
45        .map(Duration::from_secs)
46        .unwrap_or(POLL_INTERVAL);
47
48    tokio::spawn(async move {
49        // First poll happens after `interval` so the server has time to
50        // bootstrap printers before the first status query lands on a device.
51        let mut ticker = tokio::time::interval(interval);
52        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
53        ticker.tick().await; // immediate first tick
54        loop {
55            ticker.tick().await;
56            let media_changed = poll_once(backend.as_ref(), &registry, advertiser.as_deref()).await;
57            // The loaded roll is part of the printer's configuration, so a swap
58            // has to outlive the process. Only on an actual change — this loop
59            // runs every few seconds.
60            if media_changed {
61                crate::server::Server::persist(&registry, &state_path);
62            }
63        }
64    })
65}
66
67/// Returns whether any printer's loaded media changed, so the caller can
68/// persist the registry.
69async fn poll_once(
70    backend: &dyn DeviceBackend,
71    registry: &PrinterRegistry,
72    advertiser: Option<&dyn AdvertiserControl>,
73) -> bool {
74    let mut media_changed = false;
75    use crate::flags::PrinterReason;
76    use crate::printer::IppPrinterState;
77    // Snapshot printers that aren't actively printing. We poll Idle AND Stopped
78    // ones: a Stopped (offline) printer must keep being polled so it recovers
79    // to Idle when the device answers again. Processing printers are skipped so
80    // we don't contend with the device mid-job.
81    let configs: Vec<_> = {
82        let g = registry.read();
83        g.iter()
84            .filter(|r| matches!(r.state, IppPrinterState::Idle | IppPrinterState::Stopped))
85            .map(|r| r.config.clone())
86            .collect()
87    };
88
89    for cfg in configs {
90        // `poll_status` awaits the device transport; the backend bridges any
91        // blocking FFI internally so other tasks stay responsive.
92        let Some(status) = backend.poll_status(&cfg).await else {
93            continue;
94        };
95
96        // `reachable` is the source of truth for both printer-state and the
97        // discovery advert. Snapshot the record (for a possible republish)
98        // while holding the lock, then reconcile the advert after releasing it.
99        let reachable = !status.reasons.contains(PrinterReason::OFFLINE);
100        let mut rec_snapshot: Option<PrinterRecord> = None;
101        {
102            let mut g = registry.write();
103            if let Some(rec) = g.iter_mut().find(|r| r.config.name == cfg.name) {
104                if rec.reasons != status.reasons {
105                    log::debug!(
106                        "status: {} reasons {:?} -> {:?}",
107                        cfg.name,
108                        rec.reasons,
109                        status.reasons
110                    );
111                    rec.reasons = status.reasons;
112                }
113                // Reflect reachability in printer-state: an unreachable device
114                // reports OFFLINE, which we surface as printer-state=stopped so
115                // CUPS *holds* queued jobs until the device is back (then idle
116                // again releases them). Only Idle/Stopped printers are in this
117                // set, so a Processing job is never disturbed.
118                let want = if reachable {
119                    IppPrinterState::Idle
120                } else {
121                    IppPrinterState::Stopped
122                };
123                if rec.state != want {
124                    log::info!("status: {} state {:?} -> {:?}", cfg.name, rec.state, want);
125                    rec.state = want;
126                }
127                // Carry forward last-known media/supply when a poll omits them.
128                if let Some(media) = status.ready_media {
129                    media_changed |= rec.set_ready_media(media);
130                }
131                if status.supply_percent.is_some() {
132                    rec.supply_percent = status.supply_percent;
133                }
134                if advertiser.is_some() {
135                    rec_snapshot = Some(rec.clone());
136                }
137            }
138        }
139
140        // Reconcile the discovery advert against reachability — idempotent, not
141        // edge-triggered: a reachable printer must be advertised, an offline one
142        // withdrawn. Reconciling (rather than firing on a state edge) means a
143        // held job that drove the printer Stopped→Processing→Idle as it finally
144        // printed still gets its advert restored on the next poll.
145        if let Some(adv) = advertiser {
146            if reachable {
147                if let Some(rec) = rec_snapshot {
148                    if !adv.is_advertised(&cfg.name) {
149                        adv.publish(&rec);
150                    }
151                }
152            } else if adv.is_advertised(&cfg.name) {
153                adv.withdraw(&cfg.name);
154            }
155        }
156    }
157    media_changed
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::device::PollStatus;
164    use crate::flags::PrinterReason;
165    use crate::printer::{IppPrinterState, PrinterConfig, PrinterRecord};
166    use parking_lot::Mutex;
167    use std::collections::HashSet;
168
169    /// Backend whose reported reasons the test flips to simulate the device
170    /// going offline and coming back.
171    struct FakeBackend {
172        reasons: Mutex<PrinterReason>,
173    }
174    #[async_trait::async_trait]
175    impl DeviceBackend for FakeBackend {
176        async fn list(&self) -> Vec<crate::device::DiscoveredDevice> {
177            Vec::new()
178        }
179        fn driver_for_device(&self, _id: &str, _uri: &str) -> Option<String> {
180            None
181        }
182        async fn poll_status(&self, _config: &PrinterConfig) -> Option<PollStatus> {
183            Some(PollStatus {
184                reasons: *self.reasons.lock(),
185                ready_media: None,
186                supply_percent: None,
187            })
188        }
189    }
190
191    /// Records the advert calls the poller makes and the live advertised set.
192    #[derive(Default)]
193    struct FakeAdvertiser {
194        advertised: Mutex<HashSet<String>>,
195    }
196    impl AdvertiserControl for FakeAdvertiser {
197        fn publish(&self, rec: &PrinterRecord) {
198            self.advertised.lock().insert(rec.config.name.clone());
199        }
200        fn withdraw(&self, name: &str) {
201            self.advertised.lock().remove(name);
202        }
203        fn is_advertised(&self, name: &str) -> bool {
204            self.advertised.lock().contains(name)
205        }
206    }
207
208    fn config(name: &str) -> PrinterConfig {
209        PrinterConfig {
210            name: name.into(),
211            display_name: String::new(),
212            driver_name: "t".into(),
213            make_and_model: "Test".into(),
214            device_id: String::new(),
215            device_uri: "mock://x".into(),
216            dpi: 203,
217            printhead_width_dots: 384,
218            media_names: vec![],
219            media_sizes: vec![],
220            media_size_min: [0, 0],
221            media_size_max: [0, 0],
222            darkness: 50,
223            document_formats: vec![],
224        }
225    }
226
227    // block_in_place inside poll_once needs a multi-threaded runtime.
228    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
229    async fn offline_stops_and_withdraws_then_recovery_republishes() {
230        let backend = FakeBackend {
231            reasons: Mutex::new(PrinterReason::OFFLINE),
232        };
233        let registry: PrinterRegistry =
234            Arc::new(parking_lot::RwLock::new(vec![PrinterRecord::new(config(
235                "p",
236            ))]));
237        let adv = Arc::new(FakeAdvertiser::default());
238        // Simulate startup register_all having advertised it.
239        adv.publish(&registry.read()[0].clone());
240        let advref: &dyn AdvertiserControl = adv.as_ref();
241
242        // Device offline -> printer stopped + advert withdrawn.
243        poll_once(&backend, &registry, Some(advref)).await;
244        assert_eq!(registry.read()[0].state, IppPrinterState::Stopped);
245        assert!(!adv.is_advertised("p"), "offline printer must be withdrawn");
246
247        // Device back -> printer idle + advert republished.
248        *backend.reasons.lock() = PrinterReason::empty();
249        poll_once(&backend, &registry, Some(advref)).await;
250        assert_eq!(registry.read()[0].state, IppPrinterState::Idle);
251        assert!(
252            adv.is_advertised("p"),
253            "recovered printer must be republished"
254        );
255    }
256
257    /// The advert is reconciled, not edge-triggered: a held job that drove the
258    /// printer Stopped -> Processing -> Idle leaves it Idle but un-advertised;
259    /// the next poll must republish it even though the poller saw no Stopped->
260    /// Idle edge.
261    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
262    async fn reconcile_republishes_without_a_state_edge() {
263        let backend = FakeBackend {
264            reasons: Mutex::new(PrinterReason::empty()),
265        };
266        let registry: PrinterRegistry =
267            Arc::new(parking_lot::RwLock::new(vec![PrinterRecord::new(config(
268                "p",
269            ))]));
270        // Already Idle (the worker reset it after a held job finished printing),
271        // but the advert was never restored.
272        let adv = Arc::new(FakeAdvertiser::default());
273        assert!(!adv.is_advertised("p"));
274        let advref: &dyn AdvertiserControl = adv.as_ref();
275
276        poll_once(&backend, &registry, Some(advref)).await;
277        assert!(
278            adv.is_advertised("p"),
279            "reconcile must restore the advert with no state transition"
280        );
281    }
282}