kaish_kernel/scheduler/job.rs
1//! Background job management for kaish.
2//!
3//! Provides the `JobManager` for tracking background jobs started with `&`.
4
5use std::collections::HashMap;
6use std::io;
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::future::Future;
10use std::sync::Arc;
11use std::time::SystemTime;
12
13use tokio::sync::{oneshot, Mutex};
14use tokio::task::JoinHandle;
15
16use crate::interpreter::ExecResult;
17use crate::scheduler::stream::BoundedStream;
18
19// Data types re-exported from kaish-types.
20pub use kaish_types::{JobId, JobInfo, JobStatus};
21
22/// One job's live output streams.
23///
24/// Handed out by [`JobManager::streams`] so a producer (the drain task behind
25/// an external command) can write into a job that outlives the command, and so
26/// an embedder can tail a running job with
27/// [`BoundedStream::changed_since`] instead of a poll loop.
28#[derive(Clone)]
29pub struct JobStreams {
30 /// The job's stdout, written as the bytes arrive.
31 ///
32 /// Fed two ways, and never both for the same bytes:
33 ///
34 /// * **Live**, per 8 KiB chunk, by the drain task behind an external
35 /// command running for this job — but only from the stage whose stdout
36 /// *is* the job's stdout (`Only` or `Last` in the pipeline), so
37 /// `a | b` streams `b` and not `a`'s bytes on their way into `b`.
38 /// * **At completion**, from the job's captured `ExecResult`, and only
39 /// when nothing was streamed live. That covers a builtin-only job
40 /// (`echo hi &`): a builtin returns its output as a value when it
41 /// finishes, so there is no byte stream to tee.
42 ///
43 /// Whichever fed it, the stream is closed once the job's result is in
44 /// ([`JobManager::finalize_streams`]), so a reader can tell "no more
45 /// coming" from "nothing yet".
46 pub stdout: Arc<BoundedStream>,
47 /// The job's stderr. Same two feeds as [`Self::stdout`], except the live
48 /// one takes **every** stage's stderr, not just the last — stderr is not
49 /// piped between stages. The consequence, stated rather than papered
50 /// over: in a job mixing builtins and externals, once any external has
51 /// written stderr the completion write is skipped, so a builtin stage's
52 /// stderr stays in the job's `ExecResult` and does not reach this stream.
53 pub stderr: Arc<BoundedStream>,
54}
55
56/// A background job.
57pub struct Job {
58 /// Job ID.
59 pub id: JobId,
60 /// Owning manager's session ID — disambiguates output file paths between
61 /// JobManager instances that share the process (and thus the same job ID
62 /// space, since IDs restart at 1 per manager).
63 session_id: u64,
64 /// Command description.
65 pub command: String,
66 /// Task handle (None if already awaited).
67 handle: Option<JoinHandle<ExecResult>>,
68 /// Channel to receive result (alternative to handle).
69 result_rx: Option<oneshot::Receiver<ExecResult>>,
70 /// Cached result after completion.
71 result: Option<ExecResult>,
72 /// Path to output file (captures stdout/stderr after completion).
73 output_file: Option<PathBuf>,
74 /// Whether to persist completed output to a host temp file. Disabled for
75 /// hermetic / read-only kernels (custom backend, NoLocal) whose output must
76 /// never reach the real filesystem outside the VFS — see
77 /// [`JobManager::set_persist_output_files`]. Stamped from the manager when
78 /// the job is registered. A hermetic kernel still reads the job's output
79 /// from its live streams (`/v/jobs/{id}/stdout`, or
80 /// [`JobManager::read_stdout`]) — the host file is a convenience, not the
81 /// only copy.
82 persist_output: bool,
83 /// Live stdout of this job. Handed out as [`JobStreams::stdout`], which
84 /// documents exactly which bytes reach it.
85 stdout_stream: Arc<BoundedStream>,
86 /// Live stderr of this job. See [`JobStreams::stderr`].
87 stderr_stream: Arc<BoundedStream>,
88 /// OS process ID (for stopped jobs).
89 pid: Option<u32>,
90 /// OS process group ID (for stopped jobs).
91 pgid: Option<u32>,
92 /// Whether this job is stopped (SIGTSTP).
93 stopped: bool,
94 /// Whether a terminating kill was dispatched at this job (`kill %N`, or
95 /// an embedder's cancel). Once the job unwinds, this turns its terminal
96 /// status into `Killed` instead of `Failed` — "someone killed it" and
97 /// "it errored on its own" stay distinguishable after the fact (GH #244).
98 /// A job that manages to finish successfully before the cascade lands
99 /// still reports `Done`: the result is the truth, the flag only colors
100 /// a non-ok exit.
101 killed: bool,
102 /// Cancellation token of the background fork running this job. Cancelling
103 /// it stops the job whether it is an in-process builtin future or wraps
104 /// external children (the cancellation cascade SIGTERM→SIGKILLs their
105 /// process groups). This is how `kill %N` reaches a job that has no OS
106 /// process group of its own (e.g. `sleep &`, a kaish builtin).
107 cancel: Option<tokio_util::sync::CancellationToken>,
108 /// Process groups of external children spawned while running this job.
109 /// Lets `kill -<sig> %N` deliver an arbitrary signal (STOP/CONT/USR1/…)
110 /// straight to the real processes via `killpg`, not just terminate. Empty
111 /// for a pure-builtin job (nothing with a PGID ran).
112 pgids: Vec<u32>,
113 /// Wall-clock time this job started running, stamped at construction.
114 /// Acquired via `kaish_types::clock::system_now` (not `SystemTime::now()`
115 /// directly) so this stays valid on `wasm32-unknown-unknown`. Surfaced on
116 /// `JobInfo.started_at` (GH #243).
117 started_at: SystemTime,
118 /// Wall-clock time this job's result became available, stamped once by
119 /// `try_poll` the moment `self.result` transitions from `None` to `Some`.
120 /// Surfaced on `JobInfo.finished_at` (GH #243).
121 finished_at: Option<SystemTime>,
122}
123
124impl Job {
125 /// Create a new job from a task handle.
126 pub fn new(id: JobId, session_id: u64, command: String, handle: JoinHandle<ExecResult>) -> Self {
127 Self {
128 id,
129 session_id,
130 command,
131 handle: Some(handle),
132 result_rx: None,
133 result: None,
134 output_file: None,
135 persist_output: true,
136 stdout_stream: Arc::new(BoundedStream::default_size()),
137 stderr_stream: Arc::new(BoundedStream::default_size()),
138 pid: None,
139 pgid: None,
140 stopped: false,
141 killed: false,
142 cancel: None,
143 pgids: Vec::new(),
144 started_at: kaish_types::clock::system_now(),
145 finished_at: None,
146 }
147 }
148
149 /// Create a new job from a result channel.
150 pub fn from_channel(id: JobId, session_id: u64, command: String, rx: oneshot::Receiver<ExecResult>) -> Self {
151 Self {
152 id,
153 session_id,
154 command,
155 handle: None,
156 result_rx: Some(rx),
157 result: None,
158 output_file: None,
159 persist_output: true,
160 stdout_stream: Arc::new(BoundedStream::default_size()),
161 stderr_stream: Arc::new(BoundedStream::default_size()),
162 pid: None,
163 pgid: None,
164 stopped: false,
165 killed: false,
166 cancel: None,
167 pgids: Vec::new(),
168 started_at: kaish_types::clock::system_now(),
169 finished_at: None,
170 }
171 }
172
173 /// Create a stopped job (from Ctrl-Z on a foreground process).
174 pub fn stopped(id: JobId, session_id: u64, command: String, pid: u32, pgid: u32) -> Self {
175 Self {
176 id,
177 session_id,
178 command,
179 handle: None,
180 result_rx: None,
181 result: None,
182 output_file: None,
183 persist_output: true,
184 stdout_stream: Arc::new(BoundedStream::default_size()),
185 stderr_stream: Arc::new(BoundedStream::default_size()),
186 pid: Some(pid),
187 pgid: Some(pgid),
188 stopped: true,
189 killed: false,
190 cancel: None,
191 pgids: Vec::new(),
192 // The foreground process actually started earlier (before Ctrl-Z
193 // stopped it into job tracking), but kaish had no job entry for it
194 // until now — "now" is the best available approximation, and a
195 // strict improvement over having no timestamp at all.
196 started_at: kaish_types::clock::system_now(),
197 finished_at: None,
198 }
199 }
200
201 /// Get the output file path (if available).
202 pub fn output_file(&self) -> Option<&PathBuf> {
203 self.output_file.as_ref()
204 }
205
206 /// This job's live output streams (see [`JobStreams`]).
207 pub fn streams(&self) -> JobStreams {
208 JobStreams {
209 stdout: self.stdout_stream.clone(),
210 stderr: self.stderr_stream.clone(),
211 }
212 }
213
214 /// Check if the job has completed.
215 ///
216 /// Stopped jobs are not considered done.
217 pub fn is_done(&mut self) -> bool {
218 if self.stopped {
219 return false;
220 }
221 self.try_poll();
222 self.result.is_some()
223 }
224
225 /// Get the job's status.
226 pub fn status(&mut self) -> JobStatus {
227 if self.stopped {
228 return JobStatus::Stopped;
229 }
230 self.try_poll();
231 match &self.result {
232 Some(r) if r.ok() => JobStatus::Done,
233 Some(_) if self.killed => JobStatus::Killed,
234 Some(_) => JobStatus::Failed,
235 None => JobStatus::Running,
236 }
237 }
238
239 /// Get the job's status as a string suitable for /v/jobs/{id}/status.
240 ///
241 /// Returns:
242 /// - `"running"` if the job is still running
243 /// - `"stopped"` if the job is stopped (Ctrl-Z / SIGTSTP)
244 /// - `"done:0"` if the job completed successfully
245 /// - `"killed:{code}"` if the job was terminated by `kill %N`
246 /// - `"failed:{code}"` if the job failed with an exit code
247 ///
248 /// The vocabulary must stay in step with [`Self::status`] — GH #252 was
249 /// exactly this pair drifting: `status()` learned the `stopped` check and
250 /// this string twin didn't, so `/v/jobs/N/status` reported a Ctrl-Z'd job
251 /// as `"running"` forever (a stopped job has no result channel, so
252 /// `try_poll` can never produce a result).
253 pub fn status_string(&mut self) -> String {
254 if self.stopped {
255 return "stopped".to_string();
256 }
257 self.try_poll();
258 match &self.result {
259 Some(r) if r.ok() => "done:0".to_string(),
260 Some(r) if self.killed => format!("killed:{}", r.code),
261 Some(r) => format!("failed:{}", r.code),
262 None => "running".to_string(),
263 }
264 }
265
266 /// Write job output to a temp file.
267 fn write_output_file(&self, result: &ExecResult) -> Option<PathBuf> {
268 // This is a human-readable text log; a binary stdout is noted, not
269 // dumped (lossy-decoding it would corrupt; raw bytes would garble the
270 // log). Only its size is recorded.
271 let is_bytes = result.is_bytes();
272 let text = if is_bytes {
273 std::borrow::Cow::Borrowed("")
274 } else {
275 result.text_out()
276 };
277 if !is_bytes && text.is_empty() && result.err.is_empty() {
278 return None;
279 }
280
281 let tmp_dir = std::env::temp_dir().join("kaish").join("jobs");
282 if std::fs::create_dir_all(&tmp_dir).is_err() {
283 tracing::warn!("Failed to create job output directory");
284 return None;
285 }
286
287 // Include the OS pid: `session_id` is only unique *within* a process
288 // (it's a process-local atomic that restarts at 0), so two kaish
289 // processes on one host — or two `cargo test` binaries — would
290 // otherwise both write `session_0_job_1.txt` into this shared dir and
291 // clobber each other (a real cross-process collision, and the source
292 // of the `test_cleanup_removes_temp_files` flake). pid + session_id +
293 // job id is unique across processes. Mirrors `output_limit`'s spill
294 // filename convention.
295 let filename = format!(
296 "session_{}_job_{}.{}.txt",
297 self.session_id,
298 self.id.0,
299 std::process::id()
300 );
301 let path = tmp_dir.join(filename);
302
303 let mut content = String::new();
304 content.push_str(&format!("# Job {}: {}\n", self.id, self.command));
305 // Same terminal-status words as `status()` — a killed job's persisted
306 // log must not claim it "Failed" on its own (GH #244 review finding).
307 let status = if result.ok() {
308 "Done"
309 } else if self.killed {
310 "Killed"
311 } else {
312 "Failed"
313 };
314 content.push_str(&format!("# Status: {status}\n\n"));
315
316 if is_bytes {
317 let n = result.out_bytes().map(|b| b.len()).unwrap_or(0);
318 content.push_str(&format!(
319 "## STDOUT\n[binary output: {n} bytes — omitted from this text log]\n"
320 ));
321 } else if !text.is_empty() {
322 content.push_str("## STDOUT\n");
323 content.push_str(&text);
324 if !text.ends_with('\n') {
325 content.push('\n');
326 }
327 }
328
329 if !result.err.is_empty() {
330 content.push_str("\n## STDERR\n");
331 content.push_str(&result.err);
332 if !result.err.ends_with('\n') {
333 content.push('\n');
334 }
335 }
336
337 match std::fs::write(&path, content) {
338 Ok(()) => Some(path),
339 Err(e) => {
340 tracing::warn!("Failed to write job output file: {}", e);
341 None
342 }
343 }
344 }
345
346 /// Remove any temp files associated with this job.
347 pub fn cleanup_files(&mut self) {
348 if let Some(path) = self.output_file.take() {
349 if let Err(e) = std::fs::remove_file(&path) {
350 // Ignore "not found" — file may not have been written
351 if e.kind() != io::ErrorKind::NotFound {
352 tracing::warn!("Failed to clean up job output file {}: {}", path.display(), e);
353 }
354 }
355 }
356 }
357
358 /// Get the result if completed, without waiting.
359 pub fn try_result(&self) -> Option<&ExecResult> {
360 self.result.as_ref()
361 }
362
363 /// Try to poll the result channel and update status.
364 ///
365 /// This is a non-blocking check that updates `self.result` if the
366 /// job has completed. Returns true if the job is now done.
367 pub fn try_poll(&mut self) -> bool {
368 if self.result.is_some() {
369 return true;
370 }
371
372 // Try to poll the oneshot channel
373 if let Some(rx) = self.result_rx.as_mut() {
374 match rx.try_recv() {
375 Ok(result) => {
376 self.result = Some(result);
377 self.result_rx = None;
378 self.finished_at = Some(kaish_types::clock::system_now());
379 return true;
380 }
381 Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
382 // Still running
383 return false;
384 }
385 Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
386 // The sender dropped without sending a result — the
387 // spawned task's future panicked and unwound before
388 // `tx.send(result)` ran (GH #247). `execute_background`
389 // uses this oneshot-channel path exclusively for every
390 // `&` job, so this is the ONLY place a background-job
391 // panic surfaces; a wording indistinguishable from an
392 // ordinary `exit 1` ("job channel closed", previously)
393 // hid a kernel bug behind what read as a normal command
394 // failure, and the case went to `tracing::error!` for
395 // the first time here — it was not logged at all before.
396 tracing::error!(
397 job_id = %self.id,
398 command = %self.command,
399 "background job task ended without producing a result — \
400 its future likely panicked"
401 );
402 self.result = Some(ExecResult::failure(
403 1,
404 format!(
405 "job {}: task ended without a result (likely a kernel panic, \
406 not the command's own exit) — see the kernel's logs",
407 self.id
408 ),
409 ));
410 self.result_rx = None;
411 self.finished_at = Some(kaish_types::clock::system_now());
412 return true;
413 }
414 }
415 }
416
417 // Check if handle is finished
418 if let Some(handle) = self.handle.as_mut()
419 && handle.is_finished() {
420 // Take the handle and wait for it (should be instant)
421 let Some(mut handle) = self.handle.take() else {
422 return false;
423 };
424 // Poll directly with a noop waker — safe because is_finished() was true
425 let waker = std::task::Waker::noop();
426 let mut cx = std::task::Context::from_waker(waker);
427 let result = match std::pin::Pin::new(&mut handle).poll(&mut cx) {
428 std::task::Poll::Ready(Ok(r)) => r,
429 std::task::Poll::Ready(Err(e)) => {
430 ExecResult::failure(1, format!("job panicked: {}", e))
431 }
432 std::task::Poll::Pending => {
433 // is_finished() promised Ready, but if the runtime
434 // ever says Pending anyway, dropping the taken handle
435 // would strand the job as "Running" forever with its
436 // result silently lost. Put it back and retry on a
437 // later poll.
438 self.handle = Some(handle);
439 return false;
440 }
441 };
442 self.result = Some(result);
443 self.finished_at = Some(kaish_types::clock::system_now());
444 return true;
445 }
446
447 false
448 }
449
450 /// The process groups recorded for this job, combining `pgids` (from
451 /// externals spawned while running) with the legacy single `pgid`
452 /// recorded for a *stopped* foreground job — the single accessor `list`/
453 /// `get`/`reap_finished` use to fill `JobInfo.pgids` (GH #243), and what
454 /// `JobManager::job_pgids` delegates to so the combine logic exists once.
455 fn pgids_combined(&self) -> Vec<u32> {
456 let mut v = self.pgids.clone();
457 if let Some(pg) = self.pgid
458 && !v.contains(&pg)
459 {
460 v.push(pg);
461 }
462 v
463 }
464
465 /// Build the full `JobInfo` snapshot for this job. `status` is taken as a
466 /// parameter rather than recomputed here because computing it requires
467 /// `&mut self` (it polls) — callers (`list`/`get`/`reap_finished`)
468 /// already did that poll before calling this. The
469 /// single chokepoint that populates every `JobInfo` field (GH #243), so
470 /// the call sites can't drift on which fields they remember to set.
471 fn to_info(&self, status: JobStatus) -> JobInfo {
472 let exit_code = self.result.as_ref().map(|r| r.code);
473 JobInfo::new(self.id, self.command.clone(), status)
474 .with_output_file(self.output_file.clone())
475 .with_pid(self.pid)
476 .with_exit_code(exit_code)
477 .with_started_at(self.started_at)
478 .with_finished_at(self.finished_at)
479 .with_pgids(self.pgids_combined())
480 }
481}
482
483/// Process-wide counter handing each JobManager a distinct session ID. Job IDs
484/// restart at 1 per manager, so the session ID is what keeps output file paths
485/// from colliding between managers sharing a process (concurrent tests, forks).
486/// It is process-LOCAL (restarts at 0 per process), so output filenames also
487/// mix in the OS pid to stay unique across processes — see `write_output_file`.
488static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(0);
489
490/// Remove job output files in `/tmp/kaish/jobs/` that were written by processes
491/// which are no longer running. Run once per process (guarded by a `Once` in
492/// [`JobManager::new`]).
493///
494/// Strategy: filenames follow `session_S_job_J.PID.txt`. We parse the PID
495/// component and skip files whose PID matches the current process (those
496/// belong to live sessions in this very process). For other PIDs we check
497/// `/proc/{pid}` on Linux; on non-Linux platforms we skip the prune entirely
498/// since there is no cheap cross-platform liveness check.
499///
500/// All errors are intentionally ignored — this is opportunistic cleanup only.
501fn prune_orphaned_job_files() {
502 // Only prune on Linux where /proc/{pid} is a reliable liveness check.
503 #[cfg(target_os = "linux")]
504 {
505 let jobs_dir = std::env::temp_dir().join("kaish").join("jobs");
506 let Ok(entries) = std::fs::read_dir(&jobs_dir) else {
507 return; // directory doesn't exist yet — nothing to prune
508 };
509 let current_pid = std::process::id();
510 for entry in entries.flatten() {
511 let name = entry.file_name();
512 let name_str = name.to_string_lossy();
513 // Expected format: session_S_job_J.PID.txt
514 // The PID sits between the last '.' before ".txt" and the preceding '.'.
515 let file_pid: Option<u32> = name_str
516 .strip_suffix(".txt")
517 .and_then(|s| s.rsplit_once('.'))
518 .and_then(|(_, pid_str)| pid_str.parse().ok());
519 let Some(pid) = file_pid else {
520 continue; // not a job output file — skip
521 };
522 if pid == current_pid {
523 continue; // belongs to the current process — leave it alone
524 }
525 // Check if the owning process is still alive via /proc.
526 if std::path::Path::new(&format!("/proc/{}", pid)).exists() {
527 continue; // process is still running — leave it alone
528 }
529 // Process is gone: remove the stale file. Error intentionally ignored.
530 let _ = std::fs::remove_file(entry.path());
531 }
532 }
533}
534
535/// Manager for background jobs.
536pub struct JobManager {
537 /// Process-unique ID for this manager, mixed into job output file paths.
538 session_id: u64,
539 /// Counter for generating unique job IDs.
540 next_id: AtomicU64,
541 /// Map of job ID to job.
542 jobs: Arc<Mutex<HashMap<JobId, Job>>>,
543 /// Whether completed jobs persist their output to a host temp file. On by
544 /// default; a hermetic / read-only kernel disables it so output never
545 /// bypasses the VFS onto the real filesystem (see
546 /// [`set_persist_output_files`](Self::set_persist_output_files)). Stamped
547 /// onto each [`Job`] at registration.
548 persist_output_files: std::sync::atomic::AtomicBool,
549 /// SIGTERM→SIGKILL grace of the cancellation cascade, in milliseconds —
550 /// mirrored from `KernelConfig::kill_grace` at kernel construction so the
551 /// `kill` builtin can bound its wait-for-death on the same number the
552 /// cascade actually uses (GH #244). Milliseconds in an atomic rather than
553 /// a locked `Duration` because readers are on the kill path and never
554 /// need sub-millisecond precision.
555 kill_grace_ms: AtomicU64,
556 /// How many finished jobs stay tracked before the oldest are reaped to
557 /// make room (GH #244: nothing auto-reaped, so a long-lived embedder
558 /// accumulated results, streams, and temp files without bound). Enforced
559 /// at registration time — see [`Self::enforce_retention_locked`] for what
560 /// "finished" excludes (gated and stopped jobs are never evicted).
561 finished_retention: AtomicU64,
562}
563
564/// Default for [`JobManager::set_finished_retention`]: keep the last 100
565/// finished jobs. Interactive sessions never notice (the REPL reaps every
566/// prompt); an embedder that never reaps stays bounded.
567pub const DEFAULT_FINISHED_RETENTION: u64 = 100;
568
569impl JobManager {
570 /// Create a new job manager.
571 ///
572 /// On construction, best-effort prunes stale job output files left by
573 /// previously crashed kaish processes. All errors are intentionally ignored
574 /// — startup cleanup is opportunistic and must never prevent the manager
575 /// from being created (silent-fallback rule: the only case where silent is
576 /// correct is read-only / cleanup-only paths with no data loss risk).
577 ///
578 /// # Scoping decision
579 /// All sessions share a single `/tmp/kaish/jobs/` directory. Filenames embed
580 /// the OS PID that wrote them (`session_S_job_J.PID.txt`). Files from the
581 /// current process are never touched here — only files whose embedded PID
582 /// refers to a dead process are removed. On Linux we check `/proc/{pid}` for
583 /// existence; on other platforms we skip the prune rather than guess.
584 pub fn new() -> Self {
585 // Orphans from dead sessions only need pruning once per process, not on
586 // every JobManager (kernels + every fork build one). The `Once` keeps
587 // the dir scan / `/proc` checks off the hot path of background jobs,
588 // scatter workers, and pipeline stages.
589 static PRUNE_ONCE: std::sync::Once = std::sync::Once::new();
590 PRUNE_ONCE.call_once(prune_orphaned_job_files);
591 Self {
592 session_id: NEXT_SESSION_ID.fetch_add(1, Ordering::SeqCst),
593 next_id: AtomicU64::new(1),
594 jobs: Arc::new(Mutex::new(HashMap::new())),
595 persist_output_files: std::sync::atomic::AtomicBool::new(true),
596 kill_grace_ms: AtomicU64::new(2_000),
597 finished_retention: AtomicU64::new(DEFAULT_FINISHED_RETENTION),
598 }
599 }
600
601 /// Mirror `KernelConfig::kill_grace` onto the manager (see the field doc).
602 pub fn set_kill_grace(&self, grace: std::time::Duration) {
603 self.kill_grace_ms
604 .store(grace.as_millis().min(u128::from(u64::MAX)) as u64, Ordering::Relaxed);
605 }
606
607 /// The cancellation cascade's SIGTERM→SIGKILL grace (see [`Self::set_kill_grace`]).
608 pub fn kill_grace(&self) -> std::time::Duration {
609 std::time::Duration::from_millis(self.kill_grace_ms.load(Ordering::Relaxed))
610 }
611
612 /// Set how many finished jobs stay tracked (default 100,
613 /// `DEFAULT_FINISHED_RETENTION`). `0` keeps no finished jobs beyond the
614 /// gate-safety rule — gated and stopped jobs are never evicted regardless.
615 pub fn set_finished_retention(&self, keep: u64) {
616 self.finished_retention.store(keep, Ordering::Relaxed);
617 }
618
619 /// Toggle whether completed jobs persist their output to a host temp file.
620 ///
621 /// Disable this for a hermetic / read-only kernel: the host write in
622 /// `Job::write_output_file` uses `std::fs` directly and so bypasses the
623 /// VFS (and any read-only mount). Turning it off costs a hermetic kernel
624 /// nothing it cannot get elsewhere — the job's output is in its live
625 /// streams (`/v/jobs/{id}/stdout`, or [`JobManager::read_stdout`]), bounded by a
626 /// 10 MB ring. Redirect to a VFS path (`cmd > /tmp/out &`) when a job
627 /// outruns that ring.
628 ///
629 /// Must be set before jobs are spawned — the flag is stamped onto each job
630 /// at registration time, not consulted at completion.
631 pub fn set_persist_output_files(&self, on: bool) {
632 self.persist_output_files.store(on, Ordering::Relaxed);
633 }
634
635 /// Whether completed jobs persist their output to a host temp file.
636 pub fn persist_output_files(&self) -> bool {
637 self.persist_output_files.load(Ordering::Relaxed)
638 }
639
640 /// Spawn a new background job from a future.
641 ///
642 /// The job is inserted into the map synchronously before returning,
643 /// guaranteeing it's immediately queryable via `exists()` or `get()`.
644 pub async fn spawn<F>(&self, command: String, future: F) -> JobId
645 where
646 F: std::future::Future<Output = ExecResult> + Send + 'static,
647 {
648 let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
649 // Propagate the embedder's trace context across the spawn boundary so
650 // background-job spans stay in the same trace (see telemetry module).
651 let handle = tokio::spawn(crate::telemetry::bind_current_context(future));
652 let mut job = Job::new(id, self.session_id, command, handle);
653 job.persist_output = self.persist_output_files();
654
655 // Insert under an async lock — NOT a busy-spin on try_lock. The old
656 // sync spin could livelock the executor: on a current-thread runtime it
657 // blocks the only worker thread, so a task holding the lock across an
658 // await can never make progress to release it. `lock().await` yields
659 // instead. The insert still completes before we return, so the job is
660 // immediately queryable via `exists()`/`get()`.
661 let mut jobs = self.jobs.lock().await;
662 jobs.insert(id, job);
663 self.enforce_retention_locked(&mut jobs);
664
665 id
666 }
667
668 /// Spawn a job that's already running and communicate via channel.
669 pub async fn register(&self, command: String, rx: oneshot::Receiver<ExecResult>) -> JobId {
670 let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
671 let mut job = Job::from_channel(id, self.session_id, command, rx);
672 job.persist_output = self.persist_output_files();
673
674 let mut jobs = self.jobs.lock().await;
675 jobs.insert(id, job);
676 self.enforce_retention_locked(&mut jobs);
677
678 id
679 }
680
681 /// A job's live output streams, or `None` if there is no such job.
682 ///
683 /// The producer side: `Kernel::try_execute_external` takes these for the
684 /// job it is running under and tees the child's pipes into them as the
685 /// bytes arrive.
686 pub async fn streams(&self, id: JobId) -> Option<JobStreams> {
687 let jobs = self.jobs.lock().await;
688 jobs.get(&id).map(|job| job.streams())
689 }
690
691 /// Snapshot a job's stdout so far, or `None` if there is no such job.
692 ///
693 /// **Readable while the job runs** — that is the point. `None` and
694 /// `Some(vec![])` are different answers: no such job, versus a job that
695 /// has not written anything yet.
696 pub async fn read_stdout(&self, id: JobId) -> Option<Vec<u8>> {
697 let stream = self.streams(id).await?.stdout;
698 Some(stream.read().await)
699 }
700
701 /// Snapshot a job's stderr so far, or `None` if there is no such job.
702 /// See [`Self::read_stdout`].
703 pub async fn read_stderr(&self, id: JobId) -> Option<Vec<u8>> {
704 let stream = self.streams(id).await?.stderr;
705 Some(stream.read().await)
706 }
707
708 /// Close out a finished job's streams: write the captured result into a
709 /// stream that received nothing live, then close both.
710 ///
711 /// The conditional is the no-double-write rule. A stream with live bytes
712 /// in it already holds exactly what the child emitted; writing
713 /// `result.text_out()` on top would repeat all of it. A stream with no
714 /// live bytes belongs to a job with nothing to tee — a builtin returns
715 /// its output as a value, not as a pipe — and would otherwise read empty
716 /// forever.
717 ///
718 /// Called by the background task that owns the job, before it hands the
719 /// result over, so a reader that sees a terminal `status` also sees a
720 /// closed, complete stream.
721 pub async fn finalize_streams(&self, id: JobId, result: &ExecResult) {
722 let Some(streams) = self.streams(id).await else {
723 return;
724 };
725
726 if streams.stdout.stats().await.total_written == 0 {
727 // Raw bytes when the payload is binary; `text_out` would decode it
728 // lossily and corrupt what a caller reads back out of the node.
729 match result.out_bytes() {
730 Some(bytes) => streams.stdout.write(bytes).await,
731 None => streams.stdout.write(result.text_out().as_bytes()).await,
732 }
733 }
734 if streams.stderr.stats().await.total_written == 0 {
735 streams.stderr.write(result.err.as_bytes()).await;
736 }
737
738 streams.stdout.close().await;
739 streams.stderr.close().await;
740 }
741
742 /// Wait for a specific job to complete.
743 ///
744 /// Returns `None` when the job does not exist **or is stopped** — a stopped
745 /// job can never finish, so waiting on one would hang forever. Callers that
746 /// need to tell the two apart check [`JobManager::get`] after a `None`.
747 ///
748 /// The job's pending awaitable (its task handle or result channel) is taken
749 /// out of the map under the lock, then the lock is **released before**
750 /// awaiting completion. Holding the `jobs` mutex across the await would
751 /// block every other job operation (spawn/register/list/status/kill) for the
752 /// whole duration of the job — so a nested `&` started under a parked
753 /// `wait %N` would deadlock. The lock is re-acquired only to finalize
754 /// (persist output, cache the result).
755 pub async fn wait(&self, id: JobId) -> Option<ExecResult> {
756 // Poll for completion WITHOUT removing the job's `JoinHandle` from the
757 // map: `Job::try_poll` (via `is_done`) consumes the handle only once it
758 // has finished. That matters two ways:
759 // * Drop-safe: a waiter dropped mid-wait (e.g. `timeout N wait %1`)
760 // never carries the handle off and orphans the result, so a later
761 // `wait %1` still completes instead of hanging.
762 // * No lock-across-await: we sleep between polls rather than holding
763 // the `jobs` mutex over the wait (which would block every other job
764 // op — the deadlock this method exists to avoid) or busy-spinning.
765 // Cost is up to one poll interval of latency on completion — imperceptible
766 // for a job wait, and the sleep keeps idle CPU at zero.
767 loop {
768 {
769 let mut jobs = self.jobs.lock().await;
770 let job = jobs.get_mut(&id)?;
771 // A stopped job can never finish: `is_done()` returns `false`
772 // for as long as it is stopped, so polling would spin forever —
773 // the same hang `wait_all`'s stopped-skip closes, reachable
774 // here directly (`wait %N` on a Ctrl-Z'd job) and by a job
775 // stopping *after* `wait_all` took its snapshot (the bg reaper
776 // observes a SIGSTOP and flips the flag mid-wait). Bail loud;
777 // the caller resumes with `bg`/`fg` and waits again.
778 if job.stopped {
779 return None;
780 }
781 if job.is_done() {
782 let result = job
783 .result
784 .clone()
785 .unwrap_or_else(|| ExecResult::failure(1, "no result"));
786 // Finalize once: persist output (idempotent on output_file).
787 if job.persist_output
788 && job.output_file.is_none()
789 && let Some(path) = job.write_output_file(&result)
790 {
791 job.output_file = Some(path);
792 }
793 // A completion was just observed — enforce retention here
794 // too, not only at registration (an embedder that stops
795 // registering must still stay bounded). The result is
796 // already cloned, so evicting this very job (cap 0) is
797 // safe.
798 self.enforce_retention_locked(&mut jobs);
799 return Some(result);
800 }
801 }
802 // Lock released between polls — other job ops run freely.
803 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
804 }
805 }
806
807 /// Wait for all jobs that can still finish, returning results in completion
808 /// order.
809 ///
810 /// **Stopped jobs are skipped, and that is load-bearing.** A Ctrl-Z'd job is
811 /// registered by [`JobManager::register_stopped`] with no `JoinHandle` and no
812 /// result channel, and [`Job::is_done`] returns `false` for as long as it is
813 /// stopped — so nothing can ever make it done. Waiting on one here spun the
814 /// 10ms poll loop forever, and since [`crate::Kernel::shutdown`] calls this, a single
815 /// Ctrl-Z hung shutdown with no timeout and no escape. Skip them: `wait_all`
816 /// means "wait for everything that will finish", not "wait for everything".
817 ///
818 /// The filter below is a snapshot; a job that stops *after* it (the bg
819 /// reaper observing a SIGSTOP) is caught by [`JobManager::wait`]'s own
820 /// stopped guard, which returns `None` instead of re-creating the hang.
821 ///
822 /// A caller that wants a stopped job to finish must resume it first (`bg`/`fg`).
823 pub async fn wait_all(&self) -> Vec<(JobId, ExecResult)> {
824 let mut results = Vec::new();
825
826 let ids: Vec<JobId> = {
827 let jobs = self.jobs.lock().await;
828 jobs.iter()
829 .filter(|(_, job)| !job.stopped)
830 .map(|(id, _)| *id)
831 .collect()
832 };
833
834 for id in ids {
835 if let Some(result) = self.wait(id).await {
836 results.push((id, result));
837 }
838 }
839
840 results
841 }
842
843 /// List all jobs with their status.
844 ///
845 /// Listing polls every job, so this is also a completion-observation
846 /// point: retention is enforced here (after the snapshot is taken — the
847 /// returned list is complete even for entries evicted by it).
848 ///
849 /// Sorted by [`JobId`] (GH #247) — the backing map is a `HashMap`, whose
850 /// iteration order is arbitrary and was leaking straight through to
851 /// `jobs`, `/v/jobs`, and `--json`: two jobs could list as `[2, 1]`. An
852 /// MCP caller handed that order, or a snapshot test pinned against it,
853 /// saw a flake with no code change — sorting makes the order a stated
854 /// contract instead of whatever the hasher happened to do.
855 pub async fn list(&self) -> Vec<JobInfo> {
856 let mut jobs = self.jobs.lock().await;
857 let mut infos: Vec<JobInfo> = jobs
858 .values_mut()
859 .map(|job| {
860 let status = job.status();
861 job.to_info(status)
862 })
863 .collect();
864 infos.sort_by_key(|info| info.id);
865 self.enforce_retention_locked(&mut jobs);
866 infos
867 }
868
869 /// Get the number of running jobs.
870 pub async fn running_count(&self) -> usize {
871 let mut jobs = self.jobs.lock().await;
872 let mut count = 0;
873 for job in jobs.values_mut() {
874 if !job.is_done() {
875 count += 1;
876 }
877 }
878 count
879 }
880
881 /// Remove completed jobs from tracking and clean up their temp files,
882 /// returning info for each job removed.
883 ///
884 /// Shared by `jobs --cleanup` (which only needs a count) and the REPL's
885 /// pre-prompt notification (GH #131, which needs the id/command/status of
886 /// each job so it can print `[N]+ Done ...` before reaping it) — one rule
887 /// for "is this job safe to reap", not two copies that could drift.
888 pub async fn reap_finished(&self) -> Vec<JobInfo> {
889 let mut jobs = self.jobs.lock().await;
890 let done_ids: Vec<JobId> = jobs
891 .iter_mut()
892 .filter_map(|(id, job)| job.is_done().then_some(*id))
893 .collect();
894
895 let mut removed = Vec::with_capacity(done_ids.len());
896 for id in done_ids {
897 let Some(mut job) = jobs.remove(&id) else {
898 continue;
899 };
900 let status = job.status();
901 let info = job.to_info(status);
902 job.cleanup_files();
903 removed.push(info);
904 }
905 removed
906 }
907
908 /// Remove completed jobs from tracking and clean up their temp files.
909 ///
910 /// The count-only form of [`reap_finished`](Self::reap_finished) that
911 /// `jobs --cleanup` reports.
912 pub async fn cleanup(&self) {
913 self.reap_finished().await;
914 }
915
916 /// Evict the oldest finished jobs beyond the retention cap
917 /// ([`Self::set_finished_retention`]). Called with the jobs lock held at
918 /// registration time (the moment the tracked-job count grows) **and** at
919 /// the completion-observation points (`list`, `wait`'s finalize) — so an
920 /// embedder that stops registering but keeps observing stays bounded
921 /// without a background sweeper (GH #244). A session that registers jobs
922 /// and then never calls anything at all holds what it registered; there
923 /// is no sweeper task by design. "Finished" follows `reap_finished`'s
924 /// rule: stopped jobs are not finished. Eviction
925 /// is oldest `finished_at` first, so the survivors are the newest N.
926 fn enforce_retention_locked(&self, jobs: &mut HashMap<JobId, Job>) {
927 let keep = self.finished_retention.load(Ordering::Relaxed) as usize;
928 let mut finished: Vec<(JobId, SystemTime)> = jobs
929 .iter_mut()
930 .filter_map(|(id, job)| {
931 job.is_done()
932 .then(|| (*id, job.finished_at.unwrap_or(job.started_at)))
933 })
934 .collect();
935 if finished.len() <= keep {
936 return;
937 }
938 // Job IDs tie-break equal timestamps (they grow monotonically), so
939 // eviction order is deterministic even under a coarse clock.
940 finished.sort_by_key(|&(id, finished_at)| (finished_at, id.0));
941 let evict = finished.len() - keep;
942 for (id, _) in finished.into_iter().take(evict) {
943 if let Some(mut job) = jobs.remove(&id) {
944 job.cleanup_files();
945 }
946 }
947 }
948
949 /// Check if a specific job exists.
950 pub async fn exists(&self, id: JobId) -> bool {
951 let jobs = self.jobs.lock().await;
952 jobs.contains_key(&id)
953 }
954
955 /// Get info for a specific job.
956 pub async fn get(&self, id: JobId) -> Option<JobInfo> {
957 let mut jobs = self.jobs.lock().await;
958 jobs.get_mut(&id).map(|job| {
959 let status = job.status();
960 job.to_info(status)
961 })
962 }
963
964 /// Get the command string for a job.
965 pub async fn get_command(&self, id: JobId) -> Option<String> {
966 let jobs = self.jobs.lock().await;
967 jobs.get(&id).map(|job| job.command.clone())
968 }
969
970 /// Get the status string for a job (for /v/jobs/{id}/status).
971 pub async fn get_status_string(&self, id: JobId) -> Option<String> {
972 let mut jobs = self.jobs.lock().await;
973 jobs.get_mut(&id).map(|job| job.status_string())
974 }
975
976 /// List all job IDs, sorted (GH #247 — see [`Self::list`]'s doc for why
977 /// the backing `HashMap`'s iteration order is not good enough here: this
978 /// backs the `/v/jobs` directory listing via [`crate::vfs::JobFs`]).
979 pub async fn list_ids(&self) -> Vec<JobId> {
980 let jobs = self.jobs.lock().await;
981 let mut ids: Vec<JobId> = jobs.keys().copied().collect();
982 ids.sort();
983 ids
984 }
985
986 /// Register a stopped job (from Ctrl-Z on a foreground process).
987 pub async fn register_stopped(&self, command: String, pid: u32, pgid: u32) -> JobId {
988 let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
989 let job = Job::stopped(id, self.session_id, command, pid, pgid);
990 let mut jobs = self.jobs.lock().await;
991 jobs.insert(id, job);
992 self.enforce_retention_locked(&mut jobs);
993 id
994 }
995
996 /// Mark a job as stopped with its process info.
997 pub async fn stop_job(&self, id: JobId, pid: u32, pgid: u32) {
998 let mut jobs = self.jobs.lock().await;
999 if let Some(job) = jobs.get_mut(&id) {
1000 job.stopped = true;
1001 job.pid = Some(pid);
1002 job.pgid = Some(pgid);
1003 }
1004 }
1005
1006 /// Mark a stopped job as resumed.
1007 pub async fn resume_job(&self, id: JobId) {
1008 let mut jobs = self.jobs.lock().await;
1009 if let Some(job) = jobs.get_mut(&id) {
1010 job.stopped = false;
1011 }
1012 }
1013
1014 /// Get the most recently stopped job.
1015 pub async fn last_stopped(&self) -> Option<JobId> {
1016 let mut jobs = self.jobs.lock().await;
1017 // Find the highest-numbered stopped job
1018 let mut best: Option<JobId> = None;
1019 for job in jobs.values_mut() {
1020 if job.stopped {
1021 match best {
1022 None => best = Some(job.id),
1023 Some(b) if job.id.0 > b.0 => best = Some(job.id),
1024 _ => {}
1025 }
1026 }
1027 }
1028 best
1029 }
1030
1031 /// Get process info (pid, pgid) for a job.
1032 pub async fn get_process_info(&self, id: JobId) -> Option<(u32, u32)> {
1033 let jobs = self.jobs.lock().await;
1034 jobs.get(&id).and_then(|job| {
1035 match (job.pid, job.pgid) {
1036 (Some(pid), Some(pgid)) => Some((pid, pgid)),
1037 _ => None,
1038 }
1039 })
1040 }
1041
1042 /// Record the cancellation token of the fork running a background job, so
1043 /// `kill %N` can stop the job even when it has no OS process group of its
1044 /// own (e.g. a pure builtin like `sleep &`).
1045 pub async fn set_cancel_token(&self, id: JobId, token: tokio_util::sync::CancellationToken) {
1046 let mut jobs = self.jobs.lock().await;
1047 if let Some(job) = jobs.get_mut(&id) {
1048 job.cancel = Some(token);
1049 }
1050 }
1051
1052 /// Flag a terminating kill and trip the job's cancellation token, as one
1053 /// operation under the jobs lock. Returns `false` — and leaves the job
1054 /// **unflagged** — when there is no lever to kill with: no cancellation
1055 /// token recorded and no OS signal already delivered (`delivered`). The
1056 /// flag turns the job's terminal status into `Killed`, so setting it
1057 /// without a working delivery would misclassify a later *organic* failure
1058 /// as a kill (found in review: `JobManager::spawn`/`register` jobs have
1059 /// no token unless the kernel records one).
1060 ///
1061 /// The flag is set *before* the token trips (the job can unwind the
1062 /// instant it does; a flag set after races the status read), and the
1063 /// token is cancelled after the lock drops — `CancellationToken::cancel`
1064 /// is synchronous, but waking waiters under the jobs lock buys nothing.
1065 pub async fn mark_killed_and_cancel(&self, id: JobId, delivered: bool) -> bool {
1066 let token = {
1067 let mut jobs = self.jobs.lock().await;
1068 let Some(job) = jobs.get_mut(&id) else {
1069 return false;
1070 };
1071 let token = job.cancel.clone();
1072 if token.is_none() && !delivered {
1073 return false;
1074 }
1075 job.killed = true;
1076 token
1077 };
1078 if let Some(token) = token {
1079 token.cancel();
1080 }
1081 true
1082 }
1083
1084 /// Cancel a job by its token. Returns `true` if a token was recorded and
1085 /// cancelled. The cancellation cascade stops in-process builtin futures and
1086 /// SIGTERM→SIGKILLs any external children's process groups.
1087 pub async fn cancel(&self, id: JobId) -> bool {
1088 let jobs = self.jobs.lock().await;
1089 match jobs.get(&id).and_then(|job| job.cancel.clone()) {
1090 Some(token) => {
1091 token.cancel();
1092 true
1093 }
1094 None => false,
1095 }
1096 }
1097
1098 /// Record a process group spawned while running a background job. Lets
1099 /// `kill -<sig> %N` deliver an arbitrary signal directly to the real
1100 /// processes. Deduplicated (a job may spawn several externals).
1101 pub async fn add_pgid(&self, id: JobId, pgid: u32) {
1102 let mut jobs = self.jobs.lock().await;
1103 if let Some(job) = jobs.get_mut(&id) {
1104 if !job.pgids.contains(&pgid) {
1105 job.pgids.push(pgid);
1106 }
1107 }
1108 }
1109
1110 /// The process groups recorded for a job (empty for a pure-builtin job).
1111 /// Includes the legacy single `pgid` recorded for *stopped* jobs (Ctrl-Z),
1112 /// so `kill %N` signals a stopped foreground job's group too.
1113 pub async fn job_pgids(&self, id: JobId) -> Vec<u32> {
1114 let jobs = self.jobs.lock().await;
1115 jobs.get(&id).map(Job::pgids_combined).unwrap_or_default()
1116 }
1117
1118 /// Non-blocking accessor for a finished job's result — `None` while the
1119 /// job is still `Running`/`Stopped`, or if `id` doesn't exist. Unlike
1120 /// [`Self::wait`], this never parks: it polls once and returns whatever is
1121 /// (or isn't) already available. GH #243: previously the only ways to
1122 /// read a job's `ExecResult` were `wait` (blocks until done) or
1123 /// string-parsing `failed:{code}` off `/v/jobs/N/status`.
1124 pub async fn try_result(&self, id: JobId) -> Option<ExecResult> {
1125 let mut jobs = self.jobs.lock().await;
1126 let job = jobs.get_mut(&id)?;
1127 job.try_poll();
1128 job.try_result().cloned()
1129 }
1130
1131 /// Remove a job from tracking.
1132 pub async fn remove(&self, id: JobId) {
1133 let mut jobs = self.jobs.lock().await;
1134 if let Some(mut job) = jobs.remove(&id) {
1135 job.cleanup_files();
1136 }
1137 }
1138}
1139
1140impl Default for JobManager {
1141 fn default() -> Self {
1142 Self::new()
1143 }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149 use std::time::Duration;
1150
1151 #[tokio::test]
1152 async fn test_no_host_output_file_when_persistence_disabled() {
1153 // A hermetic / read-only kernel (custom backend, or NoLocal mode)
1154 // disables host output-file persistence so a background job's output
1155 // never lands on the real filesystem via `std::fs`, bypassing the VFS.
1156 let manager = JobManager::new();
1157 assert!(manager.persist_output_files(), "default is to persist");
1158 manager.set_persist_output_files(false);
1159 assert!(!manager.persist_output_files());
1160
1161 let id = manager.spawn("leaky".to_string(), async {
1162 ExecResult::success("output that must not hit host disk")
1163 }).await;
1164 tokio::time::sleep(Duration::from_millis(10)).await;
1165 let result = manager.wait(id).await;
1166 assert!(result.is_some());
1167
1168 // No temp file should have been written to the host filesystem.
1169 let output_file = {
1170 let jobs = manager.jobs.lock().await;
1171 jobs.get(&id).and_then(|j| j.output_file().cloned())
1172 };
1173 assert!(
1174 output_file.is_none(),
1175 "no host output file should be written when persistence is disabled, got {output_file:?}"
1176 );
1177 }
1178
1179 #[tokio::test]
1180 async fn test_spawn_and_wait() {
1181 let manager = JobManager::new();
1182
1183 let id = manager.spawn("test".to_string(), async {
1184 tokio::time::sleep(Duration::from_millis(10)).await;
1185 ExecResult::success("done")
1186 }).await;
1187
1188 // Wait a bit for the job to be registered
1189 tokio::time::sleep(Duration::from_millis(5)).await;
1190
1191 let result = manager.wait(id).await;
1192 assert!(result.is_some());
1193 let result = result.unwrap();
1194 assert!(result.ok());
1195 assert_eq!(&*result.text_out(), "done");
1196 }
1197
1198 #[tokio::test]
1199 async fn test_wait_all() {
1200 let manager = JobManager::new();
1201
1202 manager.spawn("job1".to_string(), async {
1203 tokio::time::sleep(Duration::from_millis(10)).await;
1204 ExecResult::success("one")
1205 }).await;
1206
1207 manager.spawn("job2".to_string(), async {
1208 tokio::time::sleep(Duration::from_millis(5)).await;
1209 ExecResult::success("two")
1210 }).await;
1211
1212 // Wait for jobs to register
1213 tokio::time::sleep(Duration::from_millis(5)).await;
1214
1215 let results = manager.wait_all().await;
1216 assert_eq!(results.len(), 2);
1217 }
1218
1219 #[tokio::test]
1220 async fn test_list_jobs() {
1221 let manager = JobManager::new();
1222
1223 manager.spawn("test job".to_string(), async {
1224 tokio::time::sleep(Duration::from_millis(50)).await;
1225 ExecResult::success("")
1226 }).await;
1227
1228 // Wait for job to register
1229 tokio::time::sleep(Duration::from_millis(5)).await;
1230
1231 let jobs = manager.list().await;
1232 assert_eq!(jobs.len(), 1);
1233 assert_eq!(jobs[0].command, "test job");
1234 assert_eq!(jobs[0].status, JobStatus::Running);
1235 }
1236
1237 #[tokio::test]
1238 async fn test_job_status_after_completion() {
1239 let manager = JobManager::new();
1240
1241 let id = manager.spawn("quick".to_string(), async {
1242 ExecResult::success("")
1243 }).await;
1244
1245 // Wait for job to complete
1246 tokio::time::sleep(Duration::from_millis(10)).await;
1247 let _ = manager.wait(id).await;
1248
1249 let info = manager.get(id).await;
1250 assert!(info.is_some());
1251 assert_eq!(info.unwrap().status, JobStatus::Done);
1252 }
1253
1254 #[tokio::test]
1255 async fn test_job_info_carries_exit_code_on_failure() {
1256 // GH #243(a): a job that exited 42 must surface the code on
1257 // JobInfo.exit_code, not just JobStatus::Failed — the audit verified
1258 // `jobs --json` lost it entirely.
1259 let manager = JobManager::new();
1260
1261 let id = manager
1262 .spawn("sh -c 'exit 42'".to_string(), async {
1263 ExecResult::failure(42, "")
1264 })
1265 .await;
1266
1267 tokio::time::sleep(Duration::from_millis(10)).await;
1268
1269 let info = manager.get(id).await.expect("job exists");
1270 assert_eq!(info.status, JobStatus::Failed);
1271 assert_eq!(info.exit_code, Some(42), "exit code must survive onto JobInfo");
1272 }
1273
1274 #[tokio::test]
1275 async fn test_job_info_exit_code_none_while_running() {
1276 let manager = JobManager::new();
1277 let (_tx, rx) = oneshot::channel();
1278 let id = manager.register("still going".to_string(), rx).await;
1279
1280 let info = manager.get(id).await.expect("job exists");
1281 assert_eq!(info.status, JobStatus::Running);
1282 assert!(info.exit_code.is_none(), "a running job has no exit code yet");
1283 }
1284
1285 #[tokio::test]
1286 async fn test_job_info_started_at_and_finished_at() {
1287 // GH #243(b): timestamps must be present so an embedder can compute
1288 // "running for Ns" or sort by recency.
1289 let manager = JobManager::new();
1290 let before_spawn = kaish_types::clock::system_now();
1291
1292 let id = manager
1293 .spawn("quick".to_string(), async {
1294 tokio::time::sleep(Duration::from_millis(10)).await;
1295 ExecResult::success("")
1296 })
1297 .await;
1298
1299 // Immediately after spawn, started_at is set but finished_at is not.
1300 let info = manager.get(id).await.expect("job exists");
1301 assert!(
1302 info.started_at >= before_spawn,
1303 "started_at must be stamped at (or after) spawn time"
1304 );
1305 assert!(info.finished_at.is_none(), "not finished yet");
1306
1307 let _ = manager.wait(id).await;
1308
1309 let info = manager.get(id).await.expect("job exists");
1310 let finished_at = info.finished_at.expect("finished_at must be set once done");
1311 assert!(
1312 finished_at >= info.started_at,
1313 "finished_at must be at or after started_at"
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn test_job_info_surfaces_pgids() {
1319 // GH #243(c): pgids (real OS process groups an embedder actually
1320 // creates) must be surfaced on JobInfo — pid almost never is
1321 // (TTY-only, Ctrl-Z path).
1322 let manager = JobManager::new();
1323 let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1324
1325 manager.add_pgid(id, 4242).await;
1326 manager.add_pgid(id, 4243).await;
1327
1328 let info = manager.get(id).await.expect("job exists");
1329 assert_eq!(info.pgids, vec![4242, 4243]);
1330 assert!(info.pid.is_none(), "pid stays None for a non-stopped job");
1331 }
1332
1333 #[tokio::test]
1334 async fn test_try_result_is_non_blocking_and_none_while_running() {
1335 // GH #243: previously the only way to read a finished job's ExecResult
1336 // was the blocking `wait`; `Job::try_result` was `pub` but the `jobs`
1337 // map was private, so nothing on JobManager could reach it.
1338 let manager = JobManager::new();
1339 let (_tx, rx) = oneshot::channel::<ExecResult>();
1340 let id = manager.register("still going".to_string(), rx).await;
1341
1342 // Must return immediately (no sleep here) with None — the job never
1343 // got a result.
1344 assert!(manager.try_result(id).await.is_none());
1345
1346 // Unknown id -> None too, no panic.
1347 assert!(manager.try_result(JobId(999)).await.is_none());
1348 }
1349
1350 #[tokio::test]
1351 async fn test_try_result_returns_result_once_finished() {
1352 let manager = JobManager::new();
1353 let id = manager
1354 .spawn("quick".to_string(), async { ExecResult::success("hi") })
1355 .await;
1356
1357 tokio::time::sleep(Duration::from_millis(10)).await;
1358
1359 let result = manager.try_result(id).await.expect("job finished");
1360 assert!(result.ok());
1361 assert_eq!(&*result.text_out(), "hi");
1362
1363 // The job is still tracked (try_result doesn't reap) — a second call
1364 // (and wait()) still sees it.
1365 assert!(manager.try_result(id).await.is_some());
1366 assert!(manager.wait(id).await.is_some());
1367 }
1368
1369 #[tokio::test]
1370 async fn test_cleanup() {
1371 let manager = JobManager::new();
1372
1373 let id = manager.spawn("done".to_string(), async {
1374 ExecResult::success("")
1375 }).await;
1376
1377 // Wait for completion
1378 tokio::time::sleep(Duration::from_millis(10)).await;
1379 let _ = manager.wait(id).await;
1380
1381 // Should have 1 job
1382 assert_eq!(manager.list().await.len(), 1);
1383
1384 // Cleanup
1385 manager.cleanup().await;
1386
1387 // Should have 0 jobs
1388 assert_eq!(manager.list().await.len(), 0);
1389 }
1390
1391 #[tokio::test]
1392 async fn test_cleanup_removes_temp_files() {
1393 // Bug K: cleanup should remove temp files
1394 let manager = JobManager::new();
1395
1396 let id = manager.spawn("output job".to_string(), async {
1397 ExecResult::success("some output that gets written to a temp file")
1398 }).await;
1399
1400 // Wait for completion (triggers output file creation)
1401 tokio::time::sleep(Duration::from_millis(10)).await;
1402 let result = manager.wait(id).await;
1403 assert!(result.is_some());
1404
1405 // Get the output file path before cleanup. The job produced output, so
1406 // a temp file must have been written — otherwise this test would pass
1407 // vacuously.
1408 let output_file = {
1409 let jobs = manager.jobs.lock().await;
1410 jobs.get(&id).and_then(|j| j.output_file().cloned())
1411 };
1412 let path = output_file.expect("job with output should have written a temp file");
1413 assert!(path.exists(), "temp file should exist before cleanup: {}", path.display());
1414
1415 // Cleanup should remove the job and its files.
1416 manager.cleanup().await;
1417
1418 assert!(
1419 !path.exists(),
1420 "temp file should be removed after cleanup: {}",
1421 path.display()
1422 );
1423 }
1424
1425 #[tokio::test]
1426 async fn test_reap_finished_returns_removed_job_info() {
1427 // GH #131: the REPL's pre-prompt notification needs the id/command/
1428 // status of each reaped job, not just a count.
1429 let manager = JobManager::new();
1430 manager.set_persist_output_files(false);
1431
1432 let id = manager
1433 .spawn("sleep 0.1".to_string(), async { ExecResult::success("") })
1434 .await;
1435 tokio::time::sleep(Duration::from_millis(10)).await;
1436 let _ = manager.wait(id).await;
1437
1438 let removed = manager.reap_finished().await;
1439 assert_eq!(removed.len(), 1);
1440 assert_eq!(removed[0].id, id);
1441 assert_eq!(removed[0].command, "sleep 0.1");
1442 assert_eq!(removed[0].status, JobStatus::Done);
1443
1444 // And it's actually gone from tracking.
1445 assert!(manager.list().await.is_empty());
1446 }
1447
1448 #[tokio::test]
1449 async fn test_register_with_channel() {
1450 let manager = JobManager::new();
1451 let (tx, rx) = oneshot::channel();
1452
1453 let id = manager.register("channel job".to_string(), rx).await;
1454
1455 // Send result
1456 tx.send(ExecResult::success("from channel")).unwrap();
1457
1458 let result = manager.wait(id).await;
1459 assert!(result.is_some());
1460 assert_eq!(&*result.unwrap().text_out(), "from channel");
1461 }
1462
1463 /// GH #247: `execute_background` uses the oneshot-channel path
1464 /// exclusively, so a panic inside the spawned task drops the sender
1465 /// without a result — the exact shape reproduced here by dropping `tx`
1466 /// directly rather than triggering a real panic. Before the fix this
1467 /// reported `failed:1` with the generic text "job channel closed",
1468 /// indistinguishable from a command that legitimately exited 1 and never
1469 /// logged. The result must now name what actually happened (a task that
1470 /// ended without producing a result) instead of reading like an ordinary
1471 /// command failure.
1472 #[tokio::test]
1473 async fn test_dropped_sender_reports_as_a_kernel_fault_not_exit_1() {
1474 let manager = JobManager::new();
1475 let (tx, rx) = oneshot::channel::<ExecResult>();
1476
1477 let id = manager.register("will panic".to_string(), rx).await;
1478 drop(tx); // simulates the spawned task's future panicking mid-flight
1479
1480 let result = manager.wait(id).await.expect("job must resolve, not hang");
1481 assert_eq!(result.code, 1);
1482 assert!(
1483 !result.err.contains("job channel closed"),
1484 "message must not use the old generic wording: {}",
1485 result.err
1486 );
1487 assert!(
1488 result.err.contains("panic") || result.err.contains("task ended without a result"),
1489 "message must name a kernel fault, not read like an ordinary exit 1: {}",
1490 result.err
1491 );
1492 }
1493
1494 #[tokio::test]
1495 async fn test_spawn_immediately_available() {
1496 // Bug J: job should be queryable immediately after spawn()
1497 let manager = JobManager::new();
1498
1499 let id = manager.spawn("instant".to_string(), async {
1500 tokio::time::sleep(Duration::from_millis(100)).await;
1501 ExecResult::success("done")
1502 }).await;
1503
1504 // Should be immediately visible without any sleep
1505 let exists = manager.exists(id).await;
1506 assert!(exists, "job should be immediately available after spawn()");
1507
1508 let info = manager.get(id).await;
1509 assert!(info.is_some(), "job info should be available immediately");
1510 }
1511
1512 #[tokio::test]
1513 async fn test_nonexistent_job() {
1514 let manager = JobManager::new();
1515 let result = manager.wait(JobId(999)).await;
1516 assert!(result.is_none());
1517 }
1518
1519 /// GH #247: `list`/`list_ids` iterated the backing `HashMap` directly, so
1520 /// two jobs could come back as `[2, 1]` — arbitrary, and a flake source
1521 /// for any MCP caller or snapshot test that depended on the order. Job
1522 /// ids are minted strictly increasing (`next_id`), so ascending-by-id is
1523 /// the one order that is both stable and meaningful (spawn order).
1524 #[tokio::test]
1525 async fn test_list_and_list_ids_are_sorted_by_id() {
1526 let manager = JobManager::new();
1527 let mut ids = Vec::new();
1528 for n in 0..8 {
1529 let (_tx, rx) = oneshot::channel::<ExecResult>();
1530 ids.push(manager.register(format!("job-{n}"), rx).await);
1531 }
1532
1533 let listed_ids = manager.list_ids().await;
1534 assert_eq!(listed_ids, ids, "list_ids must come back in ascending JobId order");
1535
1536 let infos = manager.list().await;
1537 let info_ids: Vec<JobId> = infos.iter().map(|i| i.id).collect();
1538 assert_eq!(info_ids, ids, "list must come back in ascending JobId order");
1539 }
1540
1541 #[tokio::test]
1542 async fn test_cancel_token_fires() {
1543 // A recorded cancel token can be tripped by id — this is how `kill %N`
1544 // stops a pure-builtin job that has no OS process group.
1545 let manager = JobManager::new();
1546 let token = tokio_util::sync::CancellationToken::new();
1547 let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1548 manager.set_cancel_token(id, token.clone()).await;
1549
1550 assert!(!token.is_cancelled());
1551 assert!(manager.cancel(id).await, "cancel should report success");
1552 assert!(token.is_cancelled(), "the job's token must be tripped");
1553 }
1554
1555 #[tokio::test]
1556 async fn test_cancel_without_token_returns_false() {
1557 let manager = JobManager::new();
1558 let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1559 // No token recorded → nothing to cancel.
1560 assert!(!manager.cancel(id).await);
1561 // Unknown id → also false.
1562 assert!(!manager.cancel(JobId(999)).await);
1563 }
1564
1565 #[tokio::test]
1566 async fn test_pgids_recorded_and_deduped() {
1567 let manager = JobManager::new();
1568 let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1569 assert!(manager.job_pgids(id).await.is_empty());
1570
1571 manager.add_pgid(id, 4242).await;
1572 manager.add_pgid(id, 4243).await;
1573 manager.add_pgid(id, 4242).await; // duplicate ignored
1574 assert_eq!(manager.job_pgids(id).await, vec![4242, 4243]);
1575
1576 // Unknown id → empty, no panic.
1577 assert!(manager.job_pgids(JobId(999)).await.is_empty());
1578 }
1579
1580 #[tokio::test]
1581 async fn wait_does_not_block_other_job_ops() {
1582 // Regression: `wait(id)` must NOT hold the jobs mutex across the job's
1583 // completion. The buggy version did, so while a `wait %N` was parked,
1584 // every other job op (list/spawn/status) blocked until the job finished
1585 // — a nested `&` under `wait %N` deadlocked. (Also covers the old
1586 // `spawn` try_lock busy-spin, which on a current-thread runtime livelocked
1587 // the executor when the lock was held.)
1588 let manager = Arc::new(JobManager::new());
1589 manager.set_persist_output_files(false);
1590
1591 // A job that blocks until we release it.
1592 let (tx, rx) = oneshot::channel::<()>();
1593 let id = manager
1594 .spawn("blocker".to_string(), async move {
1595 let _ = rx.await;
1596 ExecResult::success("done")
1597 })
1598 .await;
1599
1600 // Park a waiter on it (in the buggy version, holds the lock for the
1601 // job's whole lifetime).
1602 let waiter = {
1603 let m = manager.clone();
1604 tokio::spawn(async move { m.wait(id).await })
1605 };
1606 // Let the waiter acquire the lock and park on the job's completion.
1607 tokio::time::sleep(Duration::from_millis(50)).await;
1608
1609 // Other job ops must stay responsive while the waiter is parked.
1610 let listed = tokio::time::timeout(Duration::from_secs(2), manager.list()).await;
1611 assert!(
1612 listed.is_ok(),
1613 "list() blocked while wait() was parked — jobs lock held across await"
1614 );
1615 let second = tokio::time::timeout(
1616 Duration::from_secs(2),
1617 manager.spawn("second".to_string(), async { ExecResult::success("2") }),
1618 )
1619 .await;
1620 assert!(
1621 second.is_ok(),
1622 "spawn() blocked/spun while wait() was parked"
1623 );
1624
1625 // Release the job; the parked waiter must observe the result.
1626 let _ = tx.send(());
1627 let result = tokio::time::timeout(Duration::from_secs(2), waiter)
1628 .await
1629 .expect("waiter join timed out")
1630 .expect("waiter task panicked");
1631 assert_eq!(result.map(|r| r.code), Some(0), "waiter should see exit 0");
1632 }
1633
1634 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1635 async fn wait_survives_a_dropped_waiter() {
1636 // Regression (Gemini review): a waiter dropped mid-wait (e.g.
1637 // `timeout N wait %1`) must NOT orphan the job's result. The buggy
1638 // version took the JoinHandle out to await it, so dropping that waiter
1639 // detached the task and lost its result, and a SECOND `wait %1` then
1640 // hung forever (busy-spinning in the AlreadyWaiting branch). `wait` must
1641 // never take the handle until it's finished.
1642 let manager = Arc::new(JobManager::new());
1643 manager.set_persist_output_files(false);
1644
1645 let (tx, rx) = oneshot::channel::<()>();
1646 let id = manager
1647 .spawn("blocker".to_string(), async move {
1648 let _ = rx.await;
1649 ExecResult::success("done")
1650 })
1651 .await;
1652
1653 // Waiter A parks on the job, then is aborted (dropped) before it finishes.
1654 {
1655 let m = manager.clone();
1656 let a = tokio::spawn(async move { m.wait(id).await });
1657 tokio::time::sleep(Duration::from_millis(20)).await;
1658 a.abort();
1659 let _ = a.await;
1660 }
1661
1662 // The job completes after A is gone.
1663 let _ = tx.send(());
1664
1665 // Waiter B must still observe the result, not hang.
1666 let res = tokio::time::timeout(Duration::from_secs(2), manager.wait(id))
1667 .await
1668 .expect("wait must not hang after a prior waiter was dropped");
1669 assert_eq!(res.map(|r| r.code), Some(0), "B should see the completed job");
1670 }
1671
1672 /// A Ctrl-Z'd job has no `JoinHandle` and no result channel, and `is_done()`
1673 /// returns `false` while `stopped` — so nothing can ever make it done.
1674 /// `wait_all` used to poll it forever at 10ms, and since `Kernel::shutdown`
1675 /// calls `wait_all`, one Ctrl-Z hung shutdown with no timeout and no escape.
1676 #[tokio::test]
1677 async fn wait_all_skips_a_stopped_job_instead_of_hanging_forever() {
1678 let manager = JobManager::new();
1679
1680 // No real process needed: `wait_all` decides on the `stopped` flag, and
1681 // the hang was never about the pid.
1682 let stopped = manager
1683 .register_stopped("sleep 5".to_string(), 4242, 4242)
1684 .await;
1685
1686 // A job that does finish, so this also proves we did not fix the hang by
1687 // making `wait_all` skip everything.
1688 let finisher = manager
1689 .spawn("finisher".to_string(), async { ExecResult::success("ok") })
1690 .await;
1691
1692 let results = tokio::time::timeout(Duration::from_secs(2), manager.wait_all())
1693 .await
1694 .expect("wait_all must not hang on a stopped job");
1695
1696 let ids: Vec<JobId> = results.iter().map(|(id, _)| *id).collect();
1697 assert!(
1698 !ids.contains(&stopped),
1699 "a stopped job can never complete, so wait_all must skip it"
1700 );
1701 assert!(
1702 ids.contains(&finisher),
1703 "wait_all must still collect jobs that can finish"
1704 );
1705 }
1706
1707 /// `wait` on an already-stopped job returns `None` immediately instead of
1708 /// polling a job that can never become done.
1709 #[tokio::test]
1710 async fn wait_returns_none_on_a_stopped_job() {
1711 let manager = JobManager::new();
1712 let id = manager
1713 .register_stopped("sleep 5".to_string(), 4242, 4242)
1714 .await;
1715
1716 let res = tokio::time::timeout(Duration::from_secs(2), manager.wait(id))
1717 .await
1718 .expect("wait on a stopped job must return, not hang");
1719 assert!(res.is_none(), "a stopped job has no result to wait for");
1720 }
1721
1722 /// The `wait_all` stopped-skip is a snapshot: a job that stops *after* the
1723 /// filter (the bg reaper observing a SIGSTOP) used to leave the inner
1724 /// `wait` polling `is_done()` forever — the same shutdown hang, reached
1725 /// through a sub-200ms window instead of always. The stopped guard inside
1726 /// `wait`'s loop closes it.
1727 #[tokio::test]
1728 async fn wait_bails_when_the_job_stops_mid_wait() {
1729 let manager = Arc::new(JobManager::new());
1730 manager.set_persist_output_files(false);
1731
1732 // A job that never finishes on its own: the sender side is kept alive
1733 // so the future stays parked until the test ends.
1734 let (_tx, rx) = oneshot::channel::<()>();
1735 let id = manager
1736 .spawn("blocker".to_string(), async move {
1737 let _ = rx.await;
1738 ExecResult::success("done")
1739 })
1740 .await;
1741
1742 let m = manager.clone();
1743 let waiter = tokio::spawn(async move { m.wait(id).await });
1744
1745 // Let the waiter enter its poll loop, then stop the job under it.
1746 tokio::time::sleep(Duration::from_millis(30)).await;
1747 manager.stop_job(id, 4242, 4242).await;
1748
1749 let res = tokio::time::timeout(Duration::from_secs(2), waiter)
1750 .await
1751 .expect("wait must return once the job stops, not poll forever")
1752 .expect("waiter task must not panic");
1753 assert!(res.is_none(), "a job that stopped mid-wait has no result");
1754 }
1755
1756 /// Same race through `wait_all`: the job passes the not-stopped snapshot,
1757 /// then stops while the inner `wait` polls it.
1758 #[tokio::test]
1759 async fn wait_all_returns_when_a_job_stops_after_the_snapshot() {
1760 let manager = Arc::new(JobManager::new());
1761 manager.set_persist_output_files(false);
1762
1763 let (_tx, rx) = oneshot::channel::<()>();
1764 let id = manager
1765 .spawn("blocker".to_string(), async move {
1766 let _ = rx.await;
1767 ExecResult::success("done")
1768 })
1769 .await;
1770
1771 let m = manager.clone();
1772 let all = tokio::spawn(async move { m.wait_all().await });
1773
1774 tokio::time::sleep(Duration::from_millis(30)).await;
1775 manager.stop_job(id, 4242, 4242).await;
1776
1777 let results = tokio::time::timeout(Duration::from_secs(2), all)
1778 .await
1779 .expect("wait_all must return once the job stops, not poll forever")
1780 .expect("wait_all task must not panic");
1781 assert!(
1782 !results.iter().any(|(rid, _)| *rid == id),
1783 "a job that stopped mid-wait_all yields no result"
1784 );
1785 }
1786
1787 /// GH #252: the status *string* (backing `/v/jobs/N/status`) must agree
1788 /// with `status()` about a stopped job. A stopped job has no result
1789 /// channel, so `try_poll` can never resolve it — without the explicit
1790 /// check it read `running` forever while `status()` said `Stopped`.
1791 #[tokio::test]
1792 async fn status_string_reports_stopped() {
1793 let manager = JobManager::new();
1794 let id = manager.register_stopped("vi".to_string(), 4242, 4242).await;
1795 assert_eq!(manager.get_status_string(id).await.as_deref(), Some("stopped"));
1796 assert_eq!(
1797 manager.get(id).await.map(|info| info.status),
1798 Some(JobStatus::Stopped),
1799 "status() and status_string() must agree"
1800 );
1801 }
1802
1803 /// GH #244: a killed job's terminal status is `Killed`/`killed:{code}`,
1804 /// not `Failed` — the flag is set by `mark_killed_and_cancel` before the
1805 /// cancel trips, and only colors a non-ok exit (a job that finished ok
1806 /// anyway still reads `Done`).
1807 #[tokio::test]
1808 async fn mark_killed_colors_the_terminal_status() {
1809 let manager = JobManager::new();
1810 manager.set_persist_output_files(false);
1811 let (tx, rx) = oneshot::channel::<()>();
1812 let id = manager
1813 .spawn("victim".to_string(), async move {
1814 let _ = rx.await;
1815 ExecResult::failure(130, "cancelled")
1816 })
1817 .await;
1818 // delivered=true stands in for a real killpg delivery — spawn()'d
1819 // jobs record no cancellation token.
1820 assert!(manager.mark_killed_and_cancel(id, true).await);
1821 drop(tx); // unblock the future — it returns the 130 result
1822 let result = manager.wait(id).await.expect("job finishes");
1823 assert_eq!(result.code, 130);
1824 assert_eq!(
1825 manager.get(id).await.map(|info| info.status),
1826 Some(JobStatus::Killed)
1827 );
1828 assert_eq!(manager.get_status_string(id).await.as_deref(), Some("killed:130"));
1829
1830 // A successful exit is never re-colored: the result is the truth.
1831 let id2 = manager
1832 .spawn("survivor".to_string(), async { ExecResult::success("done") })
1833 .await;
1834 assert!(manager.mark_killed_and_cancel(id2, true).await);
1835 let result = manager.wait(id2).await.expect("job finishes");
1836 assert!(result.ok());
1837 assert_eq!(
1838 manager.get(id2).await.map(|info| info.status),
1839 Some(JobStatus::Done),
1840 "a job that finished ok before the kill landed reports Done"
1841 );
1842 }
1843
1844 /// Review finding (GH #244): with no cancellation token and nothing
1845 /// delivered, `mark_killed_and_cancel` must refuse AND leave the flag
1846 /// unset — otherwise a later organic failure reads as a kill that never
1847 /// happened.
1848 #[tokio::test]
1849 async fn no_lever_kill_does_not_color_a_later_organic_failure() {
1850 let manager = JobManager::new();
1851 manager.set_persist_output_files(false);
1852 let (tx, rx) = oneshot::channel::<()>();
1853 let id = manager
1854 .spawn("doomed anyway".to_string(), async move {
1855 let _ = rx.await;
1856 ExecResult::failure(7, "organic failure")
1857 })
1858 .await;
1859 assert!(
1860 !manager.mark_killed_and_cancel(id, false).await,
1861 "no token + nothing delivered must refuse"
1862 );
1863 drop(tx);
1864 let result = manager.wait(id).await.expect("job finishes");
1865 assert_eq!(result.code, 7);
1866 assert_eq!(
1867 manager.get(id).await.map(|info| info.status),
1868 Some(JobStatus::Failed),
1869 "the failed kill attempt must not have colored the status"
1870 );
1871 assert_eq!(manager.get_status_string(id).await.as_deref(), Some("failed:7"));
1872 }
1873
1874 /// Review finding (GH #244): retention must also hold when jobs finish
1875 /// AFTER registration stopped — `list()` observes completions and
1876 /// enforces. (The registration-time test above releases each job before
1877 /// the next spawn, which masked this.)
1878 #[tokio::test]
1879 async fn retention_enforced_when_completion_is_observed_by_list() {
1880 let manager = JobManager::new();
1881 manager.set_persist_output_files(false);
1882 manager.set_finished_retention(2);
1883
1884 let mut releases = Vec::new();
1885 let mut ids = Vec::new();
1886 for n in 0..5 {
1887 let (tx, rx) = oneshot::channel::<()>();
1888 releases.push(tx);
1889 ids.push(
1890 manager
1891 .spawn(format!("held {n}"), async move {
1892 let _ = rx.await;
1893 ExecResult::success("")
1894 })
1895 .await,
1896 );
1897 }
1898 // All five registered while RUNNING — registration-time enforcement
1899 // had nothing to evict. Now they all finish with no registration
1900 // following. (Not waited one-by-one: wait() itself now evicts at
1901 // each observed completion, so a sequential wait on the oldest ids
1902 // finds them already gone — which is the feature, not the fixture.)
1903 for tx in releases {
1904 drop(tx);
1905 }
1906 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1907 loop {
1908 let infos = manager.list().await;
1909 if infos.iter().all(|info| info.status != JobStatus::Running) {
1910 break;
1911 }
1912 assert!(
1913 std::time::Instant::now() < deadline,
1914 "jobs did not finish in time"
1915 );
1916 tokio::time::sleep(Duration::from_millis(10)).await;
1917 }
1918 let mut tracked = 0;
1919 for id in &ids {
1920 if manager.exists(*id).await {
1921 tracked += 1;
1922 }
1923 }
1924 assert!(
1925 tracked <= 2,
1926 "finished jobs beyond the cap must be evicted once observed, still tracked: {tracked}"
1927 );
1928 }
1929
1930 /// GH #244: registration evicts the oldest finished jobs beyond the
1931 /// retention cap, so a never-reaping embedder stays bounded. Running jobs
1932 /// are never evicted.
1933 #[tokio::test]
1934 async fn finished_retention_evicts_oldest_at_registration() {
1935 let manager = JobManager::new();
1936 manager.set_persist_output_files(false);
1937 manager.set_finished_retention(2);
1938
1939 let mut finished_ids = Vec::new();
1940 for n in 0..4 {
1941 let id = manager
1942 .spawn(format!("quick {n}"), async { ExecResult::success("") })
1943 .await;
1944 manager.wait(id).await.expect("job finishes");
1945 finished_ids.push(id);
1946 }
1947 // A still-running job to prove eviction only touches finished ones.
1948 let (_tx, rx) = oneshot::channel::<()>();
1949 let running = manager
1950 .spawn("blocker".to_string(), async move {
1951 let _ = rx.await;
1952 ExecResult::success("")
1953 })
1954 .await;
1955
1956 assert!(manager.exists(running).await, "running job is never evicted");
1957 let tracked_finished: Vec<bool> = {
1958 let mut v = Vec::new();
1959 for id in &finished_ids {
1960 v.push(manager.exists(*id).await);
1961 }
1962 v
1963 };
1964 // Registering the 3rd and 4th quick jobs (and the blocker) evicted the
1965 // oldest finished entries down to the cap of 2.
1966 assert_eq!(
1967 tracked_finished,
1968 vec![false, false, true, true],
1969 "oldest finished jobs evicted first: {finished_ids:?}"
1970 );
1971 }
1972}