Skip to main content

optative_process_pool/
supervisor.rs

1use std::collections::{BTreeMap, HashMap};
2use std::path::PathBuf;
3use std::sync::mpsc;
4
5use optative::reconcile::ReconcileErrors;
6use tempfile::NamedTempFile;
7
8use crate::process::{ProcessIdentity, ProcessSource, ProcessState, SpawnError};
9use crate::resource::Resource;
10use crate::{ProcessPool, StreamItem};
11
12#[derive(Clone, Debug, PartialEq)]
13pub struct ProcessSpec {
14    pub identity: ProcessIdentity,
15    pub args: Vec<Resource>,
16    pub env: BTreeMap<String, Resource>,
17    pub current_dir: Option<PathBuf>,
18    pub props: Option<serde_json::Value>,
19}
20
21struct CachedResolution {
22    spec: ProcessSpec,
23    source: ProcessSource,
24    _handles: Vec<NamedTempFile>,
25}
26
27fn resolve_spec(spec: &ProcessSpec) -> Result<(ProcessSource, Vec<NamedTempFile>), std::io::Error> {
28    let mut args = Vec::new();
29    let mut handles = Vec::new();
30
31    for resource in &spec.args {
32        let resolved = resource.resolve()?;
33        args.push(resolved.value);
34        if let Some(h) = resolved.handle {
35            handles.push(h);
36        }
37    }
38
39    let mut env = BTreeMap::new();
40    for (key, resource) in &spec.env {
41        let resolved = resource.resolve()?;
42        env.insert(key.clone(), resolved.value);
43        if let Some(h) = resolved.handle {
44            handles.push(h);
45        }
46    }
47
48    let source = ProcessSource {
49        identity: spec.identity.clone(),
50        args,
51        env,
52        current_dir: spec.current_dir.clone(),
53        props: spec.props.clone(),
54    };
55
56    Ok((source, handles))
57}
58
59pub struct ProcessSupervisor {
60    pool: ProcessPool,
61    states: HashMap<ProcessIdentity, CachedResolution>,
62}
63
64impl ProcessSupervisor {
65    pub fn new(stream_tx: mpsc::Sender<StreamItem>) -> Self {
66        Self {
67            pool: ProcessPool::new(stream_tx),
68            states: HashMap::new(),
69        }
70    }
71
72    pub fn reconcile(
73        &mut self,
74        desired: Vec<ProcessSpec>,
75    ) -> ReconcileErrors<ProcessIdentity, SpawnError> {
76        let mut resolved = Vec::new();
77        let mut new_states: HashMap<ProcessIdentity, CachedResolution> = HashMap::new();
78        let mut errors = ReconcileErrors::new();
79        let mut needs_restart = Vec::new();
80
81        for spec in desired {
82            let identity = spec.identity.clone();
83
84            if let Some(cached) = self.states.remove(&identity) {
85                if cached.spec == spec {
86                    resolved.push(cached.source.clone());
87                    new_states.insert(identity, cached);
88                    continue;
89                }
90                needs_restart.push(identity.clone());
91            }
92
93            match resolve_spec(&spec) {
94                Ok((source, handles)) => {
95                    resolved.push(source.clone());
96                    new_states.insert(
97                        identity,
98                        CachedResolution {
99                            spec,
100                            source,
101                            _handles: handles,
102                        },
103                    );
104                }
105                Err(e) => {
106                    errors.push((identity, SpawnError::ResourceResolutionFailed { source: e }));
107                }
108            }
109        }
110
111        // ProcessSource::reconcile_self doesn't detect arg changes, so changed
112        // specs need an explicit exit-then-enter cycle. First pass excludes
113        // them (pool exits the old process), second pass includes them (pool
114        // enters the new one).
115        if !needs_restart.is_empty() {
116            let without: Vec<ProcessSource> = resolved
117                .iter()
118                .filter(|s| !needs_restart.contains(&s.identity))
119                .cloned()
120                .collect();
121            self.pool.reconcile(without);
122        }
123
124        let pool_errors = self.pool.reconcile(resolved);
125        errors.extend(pool_errors);
126
127        self.states = new_states;
128        errors
129    }
130
131    pub fn get(&self, identity: &ProcessIdentity) -> Option<&ProcessState> {
132        self.pool.get(identity)
133    }
134
135    pub fn iter(&self) -> impl Iterator<Item = (&ProcessIdentity, &ProcessState)> {
136        self.pool.iter()
137    }
138
139    /// Drains every tracked process by reconciling against an empty desired
140    /// set, driving each one through `Lifecycle::exit` (SIGTERM, then
141    /// SIGKILL after `SHUTDOWN_GRACE_PERIOD`).
142    ///
143    /// Dropping a `ProcessSupervisor` does not do this on its own: `Child`'s
144    /// own `Drop` never kills its process, so call this explicitly before
145    /// the owning program exits or re-execs — otherwise every process it's
146    /// still tracking is abandoned rather than torn down.
147    pub fn shutdown_all(&mut self) -> ReconcileErrors<ProcessIdentity, SpawnError> {
148        self.reconcile(vec![])
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use std::time::Duration;
156
157    fn wait_for_line(rx: &mpsc::Receiver<StreamItem>, timeout: Duration) -> Option<StreamItem> {
158        rx.recv_timeout(timeout).ok()
159    }
160
161    #[test]
162    fn string_args_work_identically_to_process_pool() {
163        let (tx, rx) = mpsc::channel();
164        let mut supervisor = ProcessSupervisor::new(tx);
165
166        let errors = supervisor.reconcile(vec![ProcessSpec {
167            identity: ProcessIdentity {
168                bin: "/bin/sh".into(),
169                key: "echo".into(),
170            },
171            args: vec!["-c".into(), "echo hello_from_supervisor".into()],
172            env: BTreeMap::new(),
173            current_dir: None,
174            props: None,
175        }]);
176        assert!(errors.is_empty());
177
178        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("should receive stdout");
179        assert_eq!(item.line, "hello_from_supervisor");
180    }
181
182    #[test]
183    fn file_resource_is_passed_as_executable_script() {
184        let (tx, rx) = mpsc::channel();
185        let mut supervisor = ProcessSupervisor::new(tx);
186
187        let errors = supervisor.reconcile(vec![ProcessSpec {
188            identity: ProcessIdentity {
189                bin: "/bin/sh".into(),
190                key: "file-test".into(),
191            },
192            args: vec![Resource::File {
193                content: "echo from_file_resource".into(),
194            }],
195            env: BTreeMap::new(),
196            current_dir: None,
197            props: None,
198        }]);
199        assert!(errors.is_empty());
200
201        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("should receive stdout");
202        assert_eq!(item.line, "from_file_resource");
203    }
204
205    #[test]
206    fn unchanged_spec_does_not_restart_process() {
207        let (tx, rx) = mpsc::channel();
208        let mut supervisor = ProcessSupervisor::new(tx);
209
210        let spec = ProcessSpec {
211            identity: ProcessIdentity {
212                bin: "/bin/sh".into(),
213                key: "stable".into(),
214            },
215            args: vec!["-c".into(), "echo started; sleep 60".into()],
216            env: BTreeMap::new(),
217            current_dir: None,
218            props: None,
219        };
220
221        supervisor.reconcile(vec![spec.clone()]);
222        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("first start");
223        assert_eq!(item.line, "started");
224
225        supervisor.reconcile(vec![spec]);
226        // If the process restarted, we'd see another "started" line.
227        let next = rx.recv_timeout(Duration::from_millis(300));
228        assert!(
229            next.is_err(),
230            "process should not have restarted for unchanged spec"
231        );
232    }
233
234    #[test]
235    fn changed_file_content_restarts_process() {
236        let (tx, rx) = mpsc::channel();
237        let mut supervisor = ProcessSupervisor::new(tx);
238
239        let mk = |content: &str| ProcessSpec {
240            identity: ProcessIdentity {
241                bin: "/bin/sh".into(),
242                key: "versioned".into(),
243            },
244            args: vec![Resource::File {
245                content: content.into(),
246            }],
247            env: BTreeMap::new(),
248            current_dir: None,
249            props: None,
250        };
251
252        supervisor.reconcile(vec![mk("echo v1")]);
253        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("v1");
254        assert_eq!(item.line, "v1");
255
256        supervisor.reconcile(vec![mk("echo v2")]);
257        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("v2");
258        assert_eq!(item.line, "v2");
259    }
260
261    #[test]
262    fn removing_process_cleans_up_file_resources() {
263        let (tx, _rx) = mpsc::channel();
264        let mut supervisor = ProcessSupervisor::new(tx);
265
266        let spec = ProcessSpec {
267            identity: ProcessIdentity {
268                bin: "/bin/sh".into(),
269                key: "cleanup".into(),
270            },
271            args: vec![Resource::File {
272                content: "sleep 60".into(),
273            }],
274            env: BTreeMap::new(),
275            current_dir: None,
276            props: None,
277        };
278
279        supervisor.reconcile(vec![spec]);
280
281        let file_paths: Vec<String> = supervisor
282            .states
283            .values()
284            .flat_map(|c| c._handles.iter())
285            .map(|h| h.path().to_string_lossy().into_owned())
286            .collect();
287        assert!(!file_paths.is_empty(), "should have at least one temp file");
288        for p in &file_paths {
289            assert!(
290                std::path::Path::new(p).exists(),
291                "file should exist while process is running"
292            );
293        }
294
295        supervisor.reconcile(vec![]);
296
297        for p in &file_paths {
298            assert!(
299                !std::path::Path::new(p).exists(),
300                "file should be cleaned up after process exits"
301            );
302        }
303    }
304
305    #[test]
306    fn shutdown_all_terminates_running_child_processes() {
307        let (tx, _rx) = mpsc::channel();
308        let mut supervisor = ProcessSupervisor::new(tx);
309
310        let spec = ProcessSpec {
311            identity: ProcessIdentity {
312                bin: "/bin/sh".into(),
313                key: "shutdown-test".into(),
314            },
315            args: vec!["-c".into(), "sleep 60".into()],
316            env: BTreeMap::new(),
317            current_dir: None,
318            props: None,
319        };
320        supervisor.reconcile(vec![spec]);
321
322        let pid = supervisor
323            .iter()
324            .next()
325            .expect("process should be tracked after reconcile")
326            .1
327            .child
328            .id();
329
330        let errors = supervisor.shutdown_all();
331        assert!(errors.is_empty());
332
333        let pid = nix::unistd::Pid::from_raw(pid as i32);
334        assert_eq!(
335            nix::sys::signal::kill(pid, None),
336            Err(nix::errno::Errno::ESRCH),
337            "child should have exited once shutdown_all returned"
338        );
339    }
340
341    #[test]
342    fn shutdown_all_terminates_every_tracked_process() {
343        let (tx, _rx) = mpsc::channel();
344        let mut supervisor = ProcessSupervisor::new(tx);
345
346        let mk = |key: &str| ProcessSpec {
347            identity: ProcessIdentity {
348                bin: "/bin/sh".into(),
349                key: key.into(),
350            },
351            args: vec!["-c".into(), "sleep 60".into()],
352            env: BTreeMap::new(),
353            current_dir: None,
354            props: None,
355        };
356        supervisor.reconcile(vec![mk("a"), mk("b"), mk("c")]);
357
358        let pids: Vec<i32> = supervisor
359            .iter()
360            .map(|(_, state)| state.child.id() as i32)
361            .collect();
362        assert_eq!(pids.len(), 3, "all three processes should be tracked");
363
364        let errors = supervisor.shutdown_all();
365        assert!(errors.is_empty());
366
367        for pid in pids {
368            let pid = nix::unistd::Pid::from_raw(pid);
369            assert_eq!(
370                nix::sys::signal::kill(pid, None),
371                Err(nix::errno::Errno::ESRCH),
372                "every tracked process should have exited, pid {pid} did not"
373            );
374        }
375        assert_eq!(
376            supervisor.iter().count(),
377            0,
378            "supervisor should track nothing after shutdown_all"
379        );
380    }
381
382    #[test]
383    fn shutdown_all_on_empty_supervisor_is_a_no_op() {
384        let (tx, _rx) = mpsc::channel();
385        let mut supervisor = ProcessSupervisor::new(tx);
386
387        let errors = supervisor.shutdown_all();
388        assert!(errors.is_empty());
389    }
390}