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 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 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 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}