Skip to main content

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;
11
12use tokio::sync::{oneshot, Mutex};
13use tokio::task::JoinHandle;
14
15use super::stream::BoundedStream;
16use crate::interpreter::ExecResult;
17
18// Data types re-exported from kaish-types.
19pub use kaish_types::{JobId, JobInfo, JobStatus};
20
21/// A background job.
22pub struct Job {
23    /// Job ID.
24    pub id: JobId,
25    /// Owning manager's session ID — disambiguates output file paths between
26    /// JobManager instances that share the process (and thus the same job ID
27    /// space, since IDs restart at 1 per manager).
28    session_id: u64,
29    /// Command description.
30    pub command: String,
31    /// Task handle (None if already awaited).
32    handle: Option<JoinHandle<ExecResult>>,
33    /// Channel to receive result (alternative to handle).
34    result_rx: Option<oneshot::Receiver<ExecResult>>,
35    /// Cached result after completion.
36    result: Option<ExecResult>,
37    /// Path to output file (captures stdout/stderr after completion).
38    output_file: Option<PathBuf>,
39    /// Whether to persist completed output to a host temp file. Disabled for
40    /// hermetic / read-only kernels (custom backend, NoLocal) whose output must
41    /// never reach the real filesystem outside the VFS — see
42    /// [`JobManager::set_persist_output_files`]. Stamped from the manager when
43    /// the job is registered. Live output is always available in-memory via the
44    /// VFS streams (`/v/jobs/{id}/stdout`), so suppressing the file loses
45    /// nothing for an in-process consumer.
46    persist_output: bool,
47    /// Live stdout stream (bounded ring buffer).
48    stdout_stream: Option<Arc<BoundedStream>>,
49    /// Live stderr stream (bounded ring buffer).
50    stderr_stream: Option<Arc<BoundedStream>>,
51    /// OS process ID (for stopped jobs).
52    pid: Option<u32>,
53    /// OS process group ID (for stopped jobs).
54    pgid: Option<u32>,
55    /// Whether this job is stopped (SIGTSTP).
56    stopped: bool,
57    /// Cancellation token of the background fork running this job. Cancelling
58    /// it stops the job whether it is an in-process builtin future or wraps
59    /// external children (the cancellation cascade SIGTERM→SIGKILLs their
60    /// process groups). This is how `kill %N` reaches a job that has no OS
61    /// process group of its own (e.g. `sleep &`, a kaish builtin).
62    cancel: Option<tokio_util::sync::CancellationToken>,
63    /// Process groups of external children spawned while running this job.
64    /// Lets `kill -<sig> %N` deliver an arbitrary signal (STOP/CONT/USR1/…)
65    /// straight to the real processes via `killpg`, not just terminate. Empty
66    /// for a pure-builtin job (nothing with a PGID ran).
67    pgids: Vec<u32>,
68}
69
70impl Job {
71    /// Create a new job from a task handle.
72    pub fn new(id: JobId, session_id: u64, command: String, handle: JoinHandle<ExecResult>) -> Self {
73        Self {
74            id,
75            session_id,
76            command,
77            handle: Some(handle),
78            result_rx: None,
79            result: None,
80            output_file: None,
81            persist_output: true,
82            stdout_stream: None,
83            stderr_stream: None,
84            pid: None,
85            pgid: None,
86            stopped: false,
87            cancel: None,
88            pgids: Vec::new(),
89        }
90    }
91
92    /// Create a new job from a result channel.
93    pub fn from_channel(id: JobId, session_id: u64, command: String, rx: oneshot::Receiver<ExecResult>) -> Self {
94        Self {
95            id,
96            session_id,
97            command,
98            handle: None,
99            result_rx: Some(rx),
100            result: None,
101            output_file: None,
102            persist_output: true,
103            stdout_stream: None,
104            stderr_stream: None,
105            pid: None,
106            pgid: None,
107            stopped: false,
108            cancel: None,
109            pgids: Vec::new(),
110        }
111    }
112
113    /// Create a new job with attached output streams.
114    ///
115    /// The streams provide live access to job output via `/v/jobs/{id}/stdout` and `/stderr`.
116    pub fn with_streams(
117        id: JobId,
118        session_id: u64,
119        command: String,
120        rx: oneshot::Receiver<ExecResult>,
121        stdout: Arc<BoundedStream>,
122        stderr: Arc<BoundedStream>,
123    ) -> Self {
124        Self {
125            id,
126            session_id,
127            command,
128            handle: None,
129            result_rx: Some(rx),
130            result: None,
131            output_file: None,
132            persist_output: true,
133            stdout_stream: Some(stdout),
134            stderr_stream: Some(stderr),
135            pid: None,
136            pgid: None,
137            stopped: false,
138            cancel: None,
139            pgids: Vec::new(),
140        }
141    }
142
143    /// Create a stopped job (from Ctrl-Z on a foreground process).
144    pub fn stopped(id: JobId, session_id: u64, command: String, pid: u32, pgid: u32) -> Self {
145        Self {
146            id,
147            session_id,
148            command,
149            handle: None,
150            result_rx: None,
151            result: None,
152            output_file: None,
153            persist_output: true,
154            stdout_stream: None,
155            stderr_stream: None,
156            pid: Some(pid),
157            pgid: Some(pgid),
158            stopped: true,
159            cancel: None,
160            pgids: Vec::new(),
161        }
162    }
163
164    /// Get the output file path (if available).
165    pub fn output_file(&self) -> Option<&PathBuf> {
166        self.output_file.as_ref()
167    }
168
169    /// Check if the job has completed.
170    ///
171    /// Stopped jobs are not considered done.
172    pub fn is_done(&mut self) -> bool {
173        if self.stopped {
174            return false;
175        }
176        self.try_poll();
177        self.result.is_some()
178    }
179
180    /// Get the job's status.
181    pub fn status(&mut self) -> JobStatus {
182        if self.stopped {
183            return JobStatus::Stopped;
184        }
185        self.try_poll();
186        match &self.result {
187            Some(r) if r.ok() => JobStatus::Done,
188            Some(_) => JobStatus::Failed,
189            None => JobStatus::Running,
190        }
191    }
192
193    /// Get the job's status as a string suitable for /v/jobs/{id}/status.
194    ///
195    /// Returns:
196    /// - `"running"` if the job is still running
197    /// - `"done:0"` if the job completed successfully
198    /// - `"failed:{code}"` if the job failed with an exit code
199    pub fn status_string(&mut self) -> String {
200        self.try_poll();
201        match &self.result {
202            Some(r) if r.ok() => "done:0".to_string(),
203            Some(r) => format!("failed:{}", r.code),
204            None => "running".to_string(),
205        }
206    }
207
208    /// Get the stdout stream (if attached).
209    pub fn stdout_stream(&self) -> Option<&Arc<BoundedStream>> {
210        self.stdout_stream.as_ref()
211    }
212
213    /// Get the stderr stream (if attached).
214    pub fn stderr_stream(&self) -> Option<&Arc<BoundedStream>> {
215        self.stderr_stream.as_ref()
216    }
217
218    /// Write job output to a temp file.
219    fn write_output_file(&self, result: &ExecResult) -> Option<PathBuf> {
220        // This is a human-readable text log; a binary stdout is noted, not
221        // dumped (lossy-decoding it would corrupt; raw bytes would garble the
222        // log). Only its size is recorded.
223        let is_bytes = result.is_bytes();
224        let text = if is_bytes {
225            std::borrow::Cow::Borrowed("")
226        } else {
227            result.text_out()
228        };
229        if !is_bytes && text.is_empty() && result.err.is_empty() {
230            return None;
231        }
232
233        let tmp_dir = std::env::temp_dir().join("kaish").join("jobs");
234        if std::fs::create_dir_all(&tmp_dir).is_err() {
235            tracing::warn!("Failed to create job output directory");
236            return None;
237        }
238
239        // Include the OS pid: `session_id` is only unique *within* a process
240        // (it's a process-local atomic that restarts at 0), so two kaish
241        // processes on one host — or two `cargo test` binaries — would
242        // otherwise both write `session_0_job_1.txt` into this shared dir and
243        // clobber each other (a real cross-process collision, and the source
244        // of the `test_cleanup_removes_temp_files` flake). pid + session_id +
245        // job id is unique across processes. Mirrors `output_limit`'s spill
246        // filename convention.
247        let filename = format!(
248            "session_{}_job_{}.{}.txt",
249            self.session_id,
250            self.id.0,
251            std::process::id()
252        );
253        let path = tmp_dir.join(filename);
254
255        let mut content = String::new();
256        content.push_str(&format!("# Job {}: {}\n", self.id, self.command));
257        content.push_str(&format!("# Status: {}\n\n", if result.ok() { "Done" } else { "Failed" }));
258
259        if is_bytes {
260            let n = result.out_bytes().map(|b| b.len()).unwrap_or(0);
261            content.push_str(&format!(
262                "## STDOUT\n[binary output: {n} bytes — omitted from this text log]\n"
263            ));
264        } else if !text.is_empty() {
265            content.push_str("## STDOUT\n");
266            content.push_str(&text);
267            if !text.ends_with('\n') {
268                content.push('\n');
269            }
270        }
271
272        if !result.err.is_empty() {
273            content.push_str("\n## STDERR\n");
274            content.push_str(&result.err);
275            if !result.err.ends_with('\n') {
276                content.push('\n');
277            }
278        }
279
280        match std::fs::write(&path, content) {
281            Ok(()) => Some(path),
282            Err(e) => {
283                tracing::warn!("Failed to write job output file: {}", e);
284                None
285            }
286        }
287    }
288
289    /// Remove any temp files associated with this job.
290    pub fn cleanup_files(&mut self) {
291        if let Some(path) = self.output_file.take() {
292            if let Err(e) = std::fs::remove_file(&path) {
293                // Ignore "not found" — file may not have been written
294                if e.kind() != io::ErrorKind::NotFound {
295                    tracing::warn!("Failed to clean up job output file {}: {}", path.display(), e);
296                }
297            }
298        }
299    }
300
301    /// Get the result if completed, without waiting.
302    pub fn try_result(&self) -> Option<&ExecResult> {
303        self.result.as_ref()
304    }
305
306    /// Try to poll the result channel and update status.
307    ///
308    /// This is a non-blocking check that updates `self.result` if the
309    /// job has completed. Returns true if the job is now done.
310    pub fn try_poll(&mut self) -> bool {
311        if self.result.is_some() {
312            return true;
313        }
314
315        // Try to poll the oneshot channel
316        if let Some(rx) = self.result_rx.as_mut() {
317            match rx.try_recv() {
318                Ok(result) => {
319                    self.result = Some(result);
320                    self.result_rx = None;
321                    return true;
322                }
323                Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
324                    // Still running
325                    return false;
326                }
327                Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
328                    // Channel closed without result - job failed
329                    self.result = Some(ExecResult::failure(1, "job channel closed"));
330                    self.result_rx = None;
331                    return true;
332                }
333            }
334        }
335
336        // Check if handle is finished
337        if let Some(handle) = self.handle.as_mut()
338            && handle.is_finished() {
339                // Take the handle and wait for it (should be instant)
340                let Some(mut handle) = self.handle.take() else {
341                    return false;
342                };
343                // Poll directly with a noop waker — safe because is_finished() was true
344                let waker = std::task::Waker::noop();
345                let mut cx = std::task::Context::from_waker(waker);
346                let result = match std::pin::Pin::new(&mut handle).poll(&mut cx) {
347                    std::task::Poll::Ready(Ok(r)) => r,
348                    std::task::Poll::Ready(Err(e)) => {
349                        ExecResult::failure(1, format!("job panicked: {}", e))
350                    }
351                    std::task::Poll::Pending => return false, // shouldn't happen
352                };
353                self.result = Some(result);
354                return true;
355            }
356
357        false
358    }
359}
360
361/// Process-wide counter handing each JobManager a distinct session ID. Job IDs
362/// restart at 1 per manager, so the session ID is what keeps output file paths
363/// from colliding between managers sharing a process (concurrent tests, forks).
364/// It is process-LOCAL (restarts at 0 per process), so output filenames also
365/// mix in the OS pid to stay unique across processes — see `write_output_file`.
366static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(0);
367
368/// Remove job output files in `/tmp/kaish/jobs/` that were written by processes
369/// which are no longer running. Run once per process (guarded by a `Once` in
370/// [`JobManager::new`]).
371///
372/// Strategy: filenames follow `session_S_job_J.PID.txt`. We parse the PID
373/// component and skip files whose PID matches the current process (those
374/// belong to live sessions in this very process). For other PIDs we check
375/// `/proc/{pid}` on Linux; on non-Linux platforms we skip the prune entirely
376/// since there is no cheap cross-platform liveness check.
377///
378/// All errors are intentionally ignored — this is opportunistic cleanup only.
379fn prune_orphaned_job_files() {
380    // Only prune on Linux where /proc/{pid} is a reliable liveness check.
381    #[cfg(target_os = "linux")]
382    {
383        let jobs_dir = std::env::temp_dir().join("kaish").join("jobs");
384        let Ok(entries) = std::fs::read_dir(&jobs_dir) else {
385            return; // directory doesn't exist yet — nothing to prune
386        };
387        let current_pid = std::process::id();
388        for entry in entries.flatten() {
389            let name = entry.file_name();
390            let name_str = name.to_string_lossy();
391            // Expected format: session_S_job_J.PID.txt
392            // The PID sits between the last '.' before ".txt" and the preceding '.'.
393            let file_pid: Option<u32> = name_str
394                .strip_suffix(".txt")
395                .and_then(|s| s.rsplit_once('.'))
396                .and_then(|(_, pid_str)| pid_str.parse().ok());
397            let Some(pid) = file_pid else {
398                continue; // not a job output file — skip
399            };
400            if pid == current_pid {
401                continue; // belongs to the current process — leave it alone
402            }
403            // Check if the owning process is still alive via /proc.
404            if std::path::Path::new(&format!("/proc/{}", pid)).exists() {
405                continue; // process is still running — leave it alone
406            }
407            // Process is gone: remove the stale file. Error intentionally ignored.
408            let _ = std::fs::remove_file(entry.path());
409        }
410    }
411}
412
413/// Manager for background jobs.
414pub struct JobManager {
415    /// Process-unique ID for this manager, mixed into job output file paths.
416    session_id: u64,
417    /// Counter for generating unique job IDs.
418    next_id: AtomicU64,
419    /// Map of job ID to job.
420    jobs: Arc<Mutex<HashMap<JobId, Job>>>,
421    /// Whether completed jobs persist their output to a host temp file. On by
422    /// default; a hermetic / read-only kernel disables it so output never
423    /// bypasses the VFS onto the real filesystem (see
424    /// [`set_persist_output_files`](Self::set_persist_output_files)). Stamped
425    /// onto each [`Job`] at registration.
426    persist_output_files: std::sync::atomic::AtomicBool,
427}
428
429impl JobManager {
430    /// Create a new job manager.
431    ///
432    /// On construction, best-effort prunes stale job output files left by
433    /// previously crashed kaish processes. All errors are intentionally ignored
434    /// — startup cleanup is opportunistic and must never prevent the manager
435    /// from being created (silent-fallback rule: the only case where silent is
436    /// correct is read-only / cleanup-only paths with no data loss risk).
437    ///
438    /// # Scoping decision
439    /// All sessions share a single `/tmp/kaish/jobs/` directory. Filenames embed
440    /// the OS PID that wrote them (`session_S_job_J.PID.txt`). Files from the
441    /// current process are never touched here — only files whose embedded PID
442    /// refers to a dead process are removed. On Linux we check `/proc/{pid}` for
443    /// existence; on other platforms we skip the prune rather than guess.
444    pub fn new() -> Self {
445        // Orphans from dead sessions only need pruning once per process, not on
446        // every JobManager (kernels + every fork build one). The `Once` keeps
447        // the dir scan / `/proc` checks off the hot path of background jobs,
448        // scatter workers, and pipeline stages.
449        static PRUNE_ONCE: std::sync::Once = std::sync::Once::new();
450        PRUNE_ONCE.call_once(prune_orphaned_job_files);
451        Self {
452            session_id: NEXT_SESSION_ID.fetch_add(1, Ordering::SeqCst),
453            next_id: AtomicU64::new(1),
454            jobs: Arc::new(Mutex::new(HashMap::new())),
455            persist_output_files: std::sync::atomic::AtomicBool::new(true),
456        }
457    }
458
459    /// Toggle whether completed jobs persist their output to a host temp file.
460    ///
461    /// Disable this for a hermetic / read-only kernel: the host write in
462    /// [`Job::write_output_file`] uses `std::fs` directly and so bypasses the
463    /// VFS (and any read-only mount). Live output stays available in-memory via
464    /// the VFS streams (`/v/jobs/{id}/stdout`), so nothing is lost in-process.
465    ///
466    /// Must be set before jobs are spawned — the flag is stamped onto each job
467    /// at registration time, not consulted at completion.
468    pub fn set_persist_output_files(&self, on: bool) {
469        self.persist_output_files.store(on, Ordering::Relaxed);
470    }
471
472    /// Whether completed jobs persist their output to a host temp file.
473    pub fn persist_output_files(&self) -> bool {
474        self.persist_output_files.load(Ordering::Relaxed)
475    }
476
477    /// Spawn a new background job from a future.
478    ///
479    /// The job is inserted into the map synchronously before returning,
480    /// guaranteeing it's immediately queryable via `exists()` or `get()`.
481    pub async fn spawn<F>(&self, command: String, future: F) -> JobId
482    where
483        F: std::future::Future<Output = ExecResult> + Send + 'static,
484    {
485        let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
486        // Propagate the embedder's trace context across the spawn boundary so
487        // background-job spans stay in the same trace (see telemetry module).
488        let handle = tokio::spawn(crate::telemetry::bind_current_context(future));
489        let mut job = Job::new(id, self.session_id, command, handle);
490        job.persist_output = self.persist_output_files();
491
492        // Insert under an async lock — NOT a busy-spin on try_lock. The old
493        // sync spin could livelock the executor: on a current-thread runtime it
494        // blocks the only worker thread, so a task holding the lock across an
495        // await can never make progress to release it. `lock().await` yields
496        // instead. The insert still completes before we return, so the job is
497        // immediately queryable via `exists()`/`get()`.
498        self.jobs.lock().await.insert(id, job);
499
500        id
501    }
502
503    /// Spawn a job that's already running and communicate via channel.
504    pub async fn register(&self, command: String, rx: oneshot::Receiver<ExecResult>) -> JobId {
505        let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
506        let mut job = Job::from_channel(id, self.session_id, command, rx);
507        job.persist_output = self.persist_output_files();
508
509        let mut jobs = self.jobs.lock().await;
510        jobs.insert(id, job);
511
512        id
513    }
514
515    /// Register a job with attached output streams.
516    ///
517    /// The streams provide live access to job output via `/v/jobs/{id}/stdout` and `/stderr`.
518    pub async fn register_with_streams(
519        &self,
520        command: String,
521        rx: oneshot::Receiver<ExecResult>,
522        stdout: Arc<BoundedStream>,
523        stderr: Arc<BoundedStream>,
524    ) -> JobId {
525        let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
526        let mut job = Job::with_streams(id, self.session_id, command, rx, stdout, stderr);
527        job.persist_output = self.persist_output_files();
528
529        let mut jobs = self.jobs.lock().await;
530        jobs.insert(id, job);
531
532        id
533    }
534
535    /// Wait for a specific job to complete.
536    ///
537    /// The job's pending awaitable (its task handle or result channel) is taken
538    /// out of the map under the lock, then the lock is **released before**
539    /// awaiting completion. Holding the `jobs` mutex across the await would
540    /// block every other job operation (spawn/register/list/status/kill) for the
541    /// whole duration of the job — so a nested `&` started under a parked
542    /// `wait %N` would deadlock. The lock is re-acquired only to finalize
543    /// (persist output, cache the result).
544    pub async fn wait(&self, id: JobId) -> Option<ExecResult> {
545        // Poll for completion WITHOUT removing the job's `JoinHandle` from the
546        // map: `Job::try_poll` (via `is_done`) consumes the handle only once it
547        // has finished. That matters two ways:
548        //   * Drop-safe: a waiter dropped mid-wait (e.g. `timeout N wait %1`)
549        //     never carries the handle off and orphans the result, so a later
550        //     `wait %1` still completes instead of hanging.
551        //   * No lock-across-await: we sleep between polls rather than holding
552        //     the `jobs` mutex over the wait (which would block every other job
553        //     op — the deadlock this method exists to avoid) or busy-spinning.
554        // Cost is up to one poll interval of latency on completion — imperceptible
555        // for a job wait, and the sleep keeps idle CPU at zero.
556        loop {
557            {
558                let mut jobs = self.jobs.lock().await;
559                let job = jobs.get_mut(&id)?;
560                if job.is_done() {
561                    let result = job
562                        .result
563                        .clone()
564                        .unwrap_or_else(|| ExecResult::failure(1, "no result"));
565                    // Finalize once: persist output (idempotent on output_file).
566                    if job.persist_output
567                        && job.output_file.is_none()
568                        && let Some(path) = job.write_output_file(&result)
569                    {
570                        job.output_file = Some(path);
571                    }
572                    return Some(result);
573                }
574            }
575            // Lock released between polls — other job ops run freely.
576            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
577        }
578    }
579
580    /// Wait for all jobs to complete, returning results in completion order.
581    pub async fn wait_all(&self) -> Vec<(JobId, ExecResult)> {
582        let mut results = Vec::new();
583
584        // Get all job IDs
585        let ids: Vec<JobId> = {
586            let jobs = self.jobs.lock().await;
587            jobs.keys().copied().collect()
588        };
589
590        for id in ids {
591            if let Some(result) = self.wait(id).await {
592                results.push((id, result));
593            }
594        }
595
596        results
597    }
598
599    /// List all jobs with their status.
600    pub async fn list(&self) -> Vec<JobInfo> {
601        let mut jobs = self.jobs.lock().await;
602        jobs.values_mut()
603            .map(|job| JobInfo {
604                id: job.id,
605                command: job.command.clone(),
606                status: job.status(),
607                output_file: job.output_file.clone(),
608                pid: job.pid,
609            })
610            .collect()
611    }
612
613    /// Get the number of running jobs.
614    pub async fn running_count(&self) -> usize {
615        let mut jobs = self.jobs.lock().await;
616        let mut count = 0;
617        for job in jobs.values_mut() {
618            if !job.is_done() {
619                count += 1;
620            }
621        }
622        count
623    }
624
625    /// Remove completed jobs from tracking and clean up their temp files.
626    pub async fn cleanup(&self) {
627        let mut jobs = self.jobs.lock().await;
628        jobs.retain(|_, job| {
629            if job.is_done() {
630                job.cleanup_files();
631                false
632            } else {
633                true
634            }
635        });
636    }
637
638    /// Check if a specific job exists.
639    pub async fn exists(&self, id: JobId) -> bool {
640        let jobs = self.jobs.lock().await;
641        jobs.contains_key(&id)
642    }
643
644    /// Get info for a specific job.
645    pub async fn get(&self, id: JobId) -> Option<JobInfo> {
646        let mut jobs = self.jobs.lock().await;
647        jobs.get_mut(&id).map(|job| JobInfo {
648            id: job.id,
649            command: job.command.clone(),
650            status: job.status(),
651            output_file: job.output_file.clone(),
652            pid: job.pid,
653        })
654    }
655
656    /// Get the command string for a job.
657    pub async fn get_command(&self, id: JobId) -> Option<String> {
658        let jobs = self.jobs.lock().await;
659        jobs.get(&id).map(|job| job.command.clone())
660    }
661
662    /// Get the status string for a job (for /v/jobs/{id}/status).
663    pub async fn get_status_string(&self, id: JobId) -> Option<String> {
664        let mut jobs = self.jobs.lock().await;
665        jobs.get_mut(&id).map(|job| job.status_string())
666    }
667
668    /// Read stdout stream content for a job.
669    ///
670    /// Returns `None` if the job doesn't exist or has no attached stream.
671    pub async fn read_stdout(&self, id: JobId) -> Option<Vec<u8>> {
672        let jobs = self.jobs.lock().await;
673        if let Some(job) = jobs.get(&id)
674            && let Some(stream) = job.stdout_stream() {
675                return Some(stream.read().await);
676            }
677        None
678    }
679
680    /// Read stderr stream content for a job.
681    ///
682    /// Returns `None` if the job doesn't exist or has no attached stream.
683    pub async fn read_stderr(&self, id: JobId) -> Option<Vec<u8>> {
684        let jobs = self.jobs.lock().await;
685        if let Some(job) = jobs.get(&id)
686            && let Some(stream) = job.stderr_stream() {
687                return Some(stream.read().await);
688            }
689        None
690    }
691
692    /// List all job IDs.
693    pub async fn list_ids(&self) -> Vec<JobId> {
694        let jobs = self.jobs.lock().await;
695        jobs.keys().copied().collect()
696    }
697
698    /// Register a stopped job (from Ctrl-Z on a foreground process).
699    pub async fn register_stopped(&self, command: String, pid: u32, pgid: u32) -> JobId {
700        let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
701        let job = Job::stopped(id, self.session_id, command, pid, pgid);
702        let mut jobs = self.jobs.lock().await;
703        jobs.insert(id, job);
704        id
705    }
706
707    /// Mark a job as stopped with its process info.
708    pub async fn stop_job(&self, id: JobId, pid: u32, pgid: u32) {
709        let mut jobs = self.jobs.lock().await;
710        if let Some(job) = jobs.get_mut(&id) {
711            job.stopped = true;
712            job.pid = Some(pid);
713            job.pgid = Some(pgid);
714        }
715    }
716
717    /// Mark a stopped job as resumed.
718    pub async fn resume_job(&self, id: JobId) {
719        let mut jobs = self.jobs.lock().await;
720        if let Some(job) = jobs.get_mut(&id) {
721            job.stopped = false;
722        }
723    }
724
725    /// Get the most recently stopped job.
726    pub async fn last_stopped(&self) -> Option<JobId> {
727        let mut jobs = self.jobs.lock().await;
728        // Find the highest-numbered stopped job
729        let mut best: Option<JobId> = None;
730        for job in jobs.values_mut() {
731            if job.stopped {
732                match best {
733                    None => best = Some(job.id),
734                    Some(b) if job.id.0 > b.0 => best = Some(job.id),
735                    _ => {}
736                }
737            }
738        }
739        best
740    }
741
742    /// Get process info (pid, pgid) for a job.
743    pub async fn get_process_info(&self, id: JobId) -> Option<(u32, u32)> {
744        let jobs = self.jobs.lock().await;
745        jobs.get(&id).and_then(|job| {
746            match (job.pid, job.pgid) {
747                (Some(pid), Some(pgid)) => Some((pid, pgid)),
748                _ => None,
749            }
750        })
751    }
752
753    /// Record the cancellation token of the fork running a background job, so
754    /// `kill %N` can stop the job even when it has no OS process group of its
755    /// own (e.g. a pure builtin like `sleep &`).
756    pub async fn set_cancel_token(&self, id: JobId, token: tokio_util::sync::CancellationToken) {
757        let mut jobs = self.jobs.lock().await;
758        if let Some(job) = jobs.get_mut(&id) {
759            job.cancel = Some(token);
760        }
761    }
762
763    /// Cancel a job by its token. Returns `true` if a token was recorded and
764    /// cancelled. The cancellation cascade stops in-process builtin futures and
765    /// SIGTERM→SIGKILLs any external children's process groups.
766    pub async fn cancel(&self, id: JobId) -> bool {
767        let jobs = self.jobs.lock().await;
768        match jobs.get(&id).and_then(|job| job.cancel.clone()) {
769            Some(token) => {
770                token.cancel();
771                true
772            }
773            None => false,
774        }
775    }
776
777    /// Record a process group spawned while running a background job. Lets
778    /// `kill -<sig> %N` deliver an arbitrary signal directly to the real
779    /// processes. Deduplicated (a job may spawn several externals).
780    pub async fn add_pgid(&self, id: JobId, pgid: u32) {
781        let mut jobs = self.jobs.lock().await;
782        if let Some(job) = jobs.get_mut(&id) {
783            if !job.pgids.contains(&pgid) {
784                job.pgids.push(pgid);
785            }
786        }
787    }
788
789    /// The process groups recorded for a job (empty for a pure-builtin job).
790    /// Includes the legacy single `pgid` recorded for *stopped* jobs (Ctrl-Z),
791    /// so `kill %N` signals a stopped foreground job's group too.
792    pub async fn job_pgids(&self, id: JobId) -> Vec<u32> {
793        let jobs = self.jobs.lock().await;
794        jobs.get(&id)
795            .map(|job| {
796                let mut v = job.pgids.clone();
797                if let Some(pg) = job.pgid {
798                    if !v.contains(&pg) {
799                        v.push(pg);
800                    }
801                }
802                v
803            })
804            .unwrap_or_default()
805    }
806
807    /// Remove a job from tracking.
808    pub async fn remove(&self, id: JobId) {
809        let mut jobs = self.jobs.lock().await;
810        if let Some(mut job) = jobs.remove(&id) {
811            job.cleanup_files();
812        }
813    }
814}
815
816impl Default for JobManager {
817    fn default() -> Self {
818        Self::new()
819    }
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825    use std::time::Duration;
826
827    #[tokio::test]
828    async fn test_no_host_output_file_when_persistence_disabled() {
829        // A hermetic / read-only kernel (custom backend, or NoLocal mode)
830        // disables host output-file persistence so a background job's output
831        // never lands on the real filesystem via `std::fs`, bypassing the VFS.
832        let manager = JobManager::new();
833        assert!(manager.persist_output_files(), "default is to persist");
834        manager.set_persist_output_files(false);
835        assert!(!manager.persist_output_files());
836
837        let id = manager.spawn("leaky".to_string(), async {
838            ExecResult::success("output that must not hit host disk")
839        }).await;
840        tokio::time::sleep(Duration::from_millis(10)).await;
841        let result = manager.wait(id).await;
842        assert!(result.is_some());
843
844        // No temp file should have been written to the host filesystem.
845        let output_file = {
846            let jobs = manager.jobs.lock().await;
847            jobs.get(&id).and_then(|j| j.output_file().cloned())
848        };
849        assert!(
850            output_file.is_none(),
851            "no host output file should be written when persistence is disabled, got {output_file:?}"
852        );
853    }
854
855    #[tokio::test]
856    async fn test_spawn_and_wait() {
857        let manager = JobManager::new();
858
859        let id = manager.spawn("test".to_string(), async {
860            tokio::time::sleep(Duration::from_millis(10)).await;
861            ExecResult::success("done")
862        }).await;
863
864        // Wait a bit for the job to be registered
865        tokio::time::sleep(Duration::from_millis(5)).await;
866
867        let result = manager.wait(id).await;
868        assert!(result.is_some());
869        let result = result.unwrap();
870        assert!(result.ok());
871        assert_eq!(&*result.text_out(), "done");
872    }
873
874    #[tokio::test]
875    async fn test_wait_all() {
876        let manager = JobManager::new();
877
878        manager.spawn("job1".to_string(), async {
879            tokio::time::sleep(Duration::from_millis(10)).await;
880            ExecResult::success("one")
881        }).await;
882
883        manager.spawn("job2".to_string(), async {
884            tokio::time::sleep(Duration::from_millis(5)).await;
885            ExecResult::success("two")
886        }).await;
887
888        // Wait for jobs to register
889        tokio::time::sleep(Duration::from_millis(5)).await;
890
891        let results = manager.wait_all().await;
892        assert_eq!(results.len(), 2);
893    }
894
895    #[tokio::test]
896    async fn test_list_jobs() {
897        let manager = JobManager::new();
898
899        manager.spawn("test job".to_string(), async {
900            tokio::time::sleep(Duration::from_millis(50)).await;
901            ExecResult::success("")
902        }).await;
903
904        // Wait for job to register
905        tokio::time::sleep(Duration::from_millis(5)).await;
906
907        let jobs = manager.list().await;
908        assert_eq!(jobs.len(), 1);
909        assert_eq!(jobs[0].command, "test job");
910        assert_eq!(jobs[0].status, JobStatus::Running);
911    }
912
913    #[tokio::test]
914    async fn test_job_status_after_completion() {
915        let manager = JobManager::new();
916
917        let id = manager.spawn("quick".to_string(), async {
918            ExecResult::success("")
919        }).await;
920
921        // Wait for job to complete
922        tokio::time::sleep(Duration::from_millis(10)).await;
923        let _ = manager.wait(id).await;
924
925        let info = manager.get(id).await;
926        assert!(info.is_some());
927        assert_eq!(info.unwrap().status, JobStatus::Done);
928    }
929
930    #[tokio::test]
931    async fn test_cleanup() {
932        let manager = JobManager::new();
933
934        let id = manager.spawn("done".to_string(), async {
935            ExecResult::success("")
936        }).await;
937
938        // Wait for completion
939        tokio::time::sleep(Duration::from_millis(10)).await;
940        let _ = manager.wait(id).await;
941
942        // Should have 1 job
943        assert_eq!(manager.list().await.len(), 1);
944
945        // Cleanup
946        manager.cleanup().await;
947
948        // Should have 0 jobs
949        assert_eq!(manager.list().await.len(), 0);
950    }
951
952    #[tokio::test]
953    async fn test_cleanup_removes_temp_files() {
954        // Bug K: cleanup should remove temp files
955        let manager = JobManager::new();
956
957        let id = manager.spawn("output job".to_string(), async {
958            ExecResult::success("some output that gets written to a temp file")
959        }).await;
960
961        // Wait for completion (triggers output file creation)
962        tokio::time::sleep(Duration::from_millis(10)).await;
963        let result = manager.wait(id).await;
964        assert!(result.is_some());
965
966        // Get the output file path before cleanup. The job produced output, so
967        // a temp file must have been written — otherwise this test would pass
968        // vacuously.
969        let output_file = {
970            let jobs = manager.jobs.lock().await;
971            jobs.get(&id).and_then(|j| j.output_file().cloned())
972        };
973        let path = output_file.expect("job with output should have written a temp file");
974        assert!(path.exists(), "temp file should exist before cleanup: {}", path.display());
975
976        // Cleanup should remove the job and its files.
977        manager.cleanup().await;
978
979        assert!(
980            !path.exists(),
981            "temp file should be removed after cleanup: {}",
982            path.display()
983        );
984    }
985
986    #[tokio::test]
987    async fn test_register_with_channel() {
988        let manager = JobManager::new();
989        let (tx, rx) = oneshot::channel();
990
991        let id = manager.register("channel job".to_string(), rx).await;
992
993        // Send result
994        tx.send(ExecResult::success("from channel")).unwrap();
995
996        let result = manager.wait(id).await;
997        assert!(result.is_some());
998        assert_eq!(&*result.unwrap().text_out(), "from channel");
999    }
1000
1001    #[tokio::test]
1002    async fn test_spawn_immediately_available() {
1003        // Bug J: job should be queryable immediately after spawn()
1004        let manager = JobManager::new();
1005
1006        let id = manager.spawn("instant".to_string(), async {
1007            tokio::time::sleep(Duration::from_millis(100)).await;
1008            ExecResult::success("done")
1009        }).await;
1010
1011        // Should be immediately visible without any sleep
1012        let exists = manager.exists(id).await;
1013        assert!(exists, "job should be immediately available after spawn()");
1014
1015        let info = manager.get(id).await;
1016        assert!(info.is_some(), "job info should be available immediately");
1017    }
1018
1019    #[tokio::test]
1020    async fn test_nonexistent_job() {
1021        let manager = JobManager::new();
1022        let result = manager.wait(JobId(999)).await;
1023        assert!(result.is_none());
1024    }
1025
1026    #[tokio::test]
1027    async fn test_cancel_token_fires() {
1028        // A recorded cancel token can be tripped by id — this is how `kill %N`
1029        // stops a pure-builtin job that has no OS process group.
1030        let manager = JobManager::new();
1031        let token = tokio_util::sync::CancellationToken::new();
1032        let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1033        manager.set_cancel_token(id, token.clone()).await;
1034
1035        assert!(!token.is_cancelled());
1036        assert!(manager.cancel(id).await, "cancel should report success");
1037        assert!(token.is_cancelled(), "the job's token must be tripped");
1038    }
1039
1040    #[tokio::test]
1041    async fn test_cancel_without_token_returns_false() {
1042        let manager = JobManager::new();
1043        let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1044        // No token recorded → nothing to cancel.
1045        assert!(!manager.cancel(id).await);
1046        // Unknown id → also false.
1047        assert!(!manager.cancel(JobId(999)).await);
1048    }
1049
1050    #[tokio::test]
1051    async fn test_pgids_recorded_and_deduped() {
1052        let manager = JobManager::new();
1053        let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1054        assert!(manager.job_pgids(id).await.is_empty());
1055
1056        manager.add_pgid(id, 4242).await;
1057        manager.add_pgid(id, 4243).await;
1058        manager.add_pgid(id, 4242).await; // duplicate ignored
1059        assert_eq!(manager.job_pgids(id).await, vec![4242, 4243]);
1060
1061        // Unknown id → empty, no panic.
1062        assert!(manager.job_pgids(JobId(999)).await.is_empty());
1063    }
1064
1065    #[tokio::test]
1066    async fn wait_does_not_block_other_job_ops() {
1067        // Regression: `wait(id)` must NOT hold the jobs mutex across the job's
1068        // completion. The buggy version did, so while a `wait %N` was parked,
1069        // every other job op (list/spawn/status) blocked until the job finished
1070        // — a nested `&` under `wait %N` deadlocked. (Also covers the old
1071        // `spawn` try_lock busy-spin, which on a current-thread runtime livelocked
1072        // the executor when the lock was held.)
1073        let manager = Arc::new(JobManager::new());
1074        manager.set_persist_output_files(false);
1075
1076        // A job that blocks until we release it.
1077        let (tx, rx) = oneshot::channel::<()>();
1078        let id = manager
1079            .spawn("blocker".to_string(), async move {
1080                let _ = rx.await;
1081                ExecResult::success("done")
1082            })
1083            .await;
1084
1085        // Park a waiter on it (in the buggy version, holds the lock for the
1086        // job's whole lifetime).
1087        let waiter = {
1088            let m = manager.clone();
1089            tokio::spawn(async move { m.wait(id).await })
1090        };
1091        // Let the waiter acquire the lock and park on the job's completion.
1092        tokio::time::sleep(Duration::from_millis(50)).await;
1093
1094        // Other job ops must stay responsive while the waiter is parked.
1095        let listed = tokio::time::timeout(Duration::from_secs(2), manager.list()).await;
1096        assert!(
1097            listed.is_ok(),
1098            "list() blocked while wait() was parked — jobs lock held across await"
1099        );
1100        let second = tokio::time::timeout(
1101            Duration::from_secs(2),
1102            manager.spawn("second".to_string(), async { ExecResult::success("2") }),
1103        )
1104        .await;
1105        assert!(
1106            second.is_ok(),
1107            "spawn() blocked/spun while wait() was parked"
1108        );
1109
1110        // Release the job; the parked waiter must observe the result.
1111        let _ = tx.send(());
1112        let result = tokio::time::timeout(Duration::from_secs(2), waiter)
1113            .await
1114            .expect("waiter join timed out")
1115            .expect("waiter task panicked");
1116        assert_eq!(result.map(|r| r.code), Some(0), "waiter should see exit 0");
1117    }
1118
1119    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1120    async fn wait_survives_a_dropped_waiter() {
1121        // Regression (Gemini review): a waiter dropped mid-wait (e.g.
1122        // `timeout N wait %1`) must NOT orphan the job's result. The buggy
1123        // version took the JoinHandle out to await it, so dropping that waiter
1124        // detached the task and lost its result, and a SECOND `wait %1` then
1125        // hung forever (busy-spinning in the AlreadyWaiting branch). `wait` must
1126        // never take the handle until it's finished.
1127        let manager = Arc::new(JobManager::new());
1128        manager.set_persist_output_files(false);
1129
1130        let (tx, rx) = oneshot::channel::<()>();
1131        let id = manager
1132            .spawn("blocker".to_string(), async move {
1133                let _ = rx.await;
1134                ExecResult::success("done")
1135            })
1136            .await;
1137
1138        // Waiter A parks on the job, then is aborted (dropped) before it finishes.
1139        {
1140            let m = manager.clone();
1141            let a = tokio::spawn(async move { m.wait(id).await });
1142            tokio::time::sleep(Duration::from_millis(20)).await;
1143            a.abort();
1144            let _ = a.await;
1145        }
1146
1147        // The job completes after A is gone.
1148        let _ = tx.send(());
1149
1150        // Waiter B must still observe the result, not hang.
1151        let res = tokio::time::timeout(Duration::from_secs(2), manager.wait(id))
1152            .await
1153            .expect("wait must not hang after a prior waiter was dropped");
1154        assert_eq!(res.map(|r| r.code), Some(0), "B should see the completed job");
1155    }
1156}