1use std::collections::HashMap;
9use std::sync::{
10 Arc, LazyLock, Mutex,
11 atomic::{AtomicBool, Ordering},
12};
13use std::time::{Duration, Instant};
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum JobState {
17 Running { output: String },
18 Completed { output: String, exit_code: i32 },
19 Cancelled { output: String },
20}
21
22const MAX_RETAINED_COMPLETED_JOBS: usize = 64;
23const MAX_RETAINED_COMPLETED_BYTES: usize = 16 * 1024 * 1024;
24const COMPLETED_JOB_TTL: Duration = Duration::from_mins(5);
25
26struct Job {
27 cancel: Arc<AtomicBool>,
28 state: JobState,
29 finished_at: Option<Instant>,
30 live: Arc<Mutex<String>>,
33}
34
35static JOBS: LazyLock<Mutex<HashMap<String, Job>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
36
37const TICK: Duration = Duration::from_secs(5);
39
40fn prune_finished_jobs(jobs: &mut HashMap<String, Job>, now: Instant) {
41 prune_finished_jobs_with_limits(
42 jobs,
43 now,
44 MAX_RETAINED_COMPLETED_JOBS,
45 MAX_RETAINED_COMPLETED_BYTES,
46 );
47}
48
49fn prune_finished_jobs_with_limits(
50 jobs: &mut HashMap<String, Job>,
51 now: Instant,
52 max_completed_jobs: usize,
53 max_completed_bytes: usize,
54) {
55 jobs.retain(|_, job| {
56 job.finished_at
57 .is_none_or(|finished_at| now.duration_since(finished_at) < COMPLETED_JOB_TTL)
58 });
59
60 let mut completed: Vec<_> = jobs
61 .iter()
62 .filter_map(|(id, job)| {
63 let finished_at = job.finished_at?;
64 let output_bytes = match &job.state {
65 JobState::Completed { output, .. } | JobState::Cancelled { output } => output.len(),
66 JobState::Running { .. } => 0,
67 };
68 Some((finished_at, id.clone(), output_bytes))
69 })
70 .collect();
71 completed.sort_unstable_by_key(|(finished_at, _, _)| *finished_at);
72
73 let mut retained_bytes = completed.iter().map(|(_, _, bytes)| bytes).sum::<usize>();
74 let mut retained_jobs = completed.len();
75 for (_, id, output_bytes) in completed {
76 if retained_jobs <= max_completed_jobs && retained_bytes <= max_completed_bytes {
77 break;
78 }
79 if retained_jobs == 1 {
80 break;
81 }
82 jobs.remove(&id);
83 retained_jobs -= 1;
84 retained_bytes = retained_bytes.saturating_sub(output_bytes);
85 }
86}
87
88pub fn start(
89 command: String,
90 cwd: String,
91 extra_env: std::collections::HashMap<String, String>,
92 timeout_ms: Option<u64>,
93) -> String {
94 let mut env_entries: Vec<_> = extra_env.iter().collect();
98 env_entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
99 let env_key = env_entries
100 .into_iter()
101 .map(|(key, value)| format!("{key}={value}"))
102 .collect::<Vec<_>>()
103 .join("\0");
104 let material = format!(
105 "{command}\0{cwd}\0{}\0{env_key}",
106 timeout_ms.unwrap_or_default()
107 );
108 let id = format!(
109 "shell_{}",
110 &blake3::hash(material.as_bytes()).to_hex()[..16]
111 );
112 let cancel = Arc::new(AtomicBool::new(false));
113 let worker_cancel = Arc::clone(&cancel);
114 let live = Arc::new(Mutex::new(String::new()));
115 let worker_live = Arc::clone(&live);
116 {
117 let mut jobs = JOBS
118 .lock()
119 .unwrap_or_else(std::sync::PoisonError::into_inner);
120 prune_finished_jobs(&mut jobs, Instant::now());
121 if matches!(
122 jobs.get(&id).map(|job| &job.state),
123 Some(JobState::Running { .. })
124 ) {
125 return id;
126 }
127 jobs.insert(
128 id.clone(),
129 Job {
130 cancel,
131 state: JobState::Running {
132 output: String::new(),
133 },
134 finished_at: None,
135 live,
136 },
137 );
138 }
139
140 let worker_id = id.clone();
141 std::thread::spawn(move || {
142 let (output, exit_code) = crate::server::execute::execute_command_with_env_cancellable(
143 &command,
144 &cwd,
145 &extra_env,
146 timeout_ms,
147 Some(&worker_cancel),
148 true,
151 Some(&worker_live),
153 );
154 let mut jobs = JOBS
155 .lock()
156 .unwrap_or_else(std::sync::PoisonError::into_inner);
157 let Some(job) = jobs.get_mut(&worker_id) else {
158 return;
159 };
160 job.state = if worker_cancel.load(Ordering::Acquire) {
161 JobState::Cancelled { output }
162 } else {
163 JobState::Completed { output, exit_code }
164 };
165 job.finished_at = Some(Instant::now());
166 prune_finished_jobs(&mut jobs, Instant::now());
167 });
168 id
169}
170
171pub enum ForegroundResult {
173 Finished { output: String, exit_code: i32 },
176 Detached { job_id: String },
179}
180
181pub fn run_foreground_or_detach(
193 command: String,
194 cwd: String,
195 extra_env: std::collections::HashMap<String, String>,
196 timeout_ms: Option<u64>,
197 soft_cap: Duration,
198 on_tick: Option<&dyn Fn(Duration)>,
199) -> ForegroundResult {
200 let id = start(command, cwd, extra_env, timeout_ms);
201 let started = Instant::now();
202 let deadline = started + soft_cap;
203 let mut next_tick = started + TICK;
204 loop {
205 match status(&id) {
206 Some(JobState::Completed { output, exit_code }) => {
207 remove(&id);
208 return ForegroundResult::Finished { output, exit_code };
209 }
210 Some(JobState::Cancelled { output }) => {
214 remove(&id);
215 return ForegroundResult::Finished {
216 output,
217 exit_code: 130,
218 };
219 }
220 _ => {}
221 }
222 let now = Instant::now();
223 if now >= deadline {
224 return ForegroundResult::Detached { job_id: id };
225 }
226 if let Some(tick) = on_tick
227 && now >= next_tick
228 {
229 tick(started.elapsed());
230 next_tick = now + TICK;
231 }
232 std::thread::sleep(Duration::from_millis(50));
233 }
234}
235
236fn remove(id: &str) {
239 JOBS.lock()
240 .unwrap_or_else(std::sync::PoisonError::into_inner)
241 .remove(id);
242}
243
244pub fn status(id: &str) -> Option<JobState> {
245 let jobs = JOBS
246 .lock()
247 .unwrap_or_else(std::sync::PoisonError::into_inner);
248 let job = jobs.get(id)?;
249 Some(match &job.state {
250 JobState::Running { .. } => JobState::Running {
253 output: job
254 .live
255 .lock()
256 .unwrap_or_else(std::sync::PoisonError::into_inner)
257 .clone(),
258 },
259 other => other.clone(),
260 })
261}
262
263pub fn cancel(id: &str) -> Option<JobState> {
264 let mut jobs = JOBS
265 .lock()
266 .unwrap_or_else(std::sync::PoisonError::into_inner);
267 let job = jobs.get_mut(id)?;
268 if matches!(job.state, JobState::Running { .. }) {
269 job.cancel.store(true, Ordering::Release);
270 }
271 Some(job.state.clone())
272}
273
274#[cfg(test)]
275mod tests {
276 use super::{
277 ForegroundResult, JobState, TICK, cancel, run_foreground_or_detach, start, status,
278 };
279 use std::time::Duration;
280
281 #[test]
282 fn completed_job_retention_is_bounded() {
283 let now = std::time::Instant::now();
284 let mut jobs = std::collections::HashMap::new();
285 for index in 0..=super::MAX_RETAINED_COMPLETED_JOBS {
286 jobs.insert(
287 format!("job_{index}"),
288 super::Job {
289 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
290 state: JobState::Completed {
291 output: "x".repeat(1024),
292 exit_code: 0,
293 },
294 finished_at: Some(
295 now.checked_sub(Duration::from_secs((index + 1) as u64))
296 .unwrap(),
297 ),
298 live: std::sync::Arc::new(std::sync::Mutex::new(String::new())),
299 },
300 );
301 }
302 jobs.insert(
303 "expired".to_string(),
304 super::Job {
305 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
306 state: JobState::Completed {
307 output: "expired".to_string(),
308 exit_code: 0,
309 },
310 finished_at: Some(
311 now.checked_sub(super::COMPLETED_JOB_TTL + Duration::from_secs(1))
312 .unwrap(),
313 ),
314 live: std::sync::Arc::new(std::sync::Mutex::new(String::new())),
315 },
316 );
317
318 super::prune_finished_jobs(&mut jobs, now);
319
320 assert_eq!(jobs.len(), super::MAX_RETAINED_COMPLETED_JOBS);
321 assert!(!jobs.contains_key("expired"));
322 assert!(!jobs.contains_key(&format!("job_{}", super::MAX_RETAINED_COMPLETED_JOBS)));
323 }
324
325 #[test]
326 fn completed_job_output_bytes_are_bounded() {
327 let now = std::time::Instant::now();
328 let mut jobs = std::collections::HashMap::new();
329 for index in 0..3 {
330 jobs.insert(
331 format!("job_{index}"),
332 super::Job {
333 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
334 state: JobState::Completed {
335 output: "x".repeat(8),
336 exit_code: 0,
337 },
338 finished_at: Some(
339 now.checked_sub(Duration::from_secs((3 - index) as u64))
340 .unwrap(),
341 ),
342 live: std::sync::Arc::new(std::sync::Mutex::new(String::new())),
343 },
344 );
345 }
346
347 super::prune_finished_jobs_with_limits(&mut jobs, now, 10, 16);
348
349 assert_eq!(jobs.len(), 2);
350 assert!(!jobs.contains_key("job_0"));
351 }
352
353 #[test]
354 #[cfg_attr(windows, ignore)]
355 fn foreground_run_finishing_within_cap_returns_inline() {
356 let result = run_foreground_or_detach(
357 "printf FG_OK".to_string(),
358 ".".to_string(),
359 std::collections::HashMap::default(),
360 Some(10_000),
361 Duration::from_secs(10),
362 None,
363 );
364 match result {
365 ForegroundResult::Finished { output, exit_code } => {
366 assert_eq!(exit_code, 0);
367 assert!(output.contains("FG_OK"));
368 }
369 ForegroundResult::Detached { .. } => panic!("fast command should not detach"),
370 }
371 }
372
373 #[test]
374 #[cfg_attr(windows, ignore)]
375 fn foreground_run_exceeding_cap_detaches_to_pollable_job() {
376 let result = run_foreground_or_detach(
377 "sleep 5; printf SLOW_OK".to_string(),
378 ".".to_string(),
379 std::collections::HashMap::default(),
380 Some(10_000),
381 Duration::from_millis(100),
382 None,
383 );
384 let ForegroundResult::Detached { job_id } = result else {
385 panic!("slow command should detach");
386 };
387 assert!(job_id.starts_with("shell_"));
388 assert!(status(&job_id).is_some());
390 cancel(&job_id);
391 }
392
393 #[test]
398 #[cfg_attr(windows, ignore)]
399 fn large_timeout_ms_still_detaches_at_the_soft_cap() {
400 let result = run_foreground_or_detach(
401 "sleep 5; printf NEVER_INLINE".to_string(),
402 ".".to_string(),
403 std::collections::HashMap::default(),
404 Some(600_000),
405 Duration::from_millis(100),
406 None,
407 );
408 let ForegroundResult::Detached { job_id } = result else {
409 panic!("timeout_ms must not extend the foreground wait");
410 };
411 assert!(status(&job_id).is_some());
412 cancel(&job_id);
413 }
414
415 #[test]
419 #[cfg_attr(windows, ignore)]
420 fn foreground_run_reports_progress_while_waiting() {
421 let ticks = std::sync::atomic::AtomicUsize::new(0);
422 let tick = |elapsed: Duration| {
423 assert!(elapsed >= TICK, "tick must report real elapsed time");
424 ticks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
425 };
426 let result = run_foreground_or_detach(
427 "sleep 30".to_string(),
428 ".".to_string(),
429 std::collections::HashMap::default(),
430 Some(60_000),
431 TICK + Duration::from_millis(500),
432 Some(&tick),
433 );
434 let ForegroundResult::Detached { job_id } = result else {
435 panic!("slow command should detach");
436 };
437 cancel(&job_id);
438 assert!(
439 ticks.load(std::sync::atomic::Ordering::Relaxed) >= 1,
440 "no progress reported during a {}s+ foreground wait",
441 TICK.as_secs()
442 );
443 }
444
445 #[test]
446 #[cfg_attr(windows, ignore)]
447 fn background_job_runs_past_request_and_can_be_observed() {
448 let id = start(
449 "sleep 0.1; printf BG_JOB_OK".to_string(),
450 ".".to_string(),
451 std::collections::HashMap::default(),
452 Some(10_000),
453 );
454 assert!(matches!(status(&id), Some(JobState::Running { .. })));
455 for _ in 0..40 {
456 if let Some(JobState::Completed { output, exit_code }) = status(&id) {
457 assert_eq!(exit_code, 0);
458 assert!(output.contains("BG_JOB_OK"));
459 return;
460 }
461 std::thread::sleep(Duration::from_millis(25));
462 }
463 panic!("background job did not complete");
464 }
465
466 #[test]
467 #[cfg_attr(windows, ignore)]
468 fn cancelling_background_job_returns_cancelled_state() {
469 let id = start(
470 "sleep 5".to_string(),
471 ".".to_string(),
472 std::collections::HashMap::default(),
473 Some(10_000),
474 );
475 assert!(matches!(cancel(&id), Some(JobState::Running { .. })));
476 for _ in 0..40 {
477 if let Some(JobState::Cancelled { output }) = status(&id) {
478 assert!(output.contains("[cancelled: command stopped on request]"));
479 return;
480 }
481 std::thread::sleep(Duration::from_millis(25));
482 }
483 panic!("background job was not cancelled");
484 }
485
486 #[test]
489 #[cfg_attr(windows, ignore)]
490 fn running_background_job_status_streams_partial_output() {
491 let id = start(
492 "printf EARLY_LINE; sleep 5".to_string(),
493 ".".to_string(),
494 std::collections::HashMap::default(),
495 Some(10_000),
496 );
497 let mut saw_partial = false;
498 for _ in 0..80 {
499 if let Some(JobState::Running { output }) = status(&id)
500 && output.contains("EARLY_LINE")
501 {
502 saw_partial = true;
503 break;
504 }
505 std::thread::sleep(Duration::from_millis(25));
506 }
507 cancel(&id);
508 assert!(
509 saw_partial,
510 "status never surfaced the running job's early output"
511 );
512 }
513}