Skip to main content

ghidra_worker_ctl/
state.rs

1//! Pure worker-lifecycle state machine (spec §4). No I/O: models the states and
2//! the one-shot WORKER_RESTARTED notice so lifecycle decisions are unit-testable
3//! without spawning a JVM.
4
5use thiserror::Error;
6
7/// Worker lifecycle state (spec §4: starting|ready|busy|crashed).
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum WorkerState {
10    Starting,
11    Ready,
12    Busy,
13    Crashed,
14}
15
16#[derive(Debug, Error, PartialEq, Eq)]
17#[error("invalid worker transition from {from:?}: {event}")]
18pub struct InvalidTransition {
19    pub from: WorkerState,
20    pub event: &'static str,
21}
22
23/// Tracks worker state plus the one-shot "restarted after crash" notice that must
24/// surface EXACTLY once (spec §4: the next call after a crash returns WORKER_RESTARTED
25/// once, so the agent knows unsaved edits were lost).
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct WorkerLifecycle {
28    state: WorkerState,
29    restart_notice_pending: bool,
30    /// True once the worker has completed at least one successful init (reached Ready). A crash
31    /// during the very first boot loses nothing, so it must NOT arm the WORKER_RESTARTED notice.
32    ever_ready: bool,
33}
34
35impl Default for WorkerLifecycle {
36    fn default() -> Self {
37        Self {
38            state: WorkerState::Starting,
39            restart_notice_pending: false,
40            ever_ready: false,
41        }
42    }
43}
44
45impl WorkerLifecycle {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    pub fn state(&self) -> WorkerState {
51        self.state
52    }
53
54    /// Successful init handshake: Starting -> Ready.
55    pub fn on_ready(&mut self) -> Result<(), InvalidTransition> {
56        match self.state {
57            WorkerState::Starting => {
58                self.state = WorkerState::Ready;
59                self.ever_ready = true;
60                Ok(())
61            }
62            from => Err(InvalidTransition {
63                from,
64                event: "on_ready",
65            }),
66        }
67    }
68
69    /// Dispatch a call onto the executor: Ready -> Busy.
70    pub fn on_dispatch(&mut self) -> Result<(), InvalidTransition> {
71        match self.state {
72            WorkerState::Ready => {
73                self.state = WorkerState::Busy;
74                Ok(())
75            }
76            from => Err(InvalidTransition {
77                from,
78                event: "on_dispatch",
79            }),
80        }
81    }
82
83    /// Call finished: Busy -> Ready.
84    pub fn on_complete(&mut self) -> Result<(), InvalidTransition> {
85        match self.state {
86            WorkerState::Busy => {
87                self.state = WorkerState::Ready;
88                Ok(())
89            }
90            from => Err(InvalidTransition {
91                from,
92                event: "on_complete",
93            }),
94        }
95    }
96
97    /// Socket closed / process died: transition to Crashed from any state.
98    pub fn on_crash(&mut self) {
99        self.state = WorkerState::Crashed;
100    }
101
102    /// Begin a respawn: Crashed -> Starting, arming the one-shot restart notice.
103    pub fn on_respawn(&mut self) -> Result<(), InvalidTransition> {
104        match self.state {
105            WorkerState::Crashed => {
106                self.state = WorkerState::Starting;
107                self.restart_notice_pending = self.ever_ready;
108                Ok(())
109            }
110            from => Err(InvalidTransition {
111                from,
112                event: "on_respawn",
113            }),
114        }
115    }
116
117    /// Consume the one-shot WORKER_RESTARTED notice; true at most once per crash.
118    pub fn take_restart_notice(&mut self) -> bool {
119        std::mem::take(&mut self.restart_notice_pending)
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn starts_in_starting() {
129        assert_eq!(WorkerLifecycle::new().state(), WorkerState::Starting);
130    }
131
132    #[test]
133    fn happy_path_starting_ready_busy_ready() {
134        let mut w = WorkerLifecycle::new();
135        w.on_ready().unwrap();
136        assert_eq!(w.state(), WorkerState::Ready);
137        w.on_dispatch().unwrap();
138        assert_eq!(w.state(), WorkerState::Busy);
139        w.on_complete().unwrap();
140        assert_eq!(w.state(), WorkerState::Ready);
141    }
142
143    #[test]
144    fn cannot_dispatch_before_ready() {
145        let mut w = WorkerLifecycle::new();
146        assert_eq!(
147            w.on_dispatch(),
148            Err(InvalidTransition {
149                from: WorkerState::Starting,
150                event: "on_dispatch"
151            })
152        );
153    }
154
155    #[test]
156    fn cannot_ready_twice() {
157        let mut w = WorkerLifecycle::new();
158        w.on_ready().unwrap();
159        assert!(w.on_ready().is_err());
160    }
161
162    #[test]
163    fn crash_then_respawn_arms_restart_notice_exactly_once() {
164        let mut w = WorkerLifecycle::new();
165        w.on_ready().unwrap();
166        w.on_dispatch().unwrap();
167        w.on_crash(); // socket closed mid-call
168        assert_eq!(w.state(), WorkerState::Crashed);
169        w.on_respawn().unwrap();
170        assert_eq!(w.state(), WorkerState::Starting);
171        // WORKER_RESTARTED surfaces once, then never again for this crash.
172        assert!(w.take_restart_notice());
173        assert!(!w.take_restart_notice());
174    }
175
176    #[test]
177    fn no_restart_notice_before_any_crash() {
178        let mut w = WorkerLifecycle::new();
179        assert!(!w.take_restart_notice());
180    }
181
182    #[test]
183    fn cannot_respawn_a_live_worker() {
184        let mut w = WorkerLifecycle::new();
185        w.on_ready().unwrap();
186        assert_eq!(
187            w.on_respawn(),
188            Err(InvalidTransition {
189                from: WorkerState::Ready,
190                event: "on_respawn"
191            })
192        );
193    }
194
195    #[test]
196    fn crash_before_first_ready_does_not_arm_restart_notice() {
197        let mut w = WorkerLifecycle::new(); // Starting, never reached Ready
198        w.on_crash();
199        w.on_respawn().unwrap();
200        // Nothing was lost (we never ran a call), so no vacuous "unsaved edits lost" notice.
201        assert!(!w.take_restart_notice());
202    }
203}