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