Skip to main content

magi/
chat_watch.rs

1//! Durable, supervised jobs started by a standing chat. Results use the same
2//! draft/turn delivery as task completion notices; jobs never run twice.
3
4use std::fs::{File, OpenOptions};
5use std::io::Read;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11
12use anyhow::{Context, Result, bail};
13use serde::{Deserialize, Serialize};
14
15use crate::proc::Quiet as _;
16use crate::talk::{self, Talks};
17
18/// Default and maximum job durations, in seconds.
19pub const DEFAULT_TIMEOUT: u64 = 900;
20/// Long jobs should be filed as tasks instead.
21pub const MAX_TIMEOUT: u64 = 3600;
22/// Reservations count against this global limit too.
23pub const MAX_CONCURRENT: usize = 4;
24/// Maximum retained output, shared by stdout and stderr.
25pub const OUTPUT_CAP: usize = 8192;
26
27#[derive(Debug, Serialize, Deserialize)]
28struct Job {
29    id: String,
30    talk: String,
31    cwd: PathBuf,
32    command: Vec<String>,
33    timeout: u64,
34    supervisor: Option<(u32, String)>,
35    child: Option<(u32, String)>,
36    result: Option<String>,
37    tail: String,
38    delivered: bool,
39}
40
41fn root(home: &Path) -> PathBuf {
42    home.join("chat-watches")
43}
44
45struct Lock(File);
46
47impl Drop for Lock {
48    fn drop(&mut self) {
49        unlock_file(&self.0);
50    }
51}
52
53fn open_lock(path: &Path) -> Result<File> {
54    Ok(OpenOptions::new()
55        .create(true)
56        .truncate(false)
57        .read(true)
58        .write(true)
59        .open(path)?)
60}
61
62fn lock(path: &Path) -> Result<Lock> {
63    let file = open_lock(path)?;
64    lock_file(&file, false)?;
65    Ok(Lock(file))
66}
67
68fn try_lock(path: &Path) -> Result<Option<Lock>> {
69    let file = open_lock(path)?;
70    if lock_file(&file, true)? {
71        Ok(Some(Lock(file)))
72    } else {
73        Ok(None)
74    }
75}
76
77#[cfg(unix)]
78fn lock_file(file: &File, immediate: bool) -> std::io::Result<bool> {
79    use std::os::fd::AsRawFd;
80    loop {
81        if unsafe {
82            libc::flock(
83                file.as_raw_fd(),
84                libc::LOCK_EX | if immediate { libc::LOCK_NB } else { 0 },
85            )
86        } == 0
87        {
88            return Ok(true);
89        }
90        let error = std::io::Error::last_os_error();
91        match error.kind() {
92            std::io::ErrorKind::Interrupted => continue,
93            std::io::ErrorKind::WouldBlock if immediate => return Ok(false),
94            _ => return Err(error),
95        }
96    }
97}
98
99#[cfg(unix)]
100fn unlock_file(file: &File) {
101    use std::os::fd::AsRawFd;
102    unsafe {
103        libc::flock(file.as_raw_fd(), libc::LOCK_UN);
104    }
105}
106
107#[cfg(windows)]
108fn lock_file(file: &File, immediate: bool) -> std::io::Result<bool> {
109    use std::os::windows::io::AsRawHandle;
110    windows::lock_file(file.as_raw_handle(), immediate)
111}
112
113#[cfg(windows)]
114fn unlock_file(file: &File) {
115    use std::os::windows::io::AsRawHandle;
116    windows::unlock_file(file.as_raw_handle());
117}
118
119fn load(dir: &Path) -> Result<Job> {
120    Ok(serde_json::from_slice(&std::fs::read(
121        dir.join("job.json"),
122    )?)?)
123}
124
125fn save(dir: &Path, job: &Job) -> Result<()> {
126    let tmp = dir.join("job.tmp");
127    std::fs::write(&tmp, serde_json::to_vec(job)?)?;
128    std::fs::rename(tmp, dir.join("job.json"))?;
129    Ok(())
130}
131
132fn directories(home: &Path) -> Result<Vec<PathBuf>> {
133    Ok(std::fs::read_dir(root(home))?
134        .filter_map(Result::ok)
135        .map(|e| e.path())
136        .filter(|p| {
137            p.is_dir()
138                && !p
139                    .file_name()
140                    .is_some_and(|n| n.to_string_lossy().starts_with('.'))
141        })
142        .collect())
143}
144
145/// Only the standing chat's own environment chooses the destination.
146pub fn authorize(talks: &Talks, run: &str, node: &str) -> Result<String> {
147    if node != crate::queue::CHAT_NODE || run.trim().is_empty() {
148        bail!("talk watch requires MAGI_NODE=chat and MAGI_RUN from a standing chat");
149    }
150    let t = talks.get(run)?;
151    if t.id != run || !t.status.open() {
152        bail!("talk watch requires the exact id of an open conversation");
153    }
154    Ok(t.id)
155}
156
157/// Reserve a slot and detach a supervisor. Return only after it has recorded
158/// startup (including an executable spawn failure, which is reported normally).
159pub fn start(
160    home: &Path,
161    talks: &Talks,
162    run: &str,
163    node: &str,
164    command: Vec<String>,
165    timeout: u64,
166) -> Result<String> {
167    let talk = authorize(talks, run, node)?;
168    if command.is_empty() || !(1..=MAX_TIMEOUT).contains(&timeout) {
169        bail!("watch needs a command and a timeout between 1 and {MAX_TIMEOUT} seconds");
170    }
171    std::fs::create_dir_all(root(home))?;
172    let _store = lock(&root(home).join("store.lock"))?;
173    let active = directories(home)?
174        .iter()
175        .try_fold(0, |n, dir| -> Result<usize> {
176            Ok(n + usize::from(load(dir)?.result.is_none()))
177        })?;
178    if active >= MAX_CONCURRENT {
179        bail!("at most {MAX_CONCURRENT} chat watches may run concurrently");
180    }
181    let id = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
182    let dir = root(home).join(&id);
183    let staging = root(home).join(format!(".new-{id}"));
184    std::fs::create_dir(&staging)?;
185    let mut job = Job {
186        id: id.clone(),
187        talk,
188        cwd: std::env::current_dir()?,
189        command,
190        timeout,
191        supervisor: None,
192        child: None,
193        result: None,
194        tail: String::new(),
195        delivered: false,
196    };
197    save(&staging, &job)?;
198    std::fs::rename(&staging, &dir)?;
199    let mut cmd = Command::new(crate::updater::current_exe()?);
200    cmd.arg("talk")
201        .arg("watch-run")
202        .arg(home)
203        .arg(&id)
204        .env_remove("MAGI_RUN")
205        .env_remove("MAGI_NODE")
206        .stdin(Stdio::null())
207        .stdout(Stdio::null())
208        .stderr(Stdio::null());
209    #[cfg(unix)]
210    {
211        use std::os::unix::process::CommandExt;
212        // A separate session also survives the calling agent's group cleanup.
213        unsafe {
214            cmd.pre_exec(|| {
215                if libc::setsid() < 0 {
216                    return Err(std::io::Error::last_os_error());
217                }
218                Ok(())
219            });
220        }
221    }
222    #[cfg(windows)]
223    {
224        use std::os::windows::process::CommandExt;
225        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
226        // and the calling agent's terminal must not end supervision.
227        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
228    }
229    let mut supervisor = match cmd.spawn() {
230        Ok(child) => child,
231        Err(e) => {
232            job.result = Some(format!("supervisor startup failed: {e}"));
233            save(&dir, &job)?;
234            return Err(e.into());
235        }
236    };
237    let deadline = Instant::now() + Duration::from_secs(10);
238    loop {
239        let fresh = load(&dir)?;
240        if fresh.supervisor.is_some() && (fresh.child.is_some() || fresh.result.is_some()) {
241            // Reap while the calling process is alive; after it exits the
242            // detached supervisor is reparented and reaped by the platform.
243            std::thread::spawn(move || {
244                let _ = supervisor.wait();
245            });
246            return Ok(id);
247        }
248        if supervisor.try_wait()?.is_some() || Instant::now() >= deadline {
249            let _ = supervisor.kill();
250            let _ = supervisor.wait();
251            bail!(
252                "watch supervisor did not acknowledge startup; its durable reservation will be reconciled"
253            );
254        }
255        std::thread::sleep(Duration::from_millis(20));
256    }
257}
258
259fn identity(pid: u32) -> Result<(u32, String)> {
260    Ok((
261        pid,
262        crate::proc::process_started_at(pid).context("cannot identify watch process")?,
263    ))
264}
265
266fn append_tail(tail: &mut Vec<u8>, bytes: &[u8]) {
267    tail.extend_from_slice(bytes);
268    if tail.len() > OUTPUT_CAP {
269        tail.drain(..tail.len() - OUTPUT_CAP);
270    }
271}
272
273fn reader(
274    mut pipe: impl Pipe + Send + 'static,
275    tail: Arc<Mutex<Vec<u8>>>,
276    stopped: Arc<AtomicBool>,
277) -> std::thread::JoinHandle<()> {
278    std::thread::spawn(move || {
279        let mut chunk = [0; 4096];
280        if pipe.nonblocking().is_err() {
281            return;
282        }
283        let mut final_budget = None;
284        loop {
285            // Drain buffered output after exit, but even an escaped process
286            // continuously writing to an inherited pipe cannot keep us here.
287            if stopped.load(Ordering::Acquire) {
288                let remaining = final_budget.get_or_insert(OUTPUT_CAP * 16);
289                if *remaining == 0 {
290                    break;
291                }
292            }
293            match pipe.read_available(&mut chunk) {
294                Ok(0) => break,
295                Ok(n) => {
296                    append_tail(
297                        &mut tail.lock().unwrap_or_else(|e| e.into_inner()),
298                        &chunk[..n],
299                    );
300                    if let Some(remaining) = final_budget.as_mut() {
301                        *remaining = remaining.saturating_sub(n);
302                    }
303                }
304                Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
305                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
306                    if stopped.load(Ordering::Acquire) {
307                        break;
308                    }
309                    std::thread::sleep(Duration::from_millis(10));
310                }
311                Err(_) => break,
312            }
313        }
314    })
315}
316
317#[cfg(unix)]
318fn kill_group(pid: u32) {
319    if let Ok(pid) = libc::pid_t::try_from(pid) {
320        // Every watch child gets its own group, so this also closes pipes
321        // inherited by its descendants after the direct child exits.
322        unsafe {
323            libc::kill(-pid, libc::SIGKILL);
324        }
325    }
326}
327
328#[cfg(not(unix))]
329fn kill_group(_pid: u32) {}
330
331// The internal wrapper has a separate, bounded launch phase. Its process
332// startup and identity checks must not consume the watched command's budget.
333fn watch_timeout(
334    startup_deadline: Instant,
335    execution_deadline: &mut Option<Instant>,
336    executing: bool,
337    timeout: u64,
338    now: Instant,
339) -> Option<String> {
340    if executing && execution_deadline.is_none() {
341        *execution_deadline = Some(now + Duration::from_secs(timeout));
342    }
343    match *execution_deadline {
344        Some(deadline) if now >= deadline => Some(format!(
345            "timed out after {timeout} second{}",
346            if timeout == 1 { "" } else { "s" }
347        )),
348        None if now >= startup_deadline => {
349            Some("startup failed: watch wrapper did not start the command".to_owned())
350        }
351        _ => None,
352    }
353}
354
355/// Internal supervisor entry point. The per-job OS lock is a durable lease:
356/// released on process death, without age-based guesses or restarting the job.
357pub fn supervise(home: &Path, id: &str) -> Result<()> {
358    if id.contains(['/', '\\']) || id == "." || id == ".." {
359        bail!("invalid watch id");
360    }
361    let dir = root(home).join(id);
362    let _lease = lock(&dir.join("lease.lock"))?;
363    let mut job = load(&dir)?;
364    if job.result.is_some() || job.supervisor.is_some() {
365        return Ok(());
366    }
367    job.supervisor = Some(identity(std::process::id())?);
368    save(&dir, &job)?;
369    let mut cmd = Command::new(crate::updater::current_exe()?);
370    cmd.arg("talk")
371        .arg("watch-exec")
372        .arg(home)
373        .arg(id)
374        .current_dir(&job.cwd)
375        .env_remove("MAGI_RUN")
376        .env_remove("MAGI_NODE")
377        .stdin(Stdio::null())
378        .stdout(Stdio::piped())
379        .stderr(Stdio::piped());
380    cmd.quiet();
381    #[cfg(unix)]
382    {
383        use std::os::unix::process::CommandExt;
384        cmd.process_group(0);
385    }
386    let mut child = match cmd.spawn() {
387        Ok(c) => c,
388        Err(e) => {
389            job.result = Some(format!("startup failed: {e}"));
390            save(&dir, &job)?;
391            return Ok(());
392        }
393    };
394    #[cfg(windows)]
395    let containment = match windows::Containment::assign(&child) {
396        Ok(c) => c,
397        Err(e) => {
398            let _ = child.kill();
399            let _ = child.wait();
400            return Err(e.into());
401        }
402    };
403    // If identity or persistence fails, do not leave an untracked process.
404    let registered = identity(child.id()).and_then(|pid| {
405        job.child = Some(pid);
406        save(&dir, &job)
407    });
408    if let Err(e) = registered {
409        kill_group(child.id());
410        #[cfg(windows)]
411        drop(containment);
412        let _ = child.wait();
413        return Err(e);
414    }
415    // The wrapper may execute only after its PID has been persisted. A
416    // supervisor crash in the spawn/save gap leaves an inert wrapper, not an
417    // untracked command. It exits when it detects the supervisor is gone.
418    std::fs::write(dir.join("armed"), b"ready")?;
419    let tail = Arc::new(Mutex::new(Vec::new()));
420    let stopped = Arc::new(AtomicBool::new(false));
421    let out = reader(
422        child.stdout.take().context("missing stdout")?,
423        tail.clone(),
424        stopped.clone(),
425    );
426    let err = reader(
427        child.stderr.take().context("missing stderr")?,
428        tail.clone(),
429        stopped.clone(),
430    );
431    let startup_deadline = Instant::now() + Duration::from_secs(10);
432    let mut execution_deadline = None;
433    let outcome = loop {
434        match child.try_wait() {
435            Ok(Some(status)) => {
436                #[cfg(unix)]
437                {
438                    use std::os::unix::process::ExitStatusExt;
439                    break status.code().map_or_else(
440                        || format!("terminated by signal {}", status.signal().unwrap_or(0)),
441                        |code| format!("exited with status {code}"),
442                    );
443                }
444                #[cfg(not(unix))]
445                break status.code().map_or_else(
446                    || "terminated without an exit code".to_owned(),
447                    |code| format!("exited with status {code}"),
448                );
449            }
450            Err(e) => break format!("result unknown: {e}"),
451            Ok(None) => {
452                let executing = execution_deadline.is_some() || dir.join("executing").is_file();
453                if let Some(result) = watch_timeout(
454                    startup_deadline,
455                    &mut execution_deadline,
456                    executing,
457                    job.timeout,
458                    Instant::now(),
459                ) {
460                    break result;
461                }
462                std::thread::sleep(Duration::from_millis(20));
463            }
464        }
465    };
466    kill_group(child.id());
467    #[cfg(windows)]
468    drop(containment);
469    let _ = child.wait();
470    stopped.store(true, Ordering::Release);
471    let _ = out.join();
472    let _ = err.join();
473    job.tail =
474        String::from_utf8_lossy(&tail.lock().unwrap_or_else(|e| e.into_inner())).into_owned();
475    job.result = Some(match std::fs::read_to_string(dir.join("spawn-error")) {
476        Ok(error) => format!("startup failed: {error}"),
477        Err(_) => outcome,
478    });
479    save(&dir, &job)
480}
481
482/// Reconcile completed or abandoned watches and queue each notice once. Lock
483/// order is watch store, job lease, talk store. Transient errors are retried.
484pub fn sweep(home: &Path, talks: &Talks) {
485    if !root(home).exists() {
486        return;
487    }
488    let sweep = || -> Result<()> {
489        let _store = lock(&root(home).join("store.lock"))?;
490        for dir in directories(home)? {
491            let Some(_lease) = try_lock(&dir.join("lease.lock"))? else {
492                continue;
493            };
494            let result = reconcile(&dir, talks);
495            if let Err(e) = result {
496                tracing::warn!("watch completion not queued: {e:#}");
497            }
498        }
499        Ok(())
500    };
501    if let Err(e) = sweep() {
502        tracing::warn!("watch sweep: {e:#}");
503    }
504}
505
506fn reconcile(dir: &Path, talks: &Talks) -> Result<()> {
507    let mut job = load(dir)?;
508    if job.delivered {
509        return Ok(());
510    }
511    if job.result.is_none() {
512        if let Some((pid, started)) = &job.child {
513            match (
514                crate::proc::pid_status(*pid),
515                crate::proc::process_started_at(*pid),
516            ) {
517                (Some(false), _) => kill_group(*pid),
518                (Some(true), Some(current)) if current == *started => kill_group(*pid),
519                (Some(true), Some(_)) => {} // PID reused: never kill it.
520                _ => return Ok(()),         // Uncertain identity retains the slot.
521            }
522        }
523        job.result =
524            Some("result unknown: supervisor was lost; job will not be restarted".to_owned());
525        save(dir, &job)?;
526    }
527    // Listing omits unreadable records, so it cannot prove deletion. Only
528    // a missing path settles delivery; read/parse/permission errors retry.
529    let gone = || {
530        matches!(
531            std::fs::symlink_metadata(talks.path_of(&job.talk)),
532            Err(error) if error.kind() == std::io::ErrorKind::NotFound
533        )
534    };
535    let mut t = match talks.get(&job.talk) {
536        Ok(t) if t.status.open() => t,
537        Ok(_) => {
538            job.delivered = true;
539            return save(dir, &job);
540        }
541        Err(_) if gone() => {
542            job.delivered = true;
543            return save(dir, &job);
544        }
545        Err(e) => return Err(e),
546    };
547    let (text, display) = crate::prompt::chat_watch_report(
548        &job.id,
549        job.result.as_deref().unwrap_or("unknown"),
550        &job.tail,
551    );
552    if let Err(e) = talk::queue_notice(
553        &mut t,
554        talks,
555        &text,
556        &format!("watch:{}:finished", job.id),
557        &|_| false,
558        None,
559        display,
560    ) {
561        match talks.get(&job.talk) {
562            Ok(t) if !t.status.open() => {}
563            Err(_) if gone() => {}
564            _ => return Err(e),
565        }
566    }
567    job.delivered = true;
568    save(dir, &job)
569}
570
571/// The launch gate prevents commands from starting before their identity is
572/// durable. This internal entry point cannot execute a job from another PID.
573pub fn execute(home: &Path, id: &str) -> Result<()> {
574    #[cfg(unix)]
575    use std::os::unix::process::CommandExt;
576    if id.contains(['/', '\\']) || id == "." || id == ".." {
577        bail!("invalid watch id");
578    }
579    let dir = root(home).join(id);
580    let deadline = Instant::now() + Duration::from_secs(10);
581    loop {
582        let job = load(&dir)?;
583        let (pid, started) = job.supervisor.as_ref().context("missing supervisor")?;
584        if job.result.is_some()
585            || crate::proc::pid_status(*pid) == Some(false)
586            || crate::proc::process_started_at(*pid).is_some_and(|s| s != *started)
587        {
588            bail!("watch supervisor was lost before launch");
589        }
590        if dir.join("armed").exists() {
591            if job.child.as_ref() != Some(&identity(std::process::id())?) {
592                bail!("watch launch belongs to a different process");
593            }
594            let mut command = Command::new(&job.command[0]);
595            command
596                .args(&job.command[1..])
597                .current_dir(&job.cwd)
598                .env_remove("MAGI_RUN")
599                .env_remove("MAGI_NODE");
600            command.quiet();
601            // Signal only after authorization and command construction. The
602            // supervisor starts the command timeout at this execution boundary.
603            std::fs::write(dir.join("executing"), b"ready")?;
604            #[cfg(unix)]
605            let error = command.exec();
606            #[cfg(windows)]
607            let error = match command.status() {
608                Ok(status) => std::process::exit(status.code().unwrap_or(1)),
609                Err(error) => error,
610            };
611            std::fs::write(dir.join("spawn-error"), error.to_string())?;
612            return Err(error.into());
613        }
614        if Instant::now() >= deadline {
615            bail!("watch launch was not armed");
616        }
617        std::thread::sleep(Duration::from_millis(10));
618    }
619}
620
621trait Pipe: Read {
622    fn nonblocking(&self) -> std::io::Result<()>;
623    fn read_available(&mut self, bytes: &mut [u8]) -> std::io::Result<usize>;
624}
625
626#[cfg(unix)]
627impl<T: Read + std::os::fd::AsRawFd> Pipe for T {
628    fn nonblocking(&self) -> std::io::Result<()> {
629        let fd = self.as_raw_fd();
630        // Do not let escaped descendants keep the supervisor waiting on pipes.
631        unsafe {
632            let flags = libc::fcntl(fd, libc::F_GETFL);
633            if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
634                return Err(std::io::Error::last_os_error());
635            }
636        }
637        Ok(())
638    }
639    fn read_available(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
640        self.read(bytes)
641    }
642}
643
644#[cfg(windows)]
645impl<T: Read + std::os::windows::io::AsRawHandle> Pipe for T {
646    fn nonblocking(&self) -> std::io::Result<()> {
647        Ok(())
648    }
649    fn read_available(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
650        let available = windows::available(self.as_raw_handle())?;
651        if available == 0 {
652            return Err(std::io::ErrorKind::WouldBlock.into());
653        }
654        let count = bytes.len().min(available as usize);
655        self.read(&mut bytes[..count])
656    }
657}
658
659// Native job objects close the whole Windows process tree when a supervisor
660// exits, even involuntarily. The launch gate stays closed until assignment.
661#[cfg(windows)]
662pub(crate) mod windows {
663    use std::ffi::c_void;
664    use std::os::windows::io::AsRawHandle;
665    type Handle = *mut c_void;
666    #[repr(C)]
667    #[derive(Default)]
668    struct Overlapped {
669        internal: usize,
670        internal_high: usize,
671        offset: u32,
672        offset_high: u32,
673        event: Handle,
674    }
675    #[repr(C)]
676    #[derive(Default)]
677    struct BasicLimits {
678        process_time: i64,
679        job_time: i64,
680        flags: u32,
681        min_working_set: usize,
682        max_working_set: usize,
683        active_processes: u32,
684        affinity: usize,
685        priority: u32,
686        scheduling: u32,
687    }
688    #[repr(C)]
689    #[derive(Default)]
690    struct ExtendedLimits {
691        basic: BasicLimits,
692        io: [u64; 6],
693        process_memory: usize,
694        job_memory: usize,
695        peak_process_memory: usize,
696        peak_job_memory: usize,
697    }
698    #[link(name = "kernel32")]
699    unsafe extern "system" {
700        fn LockFileEx(
701            file: Handle,
702            flags: u32,
703            reserved: u32,
704            bytes: u32,
705            bytes_high: u32,
706            overlapped: *mut Overlapped,
707        ) -> i32;
708        fn UnlockFileEx(
709            file: Handle,
710            reserved: u32,
711            bytes: u32,
712            bytes_high: u32,
713            overlapped: *mut Overlapped,
714        ) -> i32;
715        fn CreateJobObjectW(attributes: Handle, name: *const u16) -> Handle;
716        fn SetInformationJobObject(job: Handle, class: i32, info: *const c_void, size: u32) -> i32;
717        fn AssignProcessToJobObject(job: Handle, process: Handle) -> i32;
718        fn CloseHandle(handle: Handle) -> i32;
719        fn GetCurrentProcess() -> Handle;
720        fn IsProcessInJob(process: Handle, job: Handle, result: *mut i32) -> i32;
721        fn QueryInformationJobObject(
722            job: Handle,
723            class: i32,
724            info: *mut c_void,
725            size: u32,
726            returned: *mut u32,
727        ) -> i32;
728        fn PeekNamedPipe(
729            pipe: Handle,
730            buffer: Handle,
731            size: u32,
732            read: *mut u32,
733            available: *mut u32,
734            left: *mut u32,
735        ) -> i32;
736    }
737    pub fn lock_file(file: Handle, immediate: bool) -> std::io::Result<bool> {
738        let mut overlapped = Overlapped::default();
739        if unsafe { LockFileEx(file, 2 | u32::from(immediate), 0, 1, 0, &mut overlapped) } != 0 {
740            return Ok(true);
741        }
742        let error = std::io::Error::last_os_error();
743        if immediate && error.raw_os_error() == Some(33) {
744            return Ok(false);
745        }
746        Err(error)
747    }
748    pub fn unlock_file(file: Handle) {
749        let mut overlapped = Overlapped::default();
750        unsafe {
751            UnlockFileEx(file, 0, 1, 0, &mut overlapped);
752        }
753    }
754    /// Detach from the console and request breakaway only when the host allows
755    /// it. An external ancestor job can still contain and terminate workers.
756    pub(crate) fn detached_flags() -> std::io::Result<u32> {
757        let mut in_job = 0;
758        if unsafe { IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &mut in_job) } == 0 {
759            return Err(std::io::Error::last_os_error());
760        }
761        if in_job == 0 {
762            return Ok(0x0000_0008 | 0x0000_0200);
763        }
764        let mut limits = BasicLimits::default();
765        if unsafe {
766            QueryInformationJobObject(
767                std::ptr::null_mut(),
768                2,
769                std::ptr::from_mut(&mut limits).cast(),
770                std::mem::size_of::<BasicLimits>() as u32,
771                std::ptr::null_mut(),
772            )
773        } == 0
774        {
775            return Err(std::io::Error::last_os_error());
776        }
777        let detached = 0x0000_0008 | 0x0000_0200;
778        if limits.flags & 0x1000 != 0 {
779            return Ok(detached);
780        } // SILENT_BREAKAWAY_OK
781        if limits.flags & 0x0800 != 0 {
782            return Ok(detached | 0x0100_0000);
783        } // BREAKAWAY_OK
784        // Inheriting an external job does not tie a child to this launcher's
785        // lifetime. Do not reject nested launches or bypass that job's limits.
786        Ok(detached)
787    }
788
789    pub struct Containment(Handle);
790    impl Containment {
791        pub fn assign(child: &std::process::Child) -> std::io::Result<Self> {
792            unsafe {
793                let handle = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
794                if handle.is_null() {
795                    return Err(std::io::Error::last_os_error());
796                }
797                let job = Self(handle);
798                let mut limits = ExtendedLimits::default();
799                limits.basic.flags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
800                if SetInformationJobObject(
801                    handle,
802                    9,
803                    std::ptr::from_ref(&limits).cast(),
804                    std::mem::size_of::<ExtendedLimits>() as u32,
805                ) == 0
806                    || AssignProcessToJobObject(handle, child.as_raw_handle()) == 0
807                {
808                    return Err(std::io::Error::last_os_error());
809                }
810                Ok(job)
811            }
812        }
813    }
814    impl Drop for Containment {
815        fn drop(&mut self) {
816            unsafe {
817                CloseHandle(self.0);
818            }
819        }
820    }
821    pub fn available(pipe: Handle) -> std::io::Result<u32> {
822        let mut available = 0;
823        if unsafe {
824            PeekNamedPipe(
825                pipe,
826                std::ptr::null_mut(),
827                0,
828                std::ptr::null_mut(),
829                &mut available,
830                std::ptr::null_mut(),
831            )
832        } == 0
833        {
834            return Err(std::io::Error::last_os_error());
835        }
836        Ok(available)
837    }
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    struct EndlessOutput;
845    impl Read for EndlessOutput {
846        fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
847            bytes.fill(b'x');
848            Ok(bytes.len())
849        }
850    }
851    impl Pipe for EndlessOutput {
852        fn nonblocking(&self) -> std::io::Result<()> {
853            Ok(())
854        }
855        fn read_available(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
856            self.read(bytes)
857        }
858    }
859
860    #[test]
861    fn wrapper_startup_does_not_spend_the_command_timeout() {
862        let start = Instant::now();
863        let startup = start + Duration::from_secs(10);
864        let mut execution = None;
865        let ready = start + Duration::from_secs(9);
866        assert_eq!(
867            watch_timeout(startup, &mut execution, false, 1, ready),
868            None
869        );
870        assert_eq!(watch_timeout(startup, &mut execution, true, 1, ready), None);
871        assert_eq!(
872            watch_timeout(
873                startup,
874                &mut execution,
875                true,
876                1,
877                ready + Duration::from_millis(999)
878            ),
879            None
880        );
881        assert_eq!(
882            watch_timeout(
883                startup,
884                &mut execution,
885                false,
886                1,
887                ready + Duration::from_secs(1)
888            ),
889            Some("timed out after 1 second".into())
890        );
891    }
892
893    #[test]
894    fn an_unstarted_wrapper_has_a_bounded_startup_failure() {
895        let deadline = Instant::now();
896        let mut execution = None;
897        assert_eq!(
898            watch_timeout(deadline, &mut execution, false, 1, deadline),
899            Some("startup failed: watch wrapper did not start the command".into())
900        );
901        assert!(execution.is_none());
902    }
903
904    #[test]
905    fn stopped_readers_finish_even_if_an_inherited_pipe_never_stops_writing() {
906        let tail = Arc::new(Mutex::new(Vec::new()));
907        reader(EndlessOutput, tail.clone(), Arc::new(AtomicBool::new(true)))
908            .join()
909            .expect("reader finished");
910        assert_eq!(tail.lock().expect("tail").len(), OUTPUT_CAP);
911    }
912}