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