1use std::collections::HashMap;
10use std::path::Path;
11use std::sync::{
12 Arc, LazyLock, Mutex,
13 atomic::{AtomicBool, AtomicU64, Ordering},
14 mpsc,
15};
16use std::time::{Duration, Instant};
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum JobState {
20 Running { output: String },
21 Completed { output: String, exit_code: i32 },
22 Cancelled { output: String },
23}
24
25const MAX_RETAINED_COMPLETED_JOBS: usize = 64;
26const MAX_RETAINED_COMPLETED_BYTES: usize = 16 * 1024 * 1024;
27const MAX_FOREGROUND_OUTPUT_RECEIVERS: usize = 64;
28const COMPLETED_JOB_TTL: Duration = Duration::from_mins(5);
29
30struct Job {
31 cancel: Arc<AtomicBool>,
32 state: JobState,
33 finished_at: Option<Instant>,
34 generation: u64,
36 actual_exit_code: i32,
37 completeness: lean_ctx::core::context_admission::OutputCompleteness,
38 output_withheld: bool,
39 notice: Option<&'static str>,
43 foreground_senders: Vec<mpsc::SyncSender<Arc<crate::server::execute::ShellExecution>>>,
46 foreground_waiters: usize,
49 background_owned: bool,
50 telemetry_recorded: bool,
51}
52
53static JOBS: LazyLock<Mutex<HashMap<String, Job>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
54static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
55
56const TICK: Duration = Duration::from_secs(5);
58
59fn prune_finished_jobs(jobs: &mut HashMap<String, Job>, now: Instant) {
60 prune_finished_jobs_with_limits(
61 jobs,
62 now,
63 MAX_RETAINED_COMPLETED_JOBS,
64 MAX_RETAINED_COMPLETED_BYTES,
65 );
66}
67
68fn prune_finished_jobs_with_limits(
69 jobs: &mut HashMap<String, Job>,
70 now: Instant,
71 max_completed_jobs: usize,
72 max_completed_bytes: usize,
73) {
74 jobs.retain(|_, job| {
75 job.foreground_waiters > 0
76 || job
77 .finished_at
78 .is_none_or(|finished_at| now.duration_since(finished_at) < COMPLETED_JOB_TTL)
79 });
80
81 let mut completed: Vec<_> = jobs
82 .iter()
83 .filter(|(_, job)| job.foreground_waiters == 0)
84 .filter_map(|(id, job)| {
85 let finished_at = job.finished_at?;
86 let output_bytes = match &job.state {
87 JobState::Completed { output, .. } | JobState::Cancelled { output } => output.len(),
88 JobState::Running { .. } => 0,
89 };
90 Some((finished_at, id.clone(), output_bytes))
91 })
92 .collect();
93 completed.sort_unstable_by_key(|(finished_at, _, _)| *finished_at);
94
95 let mut retained_bytes = completed.iter().map(|(_, _, bytes)| bytes).sum::<usize>();
96 let mut retained_jobs = completed.len();
97 for (_, id, output_bytes) in completed {
98 if retained_jobs <= max_completed_jobs && retained_bytes <= max_completed_bytes {
99 break;
100 }
101 if retained_jobs == 1 {
102 break;
103 }
104 jobs.remove(&id);
105 retained_jobs -= 1;
106 retained_bytes = retained_bytes.saturating_sub(output_bytes);
107 }
108}
109
110pub fn start(
111 command: String,
112 cwd: String,
113 extra_env: std::collections::HashMap<String, String>,
114 timeout_ms: Option<u64>,
115) -> String {
116 start_inner(command, cwd, extra_env, timeout_ms, false)
117}
118
119fn start_inner(
120 command: String,
121 cwd: String,
122 extra_env: std::collections::HashMap<String, String>,
123 timeout_ms: Option<u64>,
124 foreground: bool,
125) -> String {
126 start_inner_with_sender(command, cwd, extra_env, timeout_ms, foreground, None)
127}
128
129fn start_inner_with_sender(
130 command: String,
131 cwd: String,
132 extra_env: std::collections::HashMap<String, String>,
133 timeout_ms: Option<u64>,
134 foreground: bool,
135 output_sender: Option<mpsc::SyncSender<Arc<crate::server::execute::ShellExecution>>>,
136) -> String {
137 let mut env_entries: Vec<_> = extra_env.iter().collect();
141 env_entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
142 let env_key = env_entries
143 .into_iter()
144 .map(|(key, value)| format!("{key}={value}"))
145 .collect::<Vec<_>>()
146 .join("\0");
147 let material = format!(
148 "{command}\0{cwd}\0{}\0{env_key}",
149 timeout_ms.unwrap_or_default()
150 );
151 let id = format!(
152 "shell_{}",
153 &blake3::hash(material.as_bytes()).to_hex()[..16]
154 );
155 let cancel = Arc::new(AtomicBool::new(false));
156 let worker_cancel = Arc::clone(&cancel);
157 {
158 let mut jobs = JOBS
159 .lock()
160 .unwrap_or_else(std::sync::PoisonError::into_inner);
161 prune_finished_jobs(&mut jobs, Instant::now());
162 if let Some(job) = jobs.get_mut(&id)
166 && (matches!(job.state, JobState::Running { .. }) || job.foreground_waiters > 0)
167 {
168 if foreground {
169 job.foreground_waiters += 1;
170 if matches!(job.state, JobState::Running { .. })
171 && job.foreground_senders.len() < MAX_FOREGROUND_OUTPUT_RECEIVERS
172 && let Some(sender) = output_sender
173 {
174 job.foreground_senders.push(sender);
175 }
176 } else {
177 job.background_owned = true;
178 }
179 return id;
180 }
181 jobs.insert(
182 id.clone(),
183 Job {
184 cancel,
185 state: JobState::Running {
186 output: String::new(),
187 },
188 finished_at: None,
189 generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed),
190 actual_exit_code: 0,
191 completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
192 output_withheld: false,
193 notice: None,
194 foreground_senders: output_sender.into_iter().collect(),
195 foreground_waiters: usize::from(foreground),
196 background_owned: !foreground,
197 telemetry_recorded: false,
198 },
199 );
200 }
201
202 let worker_id = id.clone();
203 let project_root = lean_ctx::core::policy::runtime::detached_project_root(Path::new(&cwd)).ok();
204 std::thread::spawn(move || {
205 let execution = crate::server::execute::execute_command_with_env_cancellable_captured(
206 &command,
207 &cwd,
208 &extra_env,
209 timeout_ms,
210 Some(&worker_cancel),
211 true,
214 );
215 let store = lean_ctx::core::context_admission::stores::StoreAdmission::current();
216 let stored_output = project_root.as_deref().and_then(|root| {
217 let root = root.to_str()?;
218 lean_ctx::core::policy::runtime::with_project_source_view(root, || {
219 let policy_outcome = lean_ctx::core::policy::runtime::active().map(|policy| {
224 lean_ctx::core::policy::content::evaluate_text(&execution.output, &policy)
225 });
226 let stored =
229 store.admit_background_output(&execution.output, execution.completeness);
230 if let Some(outcome) = policy_outcome
231 && !outcome.audit.is_empty()
232 && (outcome.blocked
233 || outcome.text != execution.output
234 || stored.as_deref() != Some(execution.output.as_str()))
235 {
236 crate::server::policy_guard::audit_filter(
237 "ctx_shell",
238 &outcome.audit,
239 outcome.blocked,
240 );
241 }
242 stored
243 })
244 .ok()
245 .flatten()
246 });
247 let mut jobs = JOBS
248 .lock()
249 .unwrap_or_else(std::sync::PoisonError::into_inner);
250 let Some(job) = jobs.get_mut(&worker_id) else {
251 return;
252 };
253 let stored_output = stored_output.filter(|_| store.is_current());
256 let cancelled = worker_cancel.load(Ordering::Acquire);
257 let foreground_result = Arc::new(execution);
258 for sender in job.foreground_senders.drain(..) {
259 let _ = sender.send(Arc::clone(&foreground_result));
260 }
261 let output_withheld = stored_output.is_none();
262 let output = stored_output.unwrap_or_default();
263 let exit_code = foreground_result.exit_code;
264 job.actual_exit_code = exit_code;
265 job.completeness = foreground_result.completeness;
266 job.output_withheld = output_withheld;
267 job.notice = foreground_result.notice;
268 job.state = if cancelled {
269 JobState::Cancelled { output }
270 } else {
271 JobState::Completed { output, exit_code }
272 };
273 job.finished_at = Some(Instant::now());
274 if !cancelled && exit_code != 0 && job.background_owned {
280 let category = if exit_code == 124 {
281 lean_ctx::core::telemetry_v2::ErrorCategory::Timeout
282 } else {
283 lean_ctx::core::telemetry_v2::ErrorCategory::Command
284 };
285 if lean_ctx::core::telemetry_aggregate::record_error_category(category).is_ok() {
286 job.telemetry_recorded = true;
287 }
288 }
289 prune_finished_jobs(&mut jobs, Instant::now());
290 });
291 id
292}
293
294pub enum ForegroundResult {
296 Finished { output: String, exit_code: i32 },
299 Detached { job_id: String },
302}
303
304pub fn run_foreground_or_detach(
306 command: String,
307 cwd: String,
308 extra_env: std::collections::HashMap<String, String>,
309 timeout_ms: Option<u64>,
310 soft_cap: Duration,
311 on_tick: Option<&dyn Fn(Duration)>,
312 cancel: Option<&tokio_util::sync::CancellationToken>,
313) -> ForegroundResult {
314 match run_foreground_or_detach_captured(
315 command, cwd, extra_env, timeout_ms, soft_cap, on_tick, cancel,
316 ) {
317 CapturedForegroundResult::Finished {
318 output,
319 exit_code,
320 notice,
321 ..
322 } => ForegroundResult::Finished {
323 output: crate::server::execute::with_notice(notice, output),
326 exit_code,
327 },
328 CapturedForegroundResult::Detached { job_id } => ForegroundResult::Detached { job_id },
329 }
330}
331
332pub(crate) enum CapturedForegroundResult {
334 Finished {
335 output: String,
336 exit_code: i32,
337 completeness: lean_ctx::core::context_admission::OutputCompleteness,
338 retained_withheld: bool,
339 notice: Option<&'static str>,
341 },
342 Detached {
343 job_id: String,
344 },
345}
346
347pub(crate) fn run_foreground_or_detach_captured(
350 command: String,
351 cwd: String,
352 extra_env: std::collections::HashMap<String, String>,
353 timeout_ms: Option<u64>,
354 soft_cap: Duration,
355 on_tick: Option<&dyn Fn(Duration)>,
356 cancel: Option<&tokio_util::sync::CancellationToken>,
357) -> CapturedForegroundResult {
358 let (sender, receiver) = mpsc::sync_channel(1);
359 let id = start_inner_with_sender(command, cwd, extra_env, timeout_ms, true, Some(sender));
360 let started = Instant::now();
361 let deadline = started + soft_cap;
362 let mut next_tick = started + TICK;
363 loop {
364 if let Ok(execution) = receiver.try_recv() {
365 release_foreground(&id, false);
366 return CapturedForegroundResult::Finished {
367 output: execution.output.clone(),
368 exit_code: execution.exit_code,
369 completeness: execution.completeness,
370 retained_withheld: false,
371 notice: execution.notice,
372 };
373 }
374 if let Some(snapshot) = snapshot(&id) {
375 match snapshot.state {
376 JobState::Completed { output, exit_code } => {
377 release_foreground(&id, false);
378 return CapturedForegroundResult::Finished {
379 output,
380 exit_code,
381 completeness: snapshot.completeness,
382 retained_withheld: snapshot.output_withheld,
383 notice: snapshot.notice,
384 };
385 }
386 JobState::Cancelled { output } => {
387 release_foreground(&id, false);
388 return CapturedForegroundResult::Finished {
389 output,
390 exit_code: snapshot.actual_exit_code,
391 completeness: snapshot.completeness,
392 retained_withheld: snapshot.output_withheld,
393 notice: snapshot.notice,
394 };
395 }
396 JobState::Running { .. } => {}
397 }
398 }
399 if cancel.is_some_and(tokio_util::sync::CancellationToken::is_cancelled) {
400 release_foreground(&id, true);
401 return CapturedForegroundResult::Detached { job_id: id };
402 }
403 let now = Instant::now();
404 if now >= deadline {
405 release_foreground(&id, true);
406 return CapturedForegroundResult::Detached { job_id: id };
407 }
408 if let Some(tick) = on_tick
409 && now >= next_tick
410 {
411 tick(started.elapsed());
412 next_tick = now + TICK;
413 }
414 std::thread::sleep(Duration::from_millis(50));
415 }
416}
417
418fn release_foreground(id: &str, detached: bool) {
423 let mut jobs = JOBS
424 .lock()
425 .unwrap_or_else(std::sync::PoisonError::into_inner);
426 let Some(job) = jobs.get_mut(id) else {
427 return;
428 };
429 job.foreground_waiters = job.foreground_waiters.saturating_sub(1);
430 if detached {
431 job.background_owned = true;
432 } else if job.foreground_waiters == 0 && !job.background_owned {
433 jobs.remove(id);
434 }
435}
436
437#[cfg(test)]
438pub(crate) fn remove_for_test(id: &str) {
439 JOBS.lock()
440 .unwrap_or_else(std::sync::PoisonError::into_inner)
441 .remove(id);
442}
443
444pub fn running_count() -> usize {
447 JOBS.lock()
448 .unwrap_or_else(std::sync::PoisonError::into_inner)
449 .values()
450 .filter(|job| matches!(job.state, JobState::Running { .. }))
451 .count()
452}
453
454#[derive(Debug, Clone)]
455pub(crate) struct JobSnapshot {
456 pub(crate) state: JobState,
457 pub(crate) generation: u64,
458 pub(crate) completeness: lean_ctx::core::context_admission::OutputCompleteness,
459 pub(crate) output_withheld: bool,
460 pub(crate) actual_exit_code: i32,
461 pub(crate) notice: Option<&'static str>,
463}
464
465pub(crate) fn snapshot(id: &str) -> Option<JobSnapshot> {
468 let jobs = JOBS
469 .lock()
470 .unwrap_or_else(std::sync::PoisonError::into_inner);
471 let job = jobs.get(id)?;
472 Some(JobSnapshot {
473 state: job.state.clone(),
474 generation: job.generation,
475 completeness: job.completeness,
476 output_withheld: job.output_withheld,
477 actual_exit_code: job.actual_exit_code,
478 notice: job.notice,
479 })
480}
481
482pub fn status(id: &str) -> Option<JobState> {
483 snapshot(id).map(|snapshot| snapshot.state)
484}
485
486pub(crate) fn replace_retained_output_if_generation(
490 id: &str,
491 generation: u64,
492 expected: &str,
493 expected_epoch: u64,
494 replacement: Option<&str>,
495) -> bool {
496 let mut jobs = JOBS
497 .lock()
498 .unwrap_or_else(std::sync::PoisonError::into_inner);
499 let Some(job) = jobs.get_mut(id) else {
500 return false;
501 };
502 if job.generation != generation
503 || (replacement.is_some()
504 && lean_ctx::core::context_admission::stores::current_epoch() != expected_epoch)
505 {
506 return false;
507 }
508 let current = match &job.state {
509 JobState::Completed { output, .. } | JobState::Cancelled { output } => output,
510 JobState::Running { .. } => return false,
511 };
512 if current != expected {
513 return false;
514 }
515 match &mut job.state {
516 JobState::Completed { output, .. } | JobState::Cancelled { output } => {
517 output.clear();
518 if let Some(replacement) = replacement {
519 output.push_str(replacement);
520 }
521 }
522 JobState::Running { .. } => return false,
523 }
524 job.output_withheld = replacement.is_none();
525 true
526}
527
528pub fn record_error_telemetry_once(
532 id: &str,
533 category: lean_ctx::core::telemetry_v2::ErrorCategory,
534) -> Result<bool, String> {
535 let mut jobs = JOBS
536 .lock()
537 .unwrap_or_else(std::sync::PoisonError::into_inner);
538 let Some(job) = jobs.get_mut(id) else {
539 return Ok(false);
540 };
541 if job.telemetry_recorded
542 || !matches!(job.state, JobState::Completed { exit_code, .. } if exit_code != 0)
543 {
544 return Ok(false);
545 }
546 lean_ctx::core::telemetry_aggregate::record_error_category(category)?;
547 job.telemetry_recorded = true;
548 Ok(true)
549}
550
551pub(crate) fn cancel_snapshot(id: &str) -> Option<JobSnapshot> {
552 {
553 let mut jobs = JOBS
554 .lock()
555 .unwrap_or_else(std::sync::PoisonError::into_inner);
556 let job = jobs.get_mut(id)?;
557 if matches!(job.state, JobState::Running { .. }) {
558 job.cancel.store(true, Ordering::Release);
559 }
560 }
561 snapshot(id)
562}
563
564pub fn cancel(id: &str) -> Option<JobState> {
565 cancel_snapshot(id).map(|snapshot| snapshot.state)
566}
567
568#[cfg(test)]
569mod tests {
570 use super::{
571 ForegroundResult, JobState, TICK, cancel, run_foreground_or_detach, start, status,
572 };
573 use std::time::Duration;
574
575 fn retained_text_held_still() -> (
582 lean_ctx::core::data_dir::IsolatedDataDir,
583 lean_ctx::core::policy::runtime::TestPolicyOverride,
584 ) {
585 let data = lean_ctx::core::data_dir::isolated_data_dir();
586 let community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
587 (data, community)
588 }
589
590 fn completed_job(exit_code: i32) -> super::Job {
591 super::Job {
592 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
593 state: JobState::Completed {
594 output: "opaque output".into(),
595 exit_code,
596 },
597 finished_at: Some(std::time::Instant::now()),
598 generation: 0,
599 actual_exit_code: 0,
600 completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
601 output_withheld: false,
602 notice: None,
603 foreground_senders: Vec::new(),
604 foreground_waiters: 0,
605 background_owned: true,
606 telemetry_recorded: false,
607 }
608 }
609
610 #[test]
611 fn retained_output_replacement_checks_epoch_under_the_job_lock() {
612 let id = "shell_epoch_replacement_test";
613 let mut job = completed_job(0);
614 job.generation = 41;
615 super::JOBS
616 .lock()
617 .unwrap_or_else(std::sync::PoisonError::into_inner)
618 .insert(id.into(), job);
619 let epoch = lean_ctx::core::context_admission::stores::current_epoch();
620
621 assert!(
622 !super::replace_retained_output_if_generation(
623 id,
624 41,
625 "opaque output",
626 epoch.wrapping_add(1),
627 Some("stale output"),
628 ),
629 "an old policy snapshot cannot repopulate a terminal job"
630 );
631 assert!(matches!(
632 super::status(id),
633 Some(JobState::Completed { output, .. }) if output == "opaque output"
634 ));
635
636 assert!(
637 super::replace_retained_output_if_generation(
638 id,
639 41,
640 "opaque output",
641 epoch.wrapping_add(1),
642 None,
643 ),
644 "scrubbing remains allowed after an epoch change"
645 );
646 assert!(matches!(
647 super::status(id),
648 Some(JobState::Completed { output, .. }) if output.is_empty()
649 ));
650 super::remove_for_test(id);
651 }
652
653 #[test]
654 #[serial_test::serial]
655 fn failed_job_telemetry_is_once_per_execution_even_when_id_is_reused() {
656 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
657 let id = "shell_same_content";
658 super::JOBS
659 .lock()
660 .unwrap_or_else(std::sync::PoisonError::into_inner)
661 .insert(id.into(), completed_job(2));
662
663 assert!(
664 super::record_error_telemetry_once(
665 id,
666 lean_ctx::core::telemetry_v2::ErrorCategory::Internal
667 )
668 .expect("record first execution")
669 );
670 assert!(
671 !super::record_error_telemetry_once(
672 id,
673 lean_ctx::core::telemetry_v2::ErrorCategory::Internal
674 )
675 .expect("deduplicate repeated poll")
676 );
677
678 super::JOBS
679 .lock()
680 .unwrap_or_else(std::sync::PoisonError::into_inner)
681 .insert(id.into(), completed_job(2));
682 assert!(
683 super::record_error_telemetry_once(
684 id,
685 lean_ctx::core::telemetry_v2::ErrorCategory::Internal
686 )
687 .expect("record replacement execution")
688 );
689
690 let batch = lean_ctx::core::telemetry_aggregate::preview_daily_batch().expect("preview");
691 let count = batch
692 .events
693 .iter()
694 .find_map(|envelope| match &envelope.event {
695 lean_ctx::core::telemetry_v2::TelemetryEventV2::ErrorCategoryAggregate(metrics)
696 if metrics.category
697 == lean_ctx::core::telemetry_v2::ErrorCategory::Internal =>
698 {
699 Some(metrics.count)
700 }
701 _ => None,
702 });
703 assert_eq!(count, Some(2));
704 super::remove_for_test(id);
705 }
706
707 fn queued_error_count(category: lean_ctx::core::telemetry_v2::ErrorCategory) -> Option<u64> {
708 let mut attempt = 0;
711 let batch = loop {
712 match lean_ctx::core::telemetry_aggregate::preview_daily_batch() {
713 Ok(batch) => break batch,
714 Err(_) if attempt < 100 => {
715 attempt += 1;
716 std::thread::sleep(Duration::from_millis(20));
717 }
718 Err(error) => panic!("preview: {error}"),
719 }
720 };
721 batch
722 .events
723 .iter()
724 .find_map(|envelope| match &envelope.event {
725 lean_ctx::core::telemetry_v2::TelemetryEventV2::ErrorCategoryAggregate(metrics)
726 if metrics.category == category =>
727 {
728 Some(metrics.count)
729 }
730 _ => None,
731 })
732 }
733
734 #[test]
738 #[serial_test::serial]
739 #[cfg_attr(windows, ignore)]
740 fn worker_counts_only_background_failures_and_as_commands() {
741 use lean_ctx::core::telemetry_v2::ErrorCategory;
742 if std::env::var_os("LEAN_CTX_BACKGROUND_TELEMETRY_SUBPROCESS").is_none() {
748 let environment = tempfile::tempdir().unwrap();
749 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
750 command
751 .args([
752 "--exact",
753 "server::background_shell::tests::worker_counts_only_background_failures_and_as_commands",
754 "--nocapture",
755 "--test-threads=1",
756 ])
757 .env("LEAN_CTX_BACKGROUND_TELEMETRY_SUBPROCESS", "1");
758 for name in [
759 "LEAN_CTX_DATA_DIR",
760 "LEAN_CTX_CONFIG_DIR",
761 "LEAN_CTX_STATE_DIR",
762 "LEAN_CTX_CACHE_DIR",
763 ] {
764 command.env(name, environment.path());
765 }
766 let output = command.output().unwrap();
767 let stdout = String::from_utf8_lossy(&output.stdout);
768 assert!(
769 output.status.success() && stdout.contains("1 passed"),
770 "{stdout}\n{}",
771 String::from_utf8_lossy(&output.stderr)
772 );
773 return;
774 }
775 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
776
777 let result = run_foreground_or_detach(
778 "exit 3".to_string(),
779 ".".to_string(),
780 std::collections::HashMap::default(),
781 Some(10_000),
782 Duration::from_secs(10),
783 None,
784 None,
785 );
786 assert!(matches!(
787 result,
788 ForegroundResult::Finished { exit_code: 3, .. }
789 ));
790 assert_eq!(queued_error_count(ErrorCategory::Command), None);
791 assert_eq!(queued_error_count(ErrorCategory::Internal), None);
792
793 let id = start(
794 "exit 4".to_string(),
795 ".".to_string(),
796 std::collections::HashMap::default(),
797 Some(10_000),
798 );
799 for _ in 0..200 {
800 if matches!(status(&id), Some(JobState::Completed { .. })) {
801 break;
802 }
803 std::thread::sleep(Duration::from_millis(25));
804 }
805 assert!(matches!(
806 status(&id),
807 Some(JobState::Completed { exit_code: 4, .. })
808 ));
809 assert_eq!(queued_error_count(ErrorCategory::Command), Some(1));
810 assert_eq!(queued_error_count(ErrorCategory::Internal), None);
811 super::remove_for_test(&id);
812 }
813
814 #[test]
815 fn completed_job_retention_is_bounded() {
816 let now = std::time::Instant::now();
817 let mut jobs = std::collections::HashMap::new();
818 for index in 0..=super::MAX_RETAINED_COMPLETED_JOBS {
819 jobs.insert(
820 format!("job_{index}"),
821 super::Job {
822 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
823 state: JobState::Completed {
824 output: "x".repeat(1024),
825 exit_code: 0,
826 },
827 finished_at: Some(
828 now.checked_sub(Duration::from_secs((index + 1) as u64))
829 .unwrap(),
830 ),
831 generation: 0,
832 actual_exit_code: 0,
833 completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
834 output_withheld: false,
835 notice: None,
836 foreground_senders: Vec::new(),
837 foreground_waiters: 0,
838 background_owned: true,
839 telemetry_recorded: false,
840 },
841 );
842 }
843 jobs.insert(
844 "expired".to_string(),
845 super::Job {
846 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
847 state: JobState::Completed {
848 output: "expired".to_string(),
849 exit_code: 0,
850 },
851 finished_at: Some(
852 now.checked_sub(super::COMPLETED_JOB_TTL + Duration::from_secs(1))
853 .unwrap(),
854 ),
855 generation: 0,
856 actual_exit_code: 0,
857 completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
858 output_withheld: false,
859 notice: None,
860 foreground_senders: Vec::new(),
861 foreground_waiters: 0,
862 background_owned: true,
863 telemetry_recorded: false,
864 },
865 );
866
867 super::prune_finished_jobs(&mut jobs, now);
868
869 assert_eq!(jobs.len(), super::MAX_RETAINED_COMPLETED_JOBS);
870 assert!(!jobs.contains_key("expired"));
871 assert!(!jobs.contains_key(&format!("job_{}", super::MAX_RETAINED_COMPLETED_JOBS)));
872 }
873
874 #[test]
875 fn completed_job_output_bytes_are_bounded() {
876 let now = std::time::Instant::now();
877 let mut jobs = std::collections::HashMap::new();
878 for index in 0..3 {
879 jobs.insert(
880 format!("job_{index}"),
881 super::Job {
882 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
883 state: JobState::Completed {
884 output: "x".repeat(8),
885 exit_code: 0,
886 },
887 finished_at: Some(
888 now.checked_sub(Duration::from_secs((3 - index) as u64))
889 .unwrap(),
890 ),
891 generation: 0,
892 actual_exit_code: 0,
893 completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
894 output_withheld: false,
895 notice: None,
896 foreground_senders: Vec::new(),
897 foreground_waiters: 0,
898 background_owned: true,
899 telemetry_recorded: false,
900 },
901 );
902 }
903
904 super::prune_finished_jobs_with_limits(&mut jobs, now, 10, 16);
905
906 assert_eq!(jobs.len(), 2);
907 assert!(!jobs.contains_key("job_0"));
908 }
909
910 #[test]
911 #[cfg_attr(windows, ignore)]
912 fn foreground_run_finishing_within_cap_returns_inline() {
913 let (_data, _community) = retained_text_held_still();
914 let result = run_foreground_or_detach(
915 "printf FG_OK".to_string(),
916 ".".to_string(),
917 std::collections::HashMap::default(),
918 Some(10_000),
919 Duration::from_secs(10),
920 None,
921 None,
922 );
923 match result {
924 ForegroundResult::Finished { output, exit_code } => {
925 assert_eq!(exit_code, 0);
926 assert!(output.contains("FG_OK"), "got: {output}");
927 }
928 ForegroundResult::Detached { .. } => panic!("fast command should not detach"),
929 }
930 }
931
932 #[test]
933 #[cfg_attr(windows, ignore)]
934 fn foreground_run_exceeding_cap_detaches_to_pollable_job() {
935 let result = run_foreground_or_detach(
936 "sleep 5; printf SLOW_OK".to_string(),
937 ".".to_string(),
938 std::collections::HashMap::default(),
939 Some(10_000),
940 Duration::from_millis(100),
941 None,
942 None,
943 );
944 let ForegroundResult::Detached { job_id } = result else {
945 panic!("slow command should detach");
946 };
947 assert!(job_id.starts_with("shell_"));
948 assert!(status(&job_id).is_some());
950 cancel(&job_id);
951 }
952
953 #[test]
958 #[cfg_attr(windows, ignore)]
959 fn large_timeout_ms_still_detaches_at_the_soft_cap() {
960 let result = run_foreground_or_detach(
961 "sleep 5; printf NEVER_INLINE".to_string(),
962 ".".to_string(),
963 std::collections::HashMap::default(),
964 Some(600_000),
965 Duration::from_millis(100),
966 None,
967 None,
968 );
969 let ForegroundResult::Detached { job_id } = result else {
970 panic!("timeout_ms must not extend the foreground wait");
971 };
972 assert!(status(&job_id).is_some());
973 cancel(&job_id);
974 }
975
976 #[test]
980 #[cfg_attr(windows, ignore)]
981 fn foreground_run_reports_progress_while_waiting() {
982 let ticks = std::sync::atomic::AtomicUsize::new(0);
983 let tick = |elapsed: Duration| {
984 assert!(elapsed >= TICK, "tick must report real elapsed time");
985 ticks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
986 };
987 let result = run_foreground_or_detach(
988 "sleep 30".to_string(),
989 ".".to_string(),
990 std::collections::HashMap::default(),
991 Some(60_000),
992 TICK + Duration::from_millis(500),
993 Some(&tick),
994 None,
995 );
996 let ForegroundResult::Detached { job_id } = result else {
997 panic!("slow command should detach");
998 };
999 cancel(&job_id);
1000 assert!(
1001 ticks.load(std::sync::atomic::Ordering::Relaxed) >= 1,
1002 "no progress reported during a {}s+ foreground wait",
1003 TICK.as_secs()
1004 );
1005 }
1006
1007 #[test]
1012 #[cfg_attr(windows, ignore)]
1013 fn cancelled_token_detaches_without_waiting_for_the_soft_cap() {
1014 let token = tokio_util::sync::CancellationToken::new();
1015 token.cancel();
1016 let started = std::time::Instant::now();
1017 let result = run_foreground_or_detach(
1018 "sleep 31".to_string(),
1022 ".".to_string(),
1023 std::collections::HashMap::default(),
1024 Some(60_000),
1025 Duration::from_mins(1),
1026 None,
1027 Some(&token),
1028 );
1029 let elapsed = started.elapsed();
1030 let ForegroundResult::Detached { job_id } = result else {
1031 panic!("a cancelled request must detach instead of waiting it out");
1032 };
1033 cancel(&job_id);
1034 assert!(
1035 elapsed < Duration::from_secs(5),
1036 "cancelled wait took {elapsed:?} against a one-minute cap — token not observed"
1037 );
1038 }
1039
1040 #[test]
1041 #[cfg_attr(windows, ignore)]
1042 fn background_job_runs_past_request_and_can_be_observed() {
1043 let (_data, _community) = retained_text_held_still();
1044 let id = start(
1045 "sleep 0.1; printf BG_JOB_OK".to_string(),
1046 ".".to_string(),
1047 std::collections::HashMap::default(),
1048 Some(10_000),
1049 );
1050 assert!(matches!(status(&id), Some(JobState::Running { .. })));
1051 for _ in 0..40 {
1052 if let Some(JobState::Completed { output, exit_code }) = status(&id) {
1053 assert_eq!(exit_code, 0);
1054 assert!(output.contains("BG_JOB_OK"), "got: {output}");
1055 return;
1056 }
1057 std::thread::sleep(Duration::from_millis(25));
1058 }
1059 panic!("background job did not complete");
1060 }
1061
1062 #[test]
1063 #[cfg_attr(windows, ignore)]
1064 fn cancelling_background_job_returns_cancelled_state() {
1065 let id = start(
1066 "sleep 5".to_string(),
1067 ".".to_string(),
1068 std::collections::HashMap::default(),
1069 Some(10_000),
1070 );
1071 assert!(matches!(cancel(&id), Some(JobState::Running { .. })));
1072 for _ in 0..40 {
1073 if let Some(JobState::Cancelled { output }) = status(&id) {
1074 assert!(
1075 !output.contains("[cancelled: command stopped on request]"),
1076 "cancellation lifecycle belongs to the structured state, not retained output"
1077 );
1078 return;
1079 }
1080 std::thread::sleep(Duration::from_millis(25));
1081 }
1082 panic!("background job was not cancelled");
1083 }
1084
1085 #[test]
1088 #[cfg_attr(windows, ignore)]
1089 fn running_background_job_status_never_exposes_live_output() {
1090 let id = start(
1091 "printf EARLY_LINE; sleep 3".to_string(),
1092 ".".to_string(),
1093 std::collections::HashMap::default(),
1094 Some(10_000),
1095 );
1096 let mut saw_running = false;
1097 for _ in 0..20 {
1098 if let Some(JobState::Running { output }) = status(&id) {
1099 saw_running = true;
1100 assert!(output.is_empty(), "running state retained: {output:?}");
1101 break;
1102 }
1103 std::thread::sleep(Duration::from_millis(10));
1104 }
1105 cancel(&id);
1106 assert!(
1107 saw_running,
1108 "job completed before running state was observed"
1109 );
1110 }
1111
1112 #[test]
1116 #[cfg_attr(windows, ignore)]
1117 fn identical_concurrent_foreground_calls_all_observe_completion() {
1118 let (_data, _community) = retained_text_held_still();
1119 const CALLERS: usize = 3;
1120 let barrier = std::sync::Arc::new(std::sync::Barrier::new(CALLERS));
1121 let handles: Vec<_> = (0..CALLERS)
1122 .map(|_| {
1123 let barrier = std::sync::Arc::clone(&barrier);
1124 std::thread::spawn(move || {
1125 barrier.wait();
1126 run_foreground_or_detach(
1127 "sleep 0.2; printf GH1876_ALL_OK".to_string(),
1128 ".".to_string(),
1129 std::collections::HashMap::default(),
1130 Some(10_000),
1131 Duration::from_secs(10),
1132 None,
1133 None,
1134 )
1135 })
1136 })
1137 .collect();
1138 for handle in handles {
1139 match handle.join().expect("foreground caller panicked") {
1140 ForegroundResult::Finished { output, exit_code } => {
1141 assert_eq!(exit_code, 0);
1142 assert!(output.contains("GH1876_ALL_OK"), "got: {output}");
1143 }
1144 ForegroundResult::Detached { job_id } => {
1145 cancel(&job_id);
1146 panic!("a coalesced caller lost the shared job and detached");
1147 }
1148 }
1149 }
1150 }
1151
1152 #[test]
1155 #[cfg_attr(windows, ignore)]
1156 fn foreground_join_keeps_a_background_job_pollable() {
1157 let (_data, _community) = retained_text_held_still();
1158 let command = "sleep 0.2; printf GH1876_BG_OK".to_string();
1159 let id = start(
1160 command.clone(),
1161 ".".to_string(),
1162 std::collections::HashMap::default(),
1163 Some(10_000),
1164 );
1165 let result = run_foreground_or_detach(
1166 command,
1167 ".".to_string(),
1168 std::collections::HashMap::default(),
1169 Some(10_000),
1170 Duration::from_secs(10),
1171 None,
1172 None,
1173 );
1174 assert!(matches!(result, ForegroundResult::Finished { .. }));
1175 match status(&id) {
1176 Some(JobState::Completed { output, .. }) => assert!(output.contains("GH1876_BG_OK")),
1177 other => panic!("background job vanished after a foreground join: {other:?}"),
1178 }
1179 super::remove_for_test(&id);
1180 }
1181
1182 #[test]
1186 #[cfg_attr(windows, ignore)]
1187 fn a_caller_joins_a_finished_entry_its_waiter_has_not_read() {
1188 let _data = lean_ctx::core::data_dir::isolated_data_dir();
1195 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
1196 let launch = || {
1197 super::start_inner(
1198 "printf GH1876_WINDOW".to_string(),
1199 ".".to_string(),
1200 std::collections::HashMap::default(),
1201 Some(10_000),
1202 true,
1203 )
1204 };
1205 let id = launch();
1206 let deadline = std::time::Instant::now() + Duration::from_secs(10);
1207 while !matches!(status(&id), Some(JobState::Completed { .. })) {
1208 assert!(std::time::Instant::now() < deadline, "job never completed");
1209 std::thread::sleep(Duration::from_millis(10));
1210 }
1211
1212 assert_eq!(launch(), id);
1213 super::release_foreground(&id, false);
1214 match status(&id) {
1215 Some(JobState::Completed { output, .. }) => assert!(output.contains("GH1876_WINDOW")),
1216 other => panic!("the joined caller lost its result: {other:?}"),
1217 }
1218 super::release_foreground(&id, false);
1219 assert!(status(&id).is_none(), "the last waiter removes the entry");
1220 }
1221
1222 #[test]
1225 #[cfg_attr(windows, ignore)]
1226 fn fast_identical_concurrent_foreground_calls_all_return() {
1227 let (_data, _community) = retained_text_held_still();
1228 const CALLERS: usize = 8;
1229 for _ in 0..5 {
1230 let barrier = std::sync::Arc::new(std::sync::Barrier::new(CALLERS));
1231 let handles: Vec<_> = (0..CALLERS)
1232 .map(|_| {
1233 let barrier = std::sync::Arc::clone(&barrier);
1234 std::thread::spawn(move || {
1235 barrier.wait();
1236 run_foreground_or_detach(
1237 "printf GH1876_FAST".to_string(),
1238 ".".to_string(),
1239 std::collections::HashMap::default(),
1240 Some(10_000),
1241 Duration::from_secs(5),
1242 None,
1243 None,
1244 )
1245 })
1246 })
1247 .collect();
1248 for handle in handles {
1249 match handle.join().expect("foreground caller panicked") {
1250 ForegroundResult::Finished { output, .. } => {
1251 assert!(output.contains("GH1876_FAST"), "got: {output}");
1252 }
1253 ForegroundResult::Detached { job_id } => {
1254 cancel(&job_id);
1255 panic!("a fast identical caller lost its job and detached");
1256 }
1257 }
1258 }
1259 }
1260 }
1261
1262 #[test]
1263 fn pruning_keeps_finished_jobs_that_a_waiter_has_not_read() {
1264 let now = std::time::Instant::now();
1265 let mut jobs = std::collections::HashMap::new();
1266 jobs.insert(
1267 "waited".to_string(),
1268 super::Job {
1269 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1270 state: JobState::Completed {
1271 output: "x".repeat(64),
1272 exit_code: 0,
1273 },
1274 finished_at: Some(
1275 now.checked_sub(super::COMPLETED_JOB_TTL + Duration::from_secs(1))
1276 .unwrap(),
1277 ),
1278 generation: 0,
1279 actual_exit_code: 0,
1280 completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
1281 output_withheld: false,
1282 notice: None,
1283 foreground_senders: Vec::new(),
1284 foreground_waiters: 1,
1285 background_owned: false,
1286 telemetry_recorded: false,
1287 },
1288 );
1289
1290 super::prune_finished_jobs_with_limits(&mut jobs, now, 0, 0);
1291
1292 assert!(jobs.contains_key("waited"));
1293 }
1294}