Skip to main content

lean_ctx/server/
background_shell.rs

1//! Explicit, in-process background jobs for `ctx_shell`.
2//!
3//! The MCP request may complete immediately, but the child keeps the exact
4//! timeout, allow-list, path-jail and process-group policy of foreground shell
5//! execution. Jobs intentionally live in the daemon: restarting it invalidates
6//! outstanding jobs rather than silently orphaning subprocesses.
7
8use 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    // #1217: shared buffer the worker streams captured output into while the
31    // job runs, so `status` can report progress before the job completes.
32    live: Arc<Mutex<String>>,
33}
34
35static JOBS: LazyLock<Mutex<HashMap<String, Job>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
36
37/// How often a foreground run reports progress to the MCP client (#1173).
38const 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    // IDs are content-addressed so tool responses stay deterministic (#498).
95    // An identical in-flight launch coalesces onto the same job instead of
96    // creating duplicate expensive builds/tests.
97    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            // #1113/#1173: bounded by output, not wall clock — a monitor loop
149            // emitting a line every 45s must survive. See `idle_keyed`.
150            true,
151            // #1217: stream captured output into the shared buffer as it arrives.
152            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
171/// Outcome of a foreground run that is allowed to detach on a soft cap.
172pub enum ForegroundResult {
173    /// The command finished within the soft cap; output is returned inline and
174    /// the job has been removed from the registry.
175    Finished { output: String, exit_code: i32 },
176    /// The command was still running at the soft cap and was left running as a
177    /// pollable background job (#1106).
178    Detached { job_id: String },
179}
180
181/// Run `command` as a managed background job but block up to `soft_cap` waiting
182/// for it to finish, so fast commands still return their output inline.
183///
184/// The MCP host aborts a tool call that stays in the foreground too long
185/// (~120s) and hands back a task id that `background_action=status` cannot
186/// resolve. By detaching *before* that deadline we always return a real
187/// `shell_*` job id the caller can poll or cancel (#1106).
188///
189/// `on_tick` is called roughly every `TICK` while the command is still
190/// running, so the caller can emit MCP progress notifications and a 3-minute
191/// build stops being indistinguishable from a hang (#1173).
192pub 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            // A cancel can only be requested via background_action once the job
211            // is detached, so an inline wait realistically only sees Completed;
212            // handle Cancelled defensively with the timeout exit code.
213            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
236/// Drop a finished job from the registry so inline foreground runs do not
237/// accumulate completed entries.
238fn 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        // #1217: a running job's output lives in the shared buffer the worker
251        // streams into; surface the captured-so-far snapshot on each poll.
252        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        // The returned id must resolve via status — the core #1106 guarantee.
389        assert!(status(&job_id).is_some());
390        cancel(&job_id);
391    }
392
393    /// #1173: a `timeout_ms` far beyond the soft cap must NOT keep the command
394    /// in the foreground. Raising the cap past the MCP host's ~120s abort is
395    /// what stranded results behind an unresolvable task id; the caller must
396    /// still get a real `shell_*` job id at the cap.
397    #[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    /// #1173: a foreground run reports progress while it waits, so a slow
416    /// command is distinguishable from a hang. Deliberately slower than the
417    /// other tests here — it has to outlive one real [`TICK`].
418    #[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    /// #1217: a still-running job's `status` must surface the captured-so-far
487    /// output, not a bare "running" with no signal of progress.
488    #[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}