Skip to main content

ipp_printer_app/
job.rs

1//! Per-server job registry: allocates job-ids, tracks state for
2//! `Get-Jobs` / `Get-Job-Attributes` / `Cancel-Job`.
3
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::Arc;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use parking_lot::RwLock;
9
10use crate::flags::PrinterReason;
11
12/// Monotonic job-id allocated by [`JobRegistry::create`].
13pub type JobId = u32;
14
15/// IPP `job-state` enum (RFC 8011 §5.3.7).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[repr(u32)]
18#[allow(missing_docs)]
19pub enum JobState {
20    Pending = 3,
21    Held = 4,
22    Processing = 5,
23    ProcessingStopped = 6,
24    Canceled = 7,
25    Aborted = 8,
26    Completed = 9,
27}
28
29impl JobState {
30    /// True for `Canceled` / `Aborted` / `Completed` — the registry will
31    /// not transition out of these states.
32    pub fn is_terminal(self) -> bool {
33        matches!(self, Self::Canceled | Self::Aborted | Self::Completed)
34    }
35}
36
37/// One job in the per-server registry.
38#[derive(Debug, Clone)]
39#[allow(missing_docs)]
40pub struct JobRecord {
41    pub id: JobId,
42    pub printer_name: String,
43    pub state: JobState,
44    pub reasons: PrinterReason,
45    pub message: String,
46    pub created_at: SystemTime,
47    pub completed_at: Option<SystemTime>,
48    /// Flipped by `Cancel-Job` so the worker can short-circuit.
49    pub cancel_flag: Arc<AtomicBool>,
50}
51
52impl JobRecord {
53    /// Seconds since epoch for `time-at-creation` / `time-at-completed`.
54    pub fn created_secs(&self) -> i32 {
55        secs_since_epoch(self.created_at)
56    }
57
58    /// Seconds since epoch for `time-at-completed`. `None` while still active.
59    pub fn completed_secs(&self) -> Option<i32> {
60        self.completed_at.map(secs_since_epoch)
61    }
62
63    /// True once `Cancel-Job` has been observed. Workers should check this
64    /// between scanlines / pages.
65    pub fn is_canceled(&self) -> bool {
66        self.cancel_flag.load(Ordering::Acquire)
67    }
68}
69
70fn secs_since_epoch(t: SystemTime) -> i32 {
71    t.duration_since(UNIX_EPOCH)
72        .map(|d| d.as_secs() as i32)
73        .unwrap_or(0)
74}
75
76/// Shared job registry. Cheap to clone.
77#[derive(Clone)]
78pub struct JobRegistry {
79    inner: Arc<RwLock<Inner>>,
80}
81
82struct Inner {
83    next_id: u32,
84    jobs: Vec<JobRecord>,
85}
86
87impl Default for JobRegistry {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl JobRegistry {
94    /// Empty registry with `next_id = 1`.
95    pub fn new() -> Self {
96        Self {
97            inner: Arc::new(RwLock::new(Inner {
98                next_id: 1,
99                jobs: Vec::new(),
100            })),
101        }
102    }
103
104    /// Allocate a new pending job for `printer_name`. Returns a clone of the
105    /// record so the caller can stash the `JobId` and `cancel_flag` without
106    /// holding the registry lock.
107    pub fn create(&self, printer_name: String) -> JobRecord {
108        let mut g = self.inner.write();
109        let id = g.next_id;
110        g.next_id = g.next_id.wrapping_add(1).max(1);
111        let rec = JobRecord {
112            id,
113            printer_name,
114            state: JobState::Pending,
115            reasons: PrinterReason::empty(),
116            message: String::new(),
117            created_at: SystemTime::now(),
118            completed_at: None,
119            cancel_flag: Arc::new(AtomicBool::new(false)),
120        };
121        g.jobs.push(rec.clone());
122        rec
123    }
124
125    /// Look up a job by id. Returns a clone so the caller doesn't hold the lock.
126    pub fn get(&self, id: JobId) -> Option<JobRecord> {
127        self.inner.read().jobs.iter().find(|j| j.id == id).cloned()
128    }
129
130    /// All jobs that target the named printer. Order is allocation order.
131    pub fn jobs_for_printer(&self, printer_name: &str) -> Vec<JobRecord> {
132        self.inner
133            .read()
134            .jobs
135            .iter()
136            .filter(|j| j.printer_name == printer_name)
137            .cloned()
138            .collect()
139    }
140
141    /// Force a job into `state`. Stamps `completed_at` when crossing into a
142    /// terminal state. No-op if the id doesn't exist.
143    pub fn set_state(&self, id: JobId, state: JobState) {
144        let mut g = self.inner.write();
145        if let Some(j) = g.jobs.iter_mut().find(|j| j.id == id) {
146            j.state = state;
147            if state.is_terminal() && j.completed_at.is_none() {
148                j.completed_at = Some(SystemTime::now());
149            }
150        }
151    }
152
153    /// Mark a job as failed with IPP reasons + message.
154    pub fn set_failure(&self, id: JobId, reasons: PrinterReason, message: String) {
155        let mut g = self.inner.write();
156        if let Some(j) = g.jobs.iter_mut().find(|j| j.id == id) {
157            j.state = JobState::Aborted;
158            j.reasons = reasons;
159            j.message = message;
160            j.completed_at = Some(SystemTime::now());
161        }
162    }
163
164    /// Request cancellation. Returns the new state, or `None` if no such job.
165    /// Already-terminal jobs are left alone.
166    pub fn cancel(&self, id: JobId) -> Option<JobState> {
167        let mut g = self.inner.write();
168        let j = g.jobs.iter_mut().find(|j| j.id == id)?;
169        if j.state.is_terminal() {
170            return Some(j.state);
171        }
172        j.cancel_flag.store(true, Ordering::Release);
173        j.state = JobState::Canceled;
174        j.completed_at = Some(SystemTime::now());
175        Some(j.state)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn distinct_ids() {
185        let reg = JobRegistry::new();
186        let a = reg.create("p".into());
187        let b = reg.create("p".into());
188        assert_ne!(a.id, b.id);
189        assert_eq!(a.state, JobState::Pending);
190    }
191
192    #[test]
193    fn cancel_flips_flag_and_state() {
194        let reg = JobRegistry::new();
195        let j = reg.create("p".into());
196        let flag = j.cancel_flag.clone();
197        assert!(!flag.load(Ordering::Acquire));
198        assert_eq!(reg.cancel(j.id), Some(JobState::Canceled));
199        assert!(flag.load(Ordering::Acquire));
200        assert_eq!(reg.get(j.id).unwrap().state, JobState::Canceled);
201    }
202
203    #[test]
204    fn cancel_terminal_is_noop() {
205        let reg = JobRegistry::new();
206        let j = reg.create("p".into());
207        reg.set_state(j.id, JobState::Completed);
208        assert_eq!(reg.cancel(j.id), Some(JobState::Completed));
209        assert!(!reg.get(j.id).unwrap().cancel_flag.load(Ordering::Acquire));
210    }
211
212    #[test]
213    fn failure_records_reasons_and_message() {
214        let reg = JobRegistry::new();
215        let j = reg.create("p".into());
216        reg.set_failure(j.id, PrinterReason::MEDIA_EMPTY, "no labels".into());
217        let after = reg.get(j.id).unwrap();
218        assert_eq!(after.state, JobState::Aborted);
219        assert_eq!(after.reasons, PrinterReason::MEDIA_EMPTY);
220        assert_eq!(after.message, "no labels");
221    }
222
223    #[test]
224    fn jobs_for_printer_filters() {
225        let reg = JobRegistry::new();
226        let _ = reg.create("a".into());
227        let _ = reg.create("b".into());
228        let _ = reg.create("a".into());
229        assert_eq!(reg.jobs_for_printer("a").len(), 2);
230        assert_eq!(reg.jobs_for_printer("b").len(), 1);
231        assert_eq!(reg.jobs_for_printer("c").len(), 0);
232    }
233}