Skip to main content

studio_worker/
job_gate.rs

1//! The worker's single-job reservation gate.
2//!
3//! A worker owns one GPU, so exactly one generation may run at a time.
4//! Three independent code paths race for it: the WS session (studio
5//! offers), the always-on local API (`POST /image`), and the
6//! auto-updater (which must not `restart_self` mid-job).  Before this
7//! gate the WS session guarded itself with a bare `Arc<AtomicBool>`
8//! CAS while the local API ignored it entirely — so a local job and a
9//! studio job could run concurrently and OOM each other, and an update
10//! could kill an in-flight job.
11//!
12//! [`JobGate`] wraps that shared flag and hands out an RAII
13//! [`JobReservation`] whose `Drop` releases the slot, so no code path
14//! can forget to clear it on an early return or a panic.  The gate is
15//! cheap to clone (an `Arc`) and the reservation is `Send`, so it
16//! moves cleanly into a spawned task for the lifetime of a job.
17
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::Arc;
20
21/// A cloneable handle to the worker's one-job-at-a-time flag.
22#[derive(Clone, Default)]
23pub struct JobGate {
24    busy: Arc<AtomicBool>,
25}
26
27impl JobGate {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Adopt an existing shared flag (the runtime already threads one
33    /// `Arc<AtomicBool>` through the WS session + auto-updater).
34    pub fn from_shared(busy: Arc<AtomicBool>) -> Self {
35        Self { busy }
36    }
37
38    /// The underlying flag, for readers that only need to observe
39    /// busyness (e.g. a heartbeat) without reserving.
40    pub fn shared(&self) -> Arc<AtomicBool> {
41        self.busy.clone()
42    }
43
44    /// True while a job holds the slot.
45    pub fn is_busy(&self) -> bool {
46        self.busy.load(Ordering::SeqCst)
47    }
48
49    /// Atomically claim the slot.  `Some(reservation)` means the caller
50    /// owns the worker until the reservation drops; `None` means a job
51    /// is already running and the caller must back off (reject the
52    /// offer / return 503 / skip the update).
53    pub fn try_reserve(&self) -> Option<JobReservation> {
54        self.busy
55            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
56            .ok()
57            .map(|_| JobReservation {
58                busy: self.busy.clone(),
59            })
60    }
61}
62
63/// RAII proof that the holder owns the worker's single job slot.
64/// Releasing on `Drop` means every exit path — success, error, or
65/// panic — frees the slot without an explicit store.
66pub struct JobReservation {
67    busy: Arc<AtomicBool>,
68}
69
70impl Drop for JobReservation {
71    fn drop(&mut self) {
72        self.busy.store(false, Ordering::SeqCst);
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn reserve_is_exclusive_until_dropped() {
82        let gate = JobGate::new();
83        assert!(!gate.is_busy());
84        let reservation = gate.try_reserve().expect("first reserve wins");
85        assert!(gate.is_busy());
86        assert!(
87            gate.try_reserve().is_none(),
88            "a second reserve must fail while the first is held"
89        );
90        drop(reservation);
91        assert!(!gate.is_busy(), "dropping the reservation frees the slot");
92        assert!(gate.try_reserve().is_some(), "the slot is claimable again");
93    }
94
95    #[test]
96    fn clones_share_one_slot() {
97        // The local API, session, and updater hold separate clones of
98        // the same gate — a reservation on one must block the others.
99        let gate = JobGate::new();
100        let other = gate.clone();
101        let _held = gate.try_reserve().unwrap();
102        assert!(other.is_busy());
103        assert!(other.try_reserve().is_none());
104    }
105
106    #[test]
107    fn from_shared_adopts_an_existing_flag() {
108        let flag = Arc::new(AtomicBool::new(false));
109        let gate = JobGate::from_shared(flag.clone());
110        let _held = gate.try_reserve().unwrap();
111        assert!(
112            flag.load(Ordering::SeqCst),
113            "reserving must set the adopted flag so existing readers see it"
114        );
115    }
116
117    #[test]
118    fn reservation_releases_on_panic_unwind() {
119        // A job that panics mid-flight must still free the worker.
120        let gate = JobGate::new();
121        let gate_for_thread = gate.clone();
122        let _ = std::thread::spawn(move || {
123            let _held = gate_for_thread.try_reserve().unwrap();
124            panic!("job blew up");
125        })
126        .join();
127        assert!(
128            !gate.is_busy(),
129            "the slot must be free after a panicking holder unwinds"
130        );
131    }
132}