diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58c7857..c584c6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,12 +18,14 @@ env: jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - run: sudo apt-get update && sudo apt-get install -y $LINUX_DEPS - uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true - run: cargo fmt --all --check - run: cargo clippy --workspace --all-targets -- -D warnings @@ -33,6 +35,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - if: runner.os == 'Linux' @@ -40,10 +43,12 @@ jobs: - uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true - run: cargo test --workspace msrv: runs-on: ubuntu-latest + timeout-minutes: 30 env: RUSTUP_TOOLCHAIN: "1.95" steps: @@ -53,4 +58,5 @@ jobs: - uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true - run: cargo check --workspace diff --git a/Cargo.lock b/Cargo.lock index e886a7a..95e8143 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,24 +26,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -134,12 +116,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - [[package]] name = "arboard" version = "3.6.1" @@ -160,32 +136,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "arrayvec" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "asn1-rs" version = "0.7.2" @@ -394,49 +344,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" -dependencies = [ - "arrayvec", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -535,12 +442,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -553,15 +454,6 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - [[package]] name = "bitvec" version = "1.1.1" @@ -624,12 +516,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "built" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" - [[package]] name = "bumpalo" version = "3.20.3" @@ -693,8 +579,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex 2.0.1", ] @@ -1444,26 +1328,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1527,21 +1391,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "eyre" version = "0.6.12" @@ -3051,16 +2900,11 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "exr", "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", - "ravif", - "rayon", - "rgb", "tiff", "zune-core", "zune-jpeg", @@ -3076,12 +2920,6 @@ dependencies = [ "quick-error", ] -[[package]] -name = "imgref" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" - [[package]] name = "indenter" version = "0.3.4" @@ -3120,17 +2958,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -3186,16 +3013,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.103" @@ -3246,28 +3063,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libfuzzer-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" -dependencies = [ - "arbitrary", - "cc", -] - [[package]] name = "libloading" version = "0.8.9" @@ -3404,15 +3205,6 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - [[package]] name = "lru" version = "0.18.0" @@ -3470,16 +3262,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - [[package]] name = "memchr" version = "2.8.2" @@ -3591,15 +3373,6 @@ dependencies = [ "libc", ] -[[package]] -name = "no_std_io2" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" -dependencies = [ - "memchr", -] - [[package]] name = "nom" version = "7.1.3" @@ -3619,12 +3392,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3670,17 +3437,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -4098,18 +3854,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pathdiff" version = "0.2.3" @@ -4439,25 +4183,6 @@ dependencies = [ "windows 0.62.2", ] -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" -dependencies = [ - "quote", - "syn 2.0.118", -] - [[package]] name = "pulldown-cmark" version = "0.12.2" @@ -4475,15 +4200,6 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "qrcode" version = "0.14.1" @@ -4819,56 +4535,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.4", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - [[package]] name = "rayon" version = "1.12.0" @@ -5031,12 +4697,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - [[package]] name = "ring" version = "0.17.14" @@ -5471,15 +5131,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - [[package]] name = "siphasher" version = "1.0.3" @@ -6313,17 +5964,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" @@ -7317,12 +6957,6 @@ dependencies = [ "markup5ever", ] -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yaml-rust" version = "0.4.5" @@ -7518,15 +7152,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index cc4ab26..7b478d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,12 @@ base64 = "0.22" percent-encoding = "2" enigo = "0.6" xcap = "0.9" -image = "0.25" +image = { version = "0.25", default-features = false, features = [ + "png", + "jpeg", + "gif", + "webp", +] } chromiumoxide = "0.9" arboard = "3.6.1" rand = "0.10" diff --git a/crates/goat-agent/src/process.rs b/crates/goat-agent/src/process.rs index 9719e89..e538e2c 100644 --- a/crates/goat-agent/src/process.rs +++ b/crates/goat-agent/src/process.rs @@ -40,9 +40,19 @@ struct Entry { watch_flooded: bool, stdin: Option, kill_pending: bool, + tasks: Vec>, } impl Entry { + /// Abort the reader/waiter background tasks tied to this process so a + /// leaked child can never keep them (and its inherited stdout/stderr + /// pipes) alive after the process is gone. + fn abort_tasks(&mut self) { + for task in self.tasks.drain(..) { + task.abort(); + } + } + fn info(&self, id: ProcessId) -> ProcessInfo { ProcessInfo { id, @@ -54,6 +64,12 @@ impl Entry { } } +impl Drop for Entry { + fn drop(&mut self) { + self.abort_tasks(); + } +} + struct Inner { entries: HashMap, next_id: u64, @@ -130,7 +146,13 @@ impl ProcessRegistry { .current_dir(cwd) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + .stderr(Stdio::piped()) + // If the waiter task is aborted or its `Child` is otherwise + // dropped, tokio kills the child instead of leaving it orphaned. + // Without this, an aborted reader/waiter task leaves the OS process + // (and its inherited stdout/stderr pipes) alive, which stalls + // shutdown — the root cause of the CI hang. + .kill_on_drop(true); set_process_group(&mut builder); let mut child = builder @@ -143,6 +165,20 @@ impl ProcessRegistry { let stderr = child.stderr.take(); let stdin = child.stdin.take(); + // Spawn the reader/waiter tasks up front and keep their handles on the + // entry. This is what makes cleanup deterministic: when the process + // exits, is killed, or the entry is dropped, we abort these tasks so a + // leaked child can never keep them — and the stdout/stderr pipes they + // hold — alive indefinitely. + let mut tasks = Vec::with_capacity(3); + if let Some(pipe) = stdout { + tasks.push(self.spawn_reader(id, pipe, Stream::Out)); + } + if let Some(pipe) = stderr { + tasks.push(self.spawn_reader(id, pipe, Stream::Err)); + } + tasks.push(self.spawn_waiter(id, child)); + { let mut inner = self.inner.lock().await; inner.next_id += 1; @@ -164,6 +200,7 @@ impl ProcessRegistry { watch_flooded: false, stdin, kill_pending: false, + tasks, }, ); } @@ -178,14 +215,6 @@ impl ProcessRegistry { .await; self.broadcast_list().await; - if let Some(pipe) = stdout { - self.spawn_reader(id, pipe, Stream::Out); - } - if let Some(pipe) = stderr { - self.spawn_reader(id, pipe, Stream::Err); - } - self.spawn_waiter(id, child); - Ok(Started { id, pgid }) } @@ -196,7 +225,12 @@ impl ProcessRegistry { } } - fn spawn_reader(self: &Arc, id: ProcessId, pipe: R, stream: Stream) + fn spawn_reader( + self: &Arc, + id: ProcessId, + pipe: R, + stream: Stream, + ) -> tokio::task::JoinHandle<()> where R: tokio::io::AsyncRead + Unpin + Send + 'static, { @@ -206,10 +240,14 @@ impl ProcessRegistry { while let Ok(Some(line)) = reader.next_line().await { registry.append_line(id, stream, line).await; } - }); + }) } - fn spawn_waiter(self: &Arc, id: ProcessId, mut child: tokio::process::Child) { + fn spawn_waiter( + self: &Arc, + id: ProcessId, + mut child: tokio::process::Child, + ) -> tokio::task::JoinHandle<()> { let registry = Arc::clone(self); tokio::spawn(async move { let status = child.wait().await; @@ -217,7 +255,7 @@ impl ProcessRegistry { registry .mark_exited(id, code, ProcessExitReason::Natural) .await; - }); + }) } async fn append_line(self: &Arc, id: ProcessId, stream: Stream, text: String) { @@ -404,6 +442,11 @@ impl ProcessRegistry { return Ok(()); } entry.kill_pending = true; + // Closing stdin sends EOF, which lets input-driven processes (a + // shell blocked on `cat`/`findstr`, a REPL, ...) exit on their own + // even if the group kill below races or misses. Dropping the handle + // closes the write end of the pipe. + entry.stdin.take(); entry.pgid }; kill_group(pgid); @@ -427,18 +470,19 @@ impl ProcessRegistry { } pub(crate) async fn shutdown_all(&self) { - let pgids: Vec> = { - let inner = self.inner.lock().await; - inner - .entries - .values() - .filter(|e| e.state == ProcessState::Running) - .map(|e| e.pgid) - .collect() + // Take every entry out of the map. Dropping the entries aborts their + // reader/waiter tasks (see `Entry::drop`), guaranteeing no background + // task can outlive shutdown holding a leaked child's pipes open. + let entries: Vec = { + let mut inner = self.inner.lock().await; + inner.entries.drain().map(|(_, entry)| entry).collect() }; - for pgid in pgids { - kill_group(pgid); + for entry in &entries { + if entry.state == ProcessState::Running { + kill_group(entry.pgid); + } } + // `entries` drops here, aborting all associated tasks. } } @@ -683,21 +727,44 @@ mod tests { wait_until_exited(®istry, started.id).await; } + // On unix, `cat` echoes stdin back, so we can verify the full round trip: + // bytes written via `write_stdin` actually reach the child and come back + // out. On Windows there is no reliable shell filter that line-echoes a piped + // stdin, so `stdin_write_succeeds` there only asserts the write half. + #[cfg(unix)] #[tokio::test] async fn stdin_write_reaches_process() { let (registry, _events, _wake) = harness(); let cwd = std::env::temp_dir(); let started = registry.spawn(plat::CAT, &cwd, false).await.unwrap(); registry.write_stdin(started.id, "typed\n").await.unwrap(); + let mut echoed = String::new(); + let mut got = false; for _ in 0..200 { let chunk = registry.read_new(started.id).await.unwrap(); - if chunk.text.contains("typed") { - registry.kill(started.id).await.unwrap(); - return; + echoed.push_str(&chunk.text); + if echoed.contains("typed") { + got = true; + break; } tokio::time::sleep(Duration::from_millis(10)).await; } - panic!("stdin was not echoed back"); + // Always tear the child down, whether or not the assertion below trips, + // so a surviving process can never outlive the test and stall shutdown. + registry.kill(started.id).await.unwrap(); + wait_until_exited(®istry, started.id).await; + assert!(got, "stdin was not echoed back"); + } + + #[cfg(not(unix))] + #[tokio::test] + async fn stdin_write_succeeds() { + let (registry, _events, _wake) = harness(); + let cwd = std::env::temp_dir(); + let started = registry.spawn(plat::CAT, &cwd, false).await.unwrap(); + registry.write_stdin(started.id, "typed\n").await.unwrap(); + registry.kill(started.id).await.unwrap(); + wait_until_exited(®istry, started.id).await; } #[tokio::test] @@ -721,4 +788,23 @@ mod tests { registry.kill(started.id).await.unwrap(); let _ = wake.try_recv(); } + + #[tokio::test] + async fn shutdown_all_terminates_running_processes() { + let (registry, _events, _wake) = harness(); + let cwd = std::env::temp_dir(); + let a = registry.spawn(plat::SLEEP_LONG, &cwd, false).await.unwrap(); + let b = registry.spawn(plat::SLEEP_LONG, &cwd, false).await.unwrap(); + assert_eq!(registry.list().await.len(), 2); + + // shutdown_all must kill the still-running children and drop their + // entries, aborting the reader/waiter tasks. If it left a child (and + // its inherited pipes) alive, the tasks would linger and, in the real + // daemon, keep the process from exiting — the root cause of the CI hang. + registry.shutdown_all().await; + + assert!(registry.list().await.is_empty()); + assert!(registry.read_new(a.id).await.is_none()); + assert!(registry.read_new(b.id).await.is_none()); + } }