From 65c48a2db3fb3b45703e7241729d67a9fb6cc8ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 24 Sep 2026 10:25:44 +0000 Subject: [PATCH 1/2] perry-container-compose: run on turnloop, drop tokio (tokio lane K) The compose engine stays async; only its leaves and executor move. A new `perry_container_compose::rt` module drives it on turnloop: `Command` (child processes via `Driver::spawn` + multishot pipe reads), `sleep` / `timeout` (turnloop timers), `shutdown_signal`, `Mutex` (async-lock), and `block_on` / `try_block_on`, which own one turnloop loop per call. The `perry-compose` binary and every test use `rt::block_on` instead of `#[tokio::main]` / `#[tokio::test]`. perry-stdlib's `container` feature now implies only `async-bridge`: `container/executor.rs` runs each operation's future with `rt::try_block_on` on turnloop's `Occupancy::Long` pool and settles through the tokio-free async bridge. Dropping an unfinished `rt::Command` future terminates the child, so a CLI call aborted by PERRY_CONTAINER_OP_TIMEOUT_SECS no longer leaves the process running. tokio_inventory drops both perry-container-compose edges; the K/N prose in the inventory and docs/turnloop/p8-report.md is corrected. --- Cargo.lock | 3 +- crates/perry-container-compose/Cargo.toml | 6 +- crates/perry-container-compose/src/backend.rs | 150 +-- .../src/backend/cli_backend.rs | 4 +- .../src/backend/detect.rs | 9 +- .../perry-container-compose/src/installer.rs | 2 +- crates/perry-container-compose/src/lib.rs | 1 + crates/perry-container-compose/src/main.rs | 7 +- .../src/orchestrate.rs | 168 +-- crates/perry-container-compose/src/rt/mod.rs | 351 ++++++ .../perry-container-compose/src/rt/process.rs | 329 ++++++ .../perry-container-compose/src/rt/signal.rs | 134 +++ .../perry-container-compose/src/rt/tests.rs | 178 +++ crates/perry-container-compose/src/rt/time.rs | 155 +++ .../perry-container-compose/src/workload.rs | 2 +- .../tests/backend_tests.rs | 21 +- .../tests/container_ops.rs | 138 +-- .../tests/exec_raw_timeout.rs | 156 +-- .../tests/functional_orchestration.rs | 1036 +++++++++-------- .../tests/live_runtime_tests.rs | 702 +++++------ .../tests/orchestration.rs | 632 +++++----- crates/perry-stdlib/Cargo.toml | 11 +- .../perry-stdlib/src/common/async_bridge.rs | 2 +- .../perry-stdlib/src/container/backend_ctl.rs | 55 +- .../perry-stdlib/src/container/compose_ffi.rs | 41 +- crates/perry-stdlib/src/container/executor.rs | 156 +++ crates/perry-stdlib/src/container/images.rs | 12 +- .../perry-stdlib/src/container/lifecycle.rs | 46 +- .../perry-stdlib/src/container/logs_exec.rs | 8 +- crates/perry-stdlib/src/container/mod.rs | 83 +- .../src/container/verification.rs | 2 +- crates/perry-stdlib/src/container/workload.rs | 14 +- .../tests/container_backend_selection.rs | 90 +- .../perry-stdlib/tests/container_ffi_tests.rs | 301 ++--- .../tests/container_verification_tests.rs | 25 +- .../commands/compile/optimized_libs/driver.rs | 2 +- docs/turnloop/p8-report.md | 10 +- scripts/tokio_inventory.json | 36 +- 38 files changed, 3216 insertions(+), 1862 deletions(-) create mode 100644 crates/perry-container-compose/src/rt/mod.rs create mode 100644 crates/perry-container-compose/src/rt/process.rs create mode 100644 crates/perry-container-compose/src/rt/signal.rs create mode 100644 crates/perry-container-compose/src/rt/tests.rs create mode 100644 crates/perry-container-compose/src/rt/time.rs create mode 100644 crates/perry-stdlib/src/container/executor.rs diff --git a/Cargo.lock b/Cargo.lock index 0cf25a15bb..4b4549e045 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5341,6 +5341,7 @@ dependencies = [ name = "perry-container-compose" version = "0.5.1654" dependencies = [ + "async-lock", "async-trait", "clap", "console 0.16.6", @@ -5354,9 +5355,9 @@ dependencies = [ "serde_json", "serde_yaml", "thiserror 1.0.69", - "tokio", "tracing", "tracing-subscriber", + "turnloop", "which", ] diff --git a/crates/perry-container-compose/Cargo.toml b/crates/perry-container-compose/Cargo.toml index d816828e4b..42cdf824d7 100644 --- a/crates/perry-container-compose/Cargo.toml +++ b/crates/perry-container-compose/Cargo.toml @@ -14,7 +14,10 @@ workspace = true serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" -tokio = { workspace = true } +# turnloop, not tokio: `rt` drives the async engine on a turnloop loop +# (child processes, timers, signals) — see src/rt/mod.rs. +turnloop = { workspace = true } +async-lock = "3" clap = { workspace = true } thiserror = { workspace = true } tracing = "0.1" @@ -29,7 +32,6 @@ console = "0.16" which = "8.0" [dev-dependencies] -tokio = { workspace = true } proptest = "1" [features] diff --git a/crates/perry-container-compose/src/backend.rs b/crates/perry-container-compose/src/backend.rs index f5fa730556..0fad0e1746 100644 --- a/crates/perry-container-compose/src/backend.rs +++ b/crates/perry-container-compose/src/backend.rs @@ -1049,85 +1049,87 @@ mod tests { /// All env-var-mutating tests in one function. cargo runs tests /// in parallel by default and `std::env::set_var` is process-global, - /// so independent `#[tokio::test]` cases would race the env var + /// so independent test cases would race the env var /// across threads and produce flaky results. Consolidate sequentially /// rather than depend on a serial-test crate (avoids the dep + the /// per-test setup overhead of `#[serial]`). - #[tokio::test] - async fn test_detect_backend_env_override_behavior() { - // ------------------------------------------------------------- - // Phase 1: single name (existing behavior, backwards-compat) - // ------------------------------------------------------------- - std::env::set_var("PERRY_CONTAINER_BACKEND", "invalid-backend-name"); - let res = detect_backend().await; - std::env::remove_var("PERRY_CONTAINER_BACKEND"); - - if let Err(ComposeError::NoBackendFound { probed }) = res { - assert_eq!(probed.len(), 1); - assert_eq!(probed[0].name, "invalid-backend-name"); - assert_eq!(probed[0].reason, "unknown backend"); - } else { - panic!("Expected NoBackendFound error from single-name override"); - } + #[test] + fn test_detect_backend_env_override_behavior() { + crate::rt::block_on(async { + // ------------------------------------------------------------- + // Phase 1: single name (existing behavior, backwards-compat) + // ------------------------------------------------------------- + std::env::set_var("PERRY_CONTAINER_BACKEND", "invalid-backend-name"); + let res = detect_backend().await; + std::env::remove_var("PERRY_CONTAINER_BACKEND"); + + if let Err(ComposeError::NoBackendFound { probed }) = res { + assert_eq!(probed.len(), 1); + assert_eq!(probed[0].name, "invalid-backend-name"); + assert_eq!(probed[0].reason, "unknown backend"); + } else { + panic!("Expected NoBackendFound error from single-name override"); + } - // ------------------------------------------------------------- - // Phase 2: comma-separated user priority list (v0.5.380 feature) - // ------------------------------------------------------------- - // Each name in the list gets probed in order. All-invalid case: - // returns NoBackendFound with one BackendProbeResult per - // attempted name, order preserved. - std::env::set_var("PERRY_CONTAINER_BACKEND", "bogus-one,bogus-two,bogus-three"); - let res = detect_backend().await; - std::env::remove_var("PERRY_CONTAINER_BACKEND"); - - if let Err(ComposeError::NoBackendFound { probed }) = res { - assert_eq!(probed.len(), 3, "expected one probe per name"); - assert_eq!(probed[0].name, "bogus-one"); - assert_eq!(probed[1].name, "bogus-two"); - assert_eq!(probed[2].name, "bogus-three"); - assert!(probed.iter().all(|p| p.reason.contains("unknown"))); - } else { - panic!("Expected NoBackendFound error from comma-separated list"); - } + // ------------------------------------------------------------- + // Phase 2: comma-separated user priority list (v0.5.380 feature) + // ------------------------------------------------------------- + // Each name in the list gets probed in order. All-invalid case: + // returns NoBackendFound with one BackendProbeResult per + // attempted name, order preserved. + std::env::set_var("PERRY_CONTAINER_BACKEND", "bogus-one,bogus-two,bogus-three"); + let res = detect_backend().await; + std::env::remove_var("PERRY_CONTAINER_BACKEND"); + + if let Err(ComposeError::NoBackendFound { probed }) = res { + assert_eq!(probed.len(), 3, "expected one probe per name"); + assert_eq!(probed[0].name, "bogus-one"); + assert_eq!(probed[1].name, "bogus-two"); + assert_eq!(probed[2].name, "bogus-three"); + assert!(probed.iter().all(|p| p.reason.contains("unknown"))); + } else { + panic!("Expected NoBackendFound error from comma-separated list"); + } - // ------------------------------------------------------------- - // Phase 3: tolerant parsing — whitespace + empty entries - // ------------------------------------------------------------- - // Real env-var input `"a, b,,c"` shouldn't produce 4 probe - // entries. Trim each entry; skip empties. - std::env::set_var("PERRY_CONTAINER_BACKEND", " bogus-a , bogus-b ,, "); - let res = detect_backend().await; - std::env::remove_var("PERRY_CONTAINER_BACKEND"); - - if let Err(ComposeError::NoBackendFound { probed }) = res { - assert_eq!(probed.len(), 2); - assert_eq!(probed[0].name, "bogus-a"); - assert_eq!(probed[1].name, "bogus-b"); - } else { - panic!("Expected NoBackendFound error from whitespace-padded list"); - } + // ------------------------------------------------------------- + // Phase 3: tolerant parsing — whitespace + empty entries + // ------------------------------------------------------------- + // Real env-var input `"a, b,,c"` shouldn't produce 4 probe + // entries. Trim each entry; skip empties. + std::env::set_var("PERRY_CONTAINER_BACKEND", " bogus-a , bogus-b ,, "); + let res = detect_backend().await; + std::env::remove_var("PERRY_CONTAINER_BACKEND"); + + if let Err(ComposeError::NoBackendFound { probed }) = res { + assert_eq!(probed.len(), 2); + assert_eq!(probed[0].name, "bogus-a"); + assert_eq!(probed[1].name, "bogus-b"); + } else { + panic!("Expected NoBackendFound error from whitespace-padded list"); + } - // ------------------------------------------------------------- - // Phase 4: empty string falls through to platform default - // ------------------------------------------------------------- - // `PERRY_CONTAINER_BACKEND= ./app` is a real shell idiom for - // "clear an override inherited from the parent env." It - // shouldn't error; should behave as if the var was unset. - std::env::set_var("PERRY_CONTAINER_BACKEND", ""); - let res = detect_backend().await; - std::env::remove_var("PERRY_CONTAINER_BACKEND"); - - // Can't assert Ok vs Err deterministically (depends on test - // runner's installed runtimes), but if Err, the probed list - // length must match platform_candidates, NOT 0 (which would - // mean the empty-list path was taken). - if let Err(ComposeError::NoBackendFound { probed }) = res { - let candidates = platform_candidates(); - assert_eq!( - probed.len(), - candidates.len(), - "empty env var should fall through to platform_candidates probe" - ); - } + // ------------------------------------------------------------- + // Phase 4: empty string falls through to platform default + // ------------------------------------------------------------- + // `PERRY_CONTAINER_BACKEND= ./app` is a real shell idiom for + // "clear an override inherited from the parent env." It + // shouldn't error; should behave as if the var was unset. + std::env::set_var("PERRY_CONTAINER_BACKEND", ""); + let res = detect_backend().await; + std::env::remove_var("PERRY_CONTAINER_BACKEND"); + + // Can't assert Ok vs Err deterministically (depends on test + // runner's installed runtimes), but if Err, the probed list + // length must match platform_candidates, NOT 0 (which would + // mean the empty-list path was taken). + if let Err(ComposeError::NoBackendFound { probed }) = res { + let candidates = platform_candidates(); + assert_eq!( + probed.len(), + candidates.len(), + "empty env var should fall through to platform_candidates probe" + ); + } + }) } } diff --git a/crates/perry-container-compose/src/backend/cli_backend.rs b/crates/perry-container-compose/src/backend/cli_backend.rs index e9c0078238..f87f1e37c0 100644 --- a/crates/perry-container-compose/src/backend/cli_backend.rs +++ b/crates/perry-container-compose/src/backend/cli_backend.rs @@ -1,5 +1,6 @@ use super::*; use crate::error::{ComposeError, Result}; +use crate::rt::Command; use crate::types::{ ComposeNetwork, ComposeServiceBuild, ComposeVolume, ContainerHandle, ContainerInfo, ContainerLogs, ContainerSpec, ImageInfo, @@ -8,7 +9,6 @@ use async_trait::async_trait; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; -use tokio::process::Command; pub struct CliBackend { pub bin: PathBuf, @@ -34,7 +34,7 @@ impl CliBackend { let timeout = Duration::from_secs(timeout_secs); let fut = Command::new(&self.bin).args(args).output(); - let output = match tokio::time::timeout(timeout, fut).await { + let output = match crate::rt::timeout(timeout, fut).await { Ok(Ok(out)) => out, Ok(Err(e)) => return Err(ComposeError::IoError(e)), Err(_) => { diff --git a/crates/perry-container-compose/src/backend/detect.rs b/crates/perry-container-compose/src/backend/detect.rs index 0f935572e0..9ed90ddd57 100644 --- a/crates/perry-container-compose/src/backend/detect.rs +++ b/crates/perry-container-compose/src/backend/detect.rs @@ -1,8 +1,8 @@ use super::*; use crate::error::{ComposeError, Result}; +use crate::rt::Command; use std::path::PathBuf; use std::time::Duration; -use tokio::process::Command; pub async fn detect_backend() -> Result> { // `PERRY_CONTAINER_BACKEND` accepts EITHER a single name (single-pin) @@ -28,8 +28,7 @@ pub async fn detect_backend() -> Result> { } else { let mut results = Vec::new(); for candidate in &user_priority { - match tokio::time::timeout(Duration::from_secs(2), probe_candidate(candidate)).await - { + match crate::rt::timeout(Duration::from_secs(2), probe_candidate(candidate)).await { Ok(Ok(backend)) => return Ok(backend), Ok(Err(reason)) => results.push(BackendProbeResult { name: candidate.to_string(), @@ -51,7 +50,7 @@ pub async fn detect_backend() -> Result> { let mut results = Vec::new(); for candidate in candidates { - match tokio::time::timeout(Duration::from_secs(2), probe_candidate(candidate)).await { + match crate::rt::timeout(Duration::from_secs(2), probe_candidate(candidate)).await { Ok(Ok(backend)) => return Ok(backend), Ok(Err(reason)) => results.push(BackendProbeResult { name: candidate.to_string(), @@ -88,7 +87,7 @@ pub async fn probe_all_candidates() -> Vec { let candidates = platform_candidates(); let mut results = Vec::with_capacity(candidates.len()); for candidate in candidates { - match tokio::time::timeout(Duration::from_secs(2), probe_candidate(candidate)).await { + match crate::rt::timeout(Duration::from_secs(2), probe_candidate(candidate)).await { Ok(Ok(_backend)) => results.push(BackendProbeResult { name: candidate.to_string(), available: true, diff --git a/crates/perry-container-compose/src/installer.rs b/crates/perry-container-compose/src/installer.rs index 8eb4954d44..e11175eeb1 100644 --- a/crates/perry-container-compose/src/installer.rs +++ b/crates/perry-container-compose/src/installer.rs @@ -118,7 +118,7 @@ impl BackendInstaller { } async fn execute_install(&self, command: &str) -> Result<()> { - let status = tokio::process::Command::new("sh") + let status = crate::rt::Command::new("sh") .arg("-c") .arg(command) .status() diff --git a/crates/perry-container-compose/src/lib.rs b/crates/perry-container-compose/src/lib.rs index 94ed0ee6eb..1b01cc3a27 100644 --- a/crates/perry-container-compose/src/lib.rs +++ b/crates/perry-container-compose/src/lib.rs @@ -9,6 +9,7 @@ pub mod error; pub mod installer; pub mod orchestrate; pub mod project; +pub mod rt; pub mod service; pub mod types; pub mod workload; diff --git a/crates/perry-container-compose/src/main.rs b/crates/perry-container-compose/src/main.rs index 73e014c72e..f4d8767073 100644 --- a/crates/perry-container-compose/src/main.rs +++ b/crates/perry-container-compose/src/main.rs @@ -4,8 +4,7 @@ use clap::Parser; use perry_container_compose::cli::{run, Cli}; use tracing_subscriber::{fmt, EnvFilter}; -#[tokio::main] -async fn main() { +fn main() { // Initialise tracing (RUST_LOG env controls verbosity) fmt() .with_env_filter(EnvFilter::from_default_env()) @@ -14,7 +13,9 @@ async fn main() { let cli = Cli::parse(); - if let Err(e) = run(cli).await { + // The engine is executor-agnostic async code; `rt::block_on` drives it on + // a turnloop loop owned by this thread (see `perry_container_compose::rt`). + if let Err(e) = perry_container_compose::rt::block_on(run(cli)) { eprintln!("Error: {}", e); std::process::exit(1); } diff --git a/crates/perry-container-compose/src/orchestrate.rs b/crates/perry-container-compose/src/orchestrate.rs index 25b8f0dd1e..eebb9d897e 100644 --- a/crates/perry-container-compose/src/orchestrate.rs +++ b/crates/perry-container-compose/src/orchestrate.rs @@ -71,91 +71,99 @@ mod tests { } } - #[tokio::test] - async fn already_running_skips_orchestration() { - let mock = MockBackend::new(); - // Default: any inspect returns running info → is_running = true. - mock.set_inspect_running(true).await; - let svc = svc_with_image("alpine"); - let result = orchestrate_service(&svc, "web", &mock).await.unwrap(); - assert!( - matches!(result, None), - "running service should skip and return None" - ); - let calls = mock.calls().await; - assert!( - !calls.iter().any(|c| matches!(c, RecordedCall::Run { .. })), - "running service must not call run" - ); - assert!( - !calls - .iter() - .any(|c| matches!(c, RecordedCall::Start { .. })), - "running service must not call start" - ); + #[test] + fn already_running_skips_orchestration() { + crate::rt::block_on(async { + let mock = MockBackend::new(); + // Default: any inspect returns running info → is_running = true. + mock.set_inspect_running(true).await; + let svc = svc_with_image("alpine"); + let result = orchestrate_service(&svc, "web", &mock).await.unwrap(); + assert!( + matches!(result, None), + "running service should skip and return None" + ); + let calls = mock.calls().await; + assert!( + !calls.iter().any(|c| matches!(c, RecordedCall::Run { .. })), + "running service must not call run" + ); + assert!( + !calls + .iter() + .any(|c| matches!(c, RecordedCall::Start { .. })), + "running service must not call start" + ); + }) } - #[tokio::test] - async fn stopped_existing_service_is_started_not_run() { - let mock = MockBackend::new(); - mock.set_inspect_running(false).await; - let svc = svc_with_image("alpine"); - let result = orchestrate_service(&svc, "web", &mock).await.unwrap(); - assert!( - matches!(result, None), - "start path returns None (no fresh handle)" - ); - let calls = mock.calls().await; - assert!( - calls - .iter() - .any(|c| matches!(c, RecordedCall::Start { .. })), - "expected backend.start to be called" - ); - assert!( - !calls.iter().any(|c| matches!(c, RecordedCall::Run { .. })), - "stopped+existing path must not call run" - ); + #[test] + fn stopped_existing_service_is_started_not_run() { + crate::rt::block_on(async { + let mock = MockBackend::new(); + mock.set_inspect_running(false).await; + let svc = svc_with_image("alpine"); + let result = orchestrate_service(&svc, "web", &mock).await.unwrap(); + assert!( + matches!(result, None), + "start path returns None (no fresh handle)" + ); + let calls = mock.calls().await; + assert!( + calls + .iter() + .any(|c| matches!(c, RecordedCall::Start { .. })), + "expected backend.start to be called" + ); + assert!( + !calls.iter().any(|c| matches!(c, RecordedCall::Run { .. })), + "stopped+existing path must not call run" + ); + }) } - #[tokio::test] - async fn missing_service_with_build_calls_build_then_run() { - let mock = MockBackend::new(); - mock.set_inspect_not_found().await; - let svc = svc_with_build("."); - let result = orchestrate_service(&svc, "api", &mock).await.unwrap(); - assert!(matches!(result, Some(_)), "fresh run returns a handle"); - let calls = mock.calls().await; - let build_idx = calls - .iter() - .position(|c| matches!(c, RecordedCall::Build { .. })) - .expect("expected backend.build"); - let run_idx = calls - .iter() - .position(|c| matches!(c, RecordedCall::Run { .. })) - .expect("expected backend.run"); - assert!( - build_idx < run_idx, - "build must precede run (Task 0.4 ordering invariant)" - ); + #[test] + fn missing_service_with_build_calls_build_then_run() { + crate::rt::block_on(async { + let mock = MockBackend::new(); + mock.set_inspect_not_found().await; + let svc = svc_with_build("."); + let result = orchestrate_service(&svc, "api", &mock).await.unwrap(); + assert!(matches!(result, Some(_)), "fresh run returns a handle"); + let calls = mock.calls().await; + let build_idx = calls + .iter() + .position(|c| matches!(c, RecordedCall::Build { .. })) + .expect("expected backend.build"); + let run_idx = calls + .iter() + .position(|c| matches!(c, RecordedCall::Run { .. })) + .expect("expected backend.run"); + assert!( + build_idx < run_idx, + "build must precede run (Task 0.4 ordering invariant)" + ); + }) } - #[tokio::test] - async fn missing_service_no_build_skips_build() { - let mock = MockBackend::new(); - mock.set_inspect_not_found().await; - let svc = svc_with_image("alpine"); // image set, no build - let _ = orchestrate_service(&svc, "cache", &mock).await.unwrap(); - let calls = mock.calls().await; - assert!( - !calls - .iter() - .any(|c| matches!(c, RecordedCall::Build { .. })), - "service without build field must not call build" - ); - assert!( - calls.iter().any(|c| matches!(c, RecordedCall::Run { .. })), - "missing-image service should call run" - ); + #[test] + fn missing_service_no_build_skips_build() { + crate::rt::block_on(async { + let mock = MockBackend::new(); + mock.set_inspect_not_found().await; + let svc = svc_with_image("alpine"); // image set, no build + let _ = orchestrate_service(&svc, "cache", &mock).await.unwrap(); + let calls = mock.calls().await; + assert!( + !calls + .iter() + .any(|c| matches!(c, RecordedCall::Build { .. })), + "service without build field must not call build" + ); + assert!( + calls.iter().any(|c| matches!(c, RecordedCall::Run { .. })), + "missing-image service should call run" + ); + }) } } diff --git a/crates/perry-container-compose/src/rt/mod.rs b/crates/perry-container-compose/src/rt/mod.rs new file mode 100644 index 0000000000..c93917d3be --- /dev/null +++ b/crates/perry-container-compose/src/rt/mod.rs @@ -0,0 +1,351 @@ +//! The crate's async runtime: a turnloop-backed `block_on` plus the few leaf +//! primitives the compose engine awaits — child processes, timers, a shutdown +//! signal and an async mutex. +//! +//! Everything above this module (`ContainerBackend`, the compose engine, the +//! workload graph, the CLI) is plain executor-agnostic `async` Rust. Only the +//! leaves need an event source, and they get it from the +//! [`turnloop::Loop`] that the innermost enclosing [`block_on`] owns on this +//! thread: +//! +//! * [`Command`] spawns the container CLI (`docker`, `podman`, `container`, +//! …) through turnloop's native child-process support, reads its stdout and +//! stderr pipes to EOF with multishot reads, and completes on the child's +//! reaped exit status. Dropping an unfinished `output()` / `status()` future +//! closes the process handle, which terminates the child — so a timed-out +//! CLI invocation is killed rather than left running. +//! * [`sleep`] / [`timeout`] are one-shot turnloop timers. +//! * [`shutdown_signal`] subscribes the loop to SIGINT / SIGTERM (console +//! Ctrl-C on Windows). +//! * [`Mutex`] is `async-lock`'s executor-agnostic mutex; its wakers may fire +//! from any thread, which [`block_on`] turns into a turnloop notification. +//! +//! # Driving it +//! +//! [`block_on`] creates one loop per call, polls the future, and turns the +//! loop whenever the future is pending — waking on a completion one of its +//! leaves submitted, on a timer, or on a cross-thread [`std::task::Waker`]. +//! It is the executor for the standalone `perry-compose` binary, for this +//! crate's tests, and — one call per operation, on a turnloop pool worker — +//! for perry-stdlib's `perry/container`, `perry/compose` and +//! `perry/workloads` bindings. Nested calls are allowed: the inner call gets +//! its own loop and the outer one resumes when it returns. +//! +//! The leaf futures hold no loop reference, only plain ids, so they are +//! `Send` and fit `#[async_trait]`'s boxed `Send` futures. The price is the +//! same rule tokio has: a leaf must be polled inside the `block_on` that first +//! polled it, and polling one outside any `block_on` panics. + +use std::cell::RefCell; +use std::collections::{HashMap, VecDeque}; +use std::future::Future; +use std::io; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; + +use turnloop::{Completions, Config, Loop, Notifier, OpResult, Timeout, Token}; + +mod process; +mod signal; +mod time; + +#[cfg(test)] +mod tests; + +pub use async_lock::{Mutex, MutexGuard}; +pub use process::{Command, ExitStatus, Output, OutputFuture, StatusFuture}; +pub use signal::{shutdown_signal, ShutdownSignal, ShutdownSignalFuture}; +pub use time::{sleep, timeout, Elapsed, Sleep, Timeout as TimeoutFuture}; + +/// Close token for handles whose `Closed` completion nobody waits for. Slot +/// tokens start at 1, so completions carrying it are dropped by dispatch. +const UNROUTED: Token = Token(0); + +static NEXT_REACTOR_ID: AtomicU64 = AtomicU64::new(1); + +thread_local! { + /// The reactor of the innermost `block_on` running on this thread. + static CURRENT: RefCell>>> = const { RefCell::new(None) }; +} + +/// One routed completion, copied out of turnloop's output buffer. +#[derive(Debug)] +pub(crate) enum Event { + Timer, + Read(Vec), + Eof, + Exited(turnloop::ExitStatus), + Signal, + Failed(turnloop::Error), +} + +#[derive(Default)] +struct Slot { + waker: Option, + events: VecDeque, +} + +/// A `block_on` call's loop plus the per-token event queues its leaves poll. +pub(crate) struct Reactor { + id: u64, + pub(crate) driver: Loop, + next_token: u64, + slots: HashMap, +} + +impl Reactor { + fn new() -> io::Result { + // Sized for a handful of concurrent CLI invocations, not a server: + // each child costs at most three handles (process, stdout, stderr). + // `blocking_pool` keeps its default because it is process-wide and + // must match every other loop's; nothing here submits to it. + let config = Config { + max_handles: 256, + max_operations: 1024, + events_per_turn: 64, + pooled_buffers: 32, + ..Config::default() + }; + let driver = Loop::new(config).map_err(io_error)?; + Ok(Self { + id: NEXT_REACTOR_ID.fetch_add(1, Ordering::Relaxed), + driver, + next_token: 1, + slots: HashMap::new(), + }) + } + + pub(crate) fn id(&self) -> u64 { + self.id + } + + /// Mint a routing token and the event queue its completions land in. + pub(crate) fn register(&mut self) -> u64 { + let token = self.next_token; + self.next_token += 1; + self.slots.insert(token, Slot::default()); + token + } + + /// Stop routing `token`; later completions for it are dropped. + pub(crate) fn forget(&mut self, token: u64) { + self.slots.remove(&token); + } + + /// Pop the next event for `token`, or remember `cx`'s waker for it. + pub(crate) fn take_event(&mut self, token: u64, cx: &Context<'_>) -> Option { + let slot = self.slots.get_mut(&token)?; + let event = slot.events.pop_front(); + if event.is_none() { + match &slot.waker { + Some(w) if w.will_wake(cx.waker()) => {} + _ => slot.waker = Some(cx.waker().clone()), + } + } + event + } + + /// Close `handle`, ignoring a handle that is already closing or gone. + pub(crate) fn close(&mut self, handle: turnloop::Handle) { + let _ = self.driver.close(handle, UNROUTED); + } + + fn turn(this: &RefCell, timeout: Timeout, completions: &mut Completions) { + let mut wake = Vec::new(); + { + let mut reactor = this.borrow_mut(); + if let Err(e) = reactor.driver.turn(timeout, completions) { + panic!("perry_container_compose::rt: turnloop turn failed: {e}"); + } + for completion in completions.drain() { + let event = match completion.result { + OpResult::Timer => Event::Timer, + OpResult::Read { n, lease } => { + let bytes = lease + .as_ref() + .map(|l| { + let s = l.as_slice(); + s[..n.min(s.len())].to_vec() + }) + .unwrap_or_default(); + Event::Read(bytes) + } + OpResult::Eof => Event::Eof, + OpResult::Exited(status) => Event::Exited(status), + OpResult::Signal(_) => Event::Signal, + OpResult::Err(e) => Event::Failed(e), + // Cancelled / Closed / Stopped acknowledge teardown the + // leaf already did; nothing waits for them. + _ => continue, + }; + if let Some(slot) = reactor.slots.get_mut(&completion.token.0) { + slot.events.push_back(event); + if let Some(w) = slot.waker.take() { + wake.push(w); + } + } + } + } + // Wake outside the borrow: a waker is foreign code. + for w in wake { + w.wake(); + } + } +} + +/// Run `f` against the current thread's innermost reactor. +/// +/// # Panics +/// Outside [`block_on`]. +pub(crate) fn with_current(f: impl FnOnce(&mut Reactor) -> R) -> R { + CURRENT.with(|current| { + let reactor = current.borrow().clone().expect( + "perry_container_compose::rt primitive polled outside rt::block_on \ + (it needs the turnloop loop that block_on owns)", + ); + let mut reactor = reactor.borrow_mut(); + f(&mut reactor) + }) +} + +/// Run `f` against the reactor `id`, which must be the current one. +/// +/// # Panics +/// When the current reactor is a different one: the leaf was first polled by +/// another `block_on`, whose loop owns its handles. +pub(crate) fn with_reactor(id: u64, f: impl FnOnce(&mut Reactor) -> R) -> R { + with_current(|reactor| { + assert_eq!( + reactor.id, id, + "perry_container_compose::rt primitive polled by a different rt::block_on than the \ + one that started it" + ); + f(reactor) + }) +} + +/// [`with_reactor`] for `Drop`: does nothing when `id` is not the current +/// reactor (its loop is gone, or dropping it will release the handles) or +/// the thread-local is already torn down. +pub(crate) fn try_with_reactor(id: u64, f: impl FnOnce(&mut Reactor)) { + let _ = CURRENT.try_with(|current| { + let Ok(current) = current.try_borrow() else { + return; + }; + if let Some(reactor) = current.as_ref() { + if let Ok(mut reactor) = reactor.try_borrow_mut() { + if reactor.id == id { + f(&mut reactor); + } + } + } + }); +} + +/// Whether this thread is inside a [`block_on`] — the analogue of +/// `tokio::runtime::Handle::try_current().is_ok()`. +pub fn in_context() -> bool { + CURRENT + .try_with(|current| current.borrow().is_some()) + .unwrap_or(false) +} + +/// The `block_on` waker: records the wake and nudges the loop, which only +/// costs a syscall when the loop is actually parked (a wake from another +/// thread). +struct WakeFlag { + woken: AtomicBool, + notifier: Notifier, +} + +impl Wake for WakeFlag { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + self.woken.store(true, Ordering::Release); + // `Err` means the loop is gone, i.e. the block_on already returned. + let _ = self.notifier.notify(); + } +} + +/// Restores the enclosing `block_on`'s reactor (if any) on exit, including +/// on unwind. +struct RestoreCurrent(Option>>); + +impl Drop for RestoreCurrent { + fn drop(&mut self) { + let previous = self.0.take(); + let _ = CURRENT.try_with(|current| *current.borrow_mut() = previous); + } +} + +/// Drive `future` to completion on this thread, on a turnloop loop created +/// for the call. Returns an error only when the loop cannot be created. +pub fn try_block_on(future: F) -> io::Result { + let reactor = Rc::new(RefCell::new(Reactor::new()?)); + let notifier = reactor.borrow().driver.notifier(); + let previous = CURRENT.with(|current| current.replace(Some(Rc::clone(&reactor)))); + // Declared after `reactor` and before `future`: the future — and every + // leaf in it — drops first, while this reactor is still current, so leaf + // destructors can close their handles; then the enclosing reactor comes + // back; then this loop drops, terminating anything still alive in it. + let _restore = RestoreCurrent(previous); + let flag = Arc::new(WakeFlag { + woken: AtomicBool::new(true), + notifier, + }); + let waker = Waker::from(Arc::clone(&flag)); + let mut cx = Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + let mut completions = Completions::with_capacity(64); + loop { + if flag.woken.swap(false, Ordering::AcqRel) { + if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { + return Ok(output); + } + } + let timeout = if flag.woken.load(Ordering::Acquire) { + Timeout::Now + } else { + Timeout::Forever + }; + Reactor::turn(&reactor, timeout, &mut completions); + } +} + +/// Drive `future` to completion on this thread — see [`try_block_on`]. +/// +/// # Panics +/// When the turnloop loop cannot be created (descriptor exhaustion). +pub fn block_on(future: F) -> F::Output { + try_block_on(future).unwrap_or_else(|e| { + panic!("perry_container_compose::rt::block_on: cannot create a turnloop loop: {e}") + }) +} + +/// Convert a turnloop error to `std::io::Error`, keeping the OS code. +pub(crate) fn io_error(e: turnloop::Error) -> io::Error { + use turnloop::ErrorKind as K; + if let Some(code) = e.os { + return io::Error::from_raw_os_error(code); + } + let kind = match e.kind { + K::NotFound => io::ErrorKind::NotFound, + K::PermissionDenied => io::ErrorKind::PermissionDenied, + K::InvalidInput => io::ErrorKind::InvalidInput, + K::Unsupported => io::ErrorKind::Unsupported, + K::TimedOut => io::ErrorKind::TimedOut, + K::WouldBlock => io::ErrorKind::WouldBlock, + K::BrokenPipe => io::ErrorKind::BrokenPipe, + K::ConnectionRefused => io::ErrorKind::ConnectionRefused, + K::ConnectionReset => io::ErrorKind::ConnectionReset, + K::AlreadyExists => io::ErrorKind::AlreadyExists, + K::Cancelled => io::ErrorKind::Interrupted, + _ => io::ErrorKind::Other, + }; + io::Error::new(kind, e) +} diff --git a/crates/perry-container-compose/src/rt/process.rs b/crates/perry-container-compose/src/rt/process.rs new file mode 100644 index 0000000000..04e5296256 --- /dev/null +++ b/crates/perry-container-compose/src/rt/process.rs @@ -0,0 +1,329 @@ +//! Child processes on turnloop: the `docker` / `podman` / `container` CLI +//! invocations every backend operation is made of. +//! +//! The shape is `std::process::Command`'s (and tokio's): build with +//! [`Command::new`] / [`Command::arg`] / [`Command::args`], then await +//! [`Command::output`] (stdin null, stdout and stderr captured) or +//! [`Command::status`] (all three inherited). The spawn happens on the first +//! poll, inside the enclosing [`super::block_on`]'s loop; the output future +//! completes once the child has been reaped AND both pipes reached EOF, so no +//! trailing output is lost. +//! +//! **Drop terminates.** Dropping a future before it completes closes the +//! process handle, which turnloop turns into terminating the child and then +//! reaping it. That is what makes `timeout(d, cmd.output())` an actual abort: +//! tokio's `output()` future (without `kill_on_drop`) left a hung CLI running +//! after the timeout fired. + +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use turnloop::{Handle, ProcessSpec, ProcessStdio, Token}; + +use super::{io_error, try_with_reactor, with_current, with_reactor, Event, Reactor}; + +/// A child-process builder. +#[derive(Clone, Debug)] +pub struct Command { + program: OsString, + args: Vec, +} + +impl Command { + /// Launch `program`, resolved through `PATH` when it has no separator. + pub fn new(program: impl AsRef) -> Self { + Self { + program: program.as_ref().to_owned(), + args: Vec::new(), + } + } + + /// Append one argument. + pub fn arg(&mut self, arg: impl AsRef) -> &mut Self { + self.args.push(arg.as_ref().to_owned()); + self + } + + /// Append arguments. + pub fn args(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + S: AsRef, + { + self.args + .extend(args.into_iter().map(|a| a.as_ref().to_owned())); + self + } + + fn spec(&self, stdio: [ProcessStdio; 3]) -> ProcessSpec { + let mut spec = ProcessSpec::new(self.program.clone()); + spec.args = self.args.clone(); + spec.stdio = stdio; + spec + } + + /// Run to completion with stdin null and stdout / stderr captured. + pub fn output(&mut self) -> OutputFuture { + OutputFuture { + inner: Child::new(self.spec([ + ProcessStdio::Null, + ProcessStdio::Pipe, + ProcessStdio::Pipe, + ])), + } + } + + /// Run to completion with all three streams inherited. + pub fn status(&mut self) -> StatusFuture { + StatusFuture { + inner: Child::new(self.spec([ProcessStdio::Inherit; 3])), + } + } +} + +/// A child's exit status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExitStatus { + code: Option, + signal: Option, +} + +impl ExitStatus { + /// Whether the child exited normally with code 0. + pub fn success(&self) -> bool { + self.code == Some(0) + } + + /// The exit code; `None` when the child was terminated by a signal. + pub fn code(&self) -> Option { + self.code + } + + /// The terminating signal number, when there was one. + pub fn signal(&self) -> Option { + self.signal + } +} + +impl From for ExitStatus { + fn from(status: turnloop::ExitStatus) -> Self { + Self { + code: status.code, + signal: status.signal, + } + } +} + +impl fmt::Display for ExitStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match (self.code, self.signal) { + (Some(code), _) => write!(f, "exit status: {code}"), + (None, Some(signal)) => write!(f, "signal: {signal}"), + (None, None) => f.write_str("unknown exit status"), + } + } +} + +/// A finished child's status and captured output. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Output { + /// The reaped exit status. + pub status: ExitStatus, + /// Everything the child wrote to stdout. + pub stdout: Vec, + /// Everything the child wrote to stderr. + pub stderr: Vec, +} + +/// One captured pipe. +struct Pipe { + handle: Handle, + token: u64, + bytes: Vec, + done: bool, +} + +/// A spawned child, owned by one reactor. +struct Running { + reactor: u64, + process: Handle, + exit_token: u64, + status: Option, + pipes: Vec, +} + +impl Running { + fn start(reactor: &mut Reactor, spec: &ProcessSpec) -> io::Result { + let exit_token = reactor.register(); + let process = match reactor.driver.spawn(spec, Token(exit_token)) { + Ok(process) => process, + Err(e) => { + reactor.forget(exit_token); + return Err(io_error(e)); + } + }; + let mut running = Self { + reactor: reactor.id(), + process: process.handle, + exit_token, + status: None, + pipes: Vec::new(), + }; + for handle in [process.stdout, process.stderr].into_iter().flatten() { + let token = reactor.register(); + running.pipes.push(Pipe { + handle, + token, + bytes: Vec::new(), + done: false, + }); + if let Err(e) = reactor.driver.read_start(handle, Token(token)) { + running.release(reactor); + return Err(io_error(e)); + } + } + // turnloop never hands back a stdin pipe we did not ask for, but a + // spec that did ask would leave the child waiting on it forever. + if let Some(stdin) = process.stdin { + reactor.close(stdin); + } + Ok(running) + } + + /// Drain routed events; `Ok(true)` once exited and every pipe hit EOF. + fn advance(&mut self, reactor: &mut Reactor, cx: &Context<'_>) -> io::Result { + while self.status.is_none() { + match reactor.take_event(self.exit_token, cx) { + Some(Event::Exited(status)) => self.status = Some(status.into()), + Some(Event::Failed(e)) => return Err(io_error(e)), + Some(_) => {} + None => break, + } + } + for pipe in &mut self.pipes { + while !pipe.done { + match reactor.take_event(pipe.token, cx) { + Some(Event::Read(bytes)) => pipe.bytes.extend_from_slice(&bytes), + Some(Event::Eof) => pipe.done = true, + Some(Event::Failed(e)) => return Err(io_error(e)), + Some(_) => {} + None => break, + } + } + } + Ok(self.status.is_some() && self.pipes.iter().all(|p| p.done)) + } + + /// Stop routing and close every handle. Closing a live process handle + /// terminates the child; closing a reaped one just releases it. + fn release(&mut self, reactor: &mut Reactor) { + reactor.forget(self.exit_token); + reactor.close(self.process); + for pipe in &self.pipes { + reactor.forget(pipe.token); + reactor.close(pipe.handle); + } + } +} + +enum Child { + Pending(ProcessSpec), + Running(Running), + Done, +} + +impl Child { + fn new(spec: ProcessSpec) -> Self { + Self::Pending(spec) + } + + fn poll(&mut self, cx: &Context<'_>) -> Poll, Vec)>> { + if let Self::Pending(spec) = self { + match with_current(|reactor| Running::start(reactor, spec)) { + Ok(running) => *self = Self::Running(running), + Err(e) => { + *self = Self::Done; + return Poll::Ready(Err(e)); + } + } + } + let Self::Running(running) = self else { + panic!("perry_container_compose::rt::Command future polled after completion"); + }; + let result = with_reactor(running.reactor, |reactor| { + let finished = running.advance(reactor, cx); + if !matches!(finished, Ok(false)) { + running.release(reactor); + } + finished + }); + match result { + Ok(false) => Poll::Pending, + Ok(true) => { + // Already released inside the reactor call above. + let status = running.status.expect("finished implies exited"); + let mut pipes = std::mem::take(&mut running.pipes) + .into_iter() + .map(|p| p.bytes); + let stdout = pipes.next().unwrap_or_default(); + let stderr = pipes.next().unwrap_or_default(); + *self = Self::Done; + Poll::Ready(Ok((status, stdout, stderr))) + } + Err(e) => { + *self = Self::Done; + Poll::Ready(Err(e)) + } + } + } +} + +impl Drop for Child { + fn drop(&mut self) { + if let Self::Running(running) = self { + let id = running.reactor; + try_with_reactor(id, |reactor| running.release(reactor)); + } + } +} + +/// Future returned by [`Command::output`]. +#[must_use = "futures do nothing unless awaited"] +pub struct OutputFuture { + inner: Child, +} + +impl Future for OutputFuture { + type Output = io::Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.get_mut().inner.poll(cx).map(|result| { + result.map(|(status, stdout, stderr)| Output { + status, + stdout, + stderr, + }) + }) + } +} + +/// Future returned by [`Command::status`]. +#[must_use = "futures do nothing unless awaited"] +pub struct StatusFuture { + inner: Child, +} + +impl Future for StatusFuture { + type Output = io::Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.get_mut() + .inner + .poll(cx) + .map(|result| result.map(|(status, _, _)| status)) + } +} diff --git a/crates/perry-container-compose/src/rt/signal.rs b/crates/perry-container-compose/src/rt/signal.rs new file mode 100644 index 0000000000..10664a0085 --- /dev/null +++ b/crates/perry-container-compose/src/rt/signal.rs @@ -0,0 +1,134 @@ +//! Process shutdown signals on turnloop's process-wide signal service. + +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use turnloop::{Handle, Signal, Token}; + +use super::{io_error, try_with_reactor, with_current, with_reactor, Event, UNROUTED}; + +/// Which shutdown request arrived. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShutdownSignal { + /// SIGINT, or console Ctrl-C on Windows. + Interrupt, + /// SIGTERM. Windows has no console equivalent, so it never arrives there. + Terminate, +} + +impl ShutdownSignal { + /// The conventional `128 + signo` exit status for this signal. + pub fn exit_code(self) -> i32 { + match self { + Self::Interrupt => 130, + Self::Terminate => 143, + } + } +} + +/// Wait for the first SIGINT or SIGTERM delivered to the process. +/// +/// Subscribing is per loop and does not steal the signal from other +/// subscribers: every subscribed turnloop loop gets its own completion. +pub fn shutdown_signal() -> ShutdownSignalFuture { + ShutdownSignalFuture { subs: None } +} + +struct Subscription { + kind: ShutdownSignal, + handle: Handle, + token: u64, +} + +struct Subscriptions { + reactor: u64, + list: Vec, +} + +/// Future returned by [`shutdown_signal`]. +#[must_use = "futures do nothing unless awaited"] +pub struct ShutdownSignalFuture { + subs: Option, +} + +impl Future for ShutdownSignalFuture { + type Output = io::Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + if this.subs.is_none() { + let subscribed = with_current(|reactor| { + let mut list = Vec::new(); + let mut last_error = None; + for (kind, signal) in [ + (ShutdownSignal::Interrupt, Signal::Int), + (ShutdownSignal::Terminate, Signal::Term), + ] { + let token = reactor.register(); + match reactor.driver.signal_start(signal, Token(token)) { + Ok(handle) => list.push(Subscription { + kind, + handle, + token, + }), + // SIGTERM is `Unsupported` on Windows; one + // subscription is enough to wait on. + Err(e) => { + reactor.forget(token); + last_error = Some(e); + } + } + } + match (list.is_empty(), last_error) { + (true, Some(e)) => Err(io_error(e)), + _ => Ok(Subscriptions { + reactor: reactor.id(), + list, + }), + } + }); + match subscribed { + Ok(subs) => this.subs = Some(subs), + Err(e) => return Poll::Ready(Err(e)), + } + } + let subs = this.subs.as_ref().expect("subscribed above"); + let arrived = with_reactor(subs.reactor, |reactor| { + let mut arrived = None; + for sub in &subs.list { + if let Some(Event::Signal) = reactor.take_event(sub.token, cx) { + arrived.get_or_insert(sub.kind); + } + } + arrived + }); + match arrived { + Some(kind) => { + this.unsubscribe(); + Poll::Ready(Ok(kind)) + } + None => Poll::Pending, + } + } +} + +impl ShutdownSignalFuture { + fn unsubscribe(&mut self) { + if let Some(subs) = self.subs.take() { + try_with_reactor(subs.reactor, |reactor| { + for sub in &subs.list { + reactor.forget(sub.token); + let _ = reactor.driver.signal_stop(sub.handle, UNROUTED); + } + }); + } + } +} + +impl Drop for ShutdownSignalFuture { + fn drop(&mut self) { + self.unsubscribe(); + } +} diff --git a/crates/perry-container-compose/src/rt/tests.rs b/crates/perry-container-compose/src/rt/tests.rs new file mode 100644 index 0000000000..23947ab77b --- /dev/null +++ b/crates/perry-container-compose/src/rt/tests.rs @@ -0,0 +1,178 @@ +//! The runtime's own contract: every leaf actually waits on the loop, and a +//! dropped child is terminated rather than orphaned. + +use super::*; +use std::time::{Duration, Instant}; + +#[test] +fn block_on_returns_a_ready_value() { + assert_eq!(block_on(async { 7 }), 7); + assert!(!in_context()); + assert!(block_on(async { in_context() })); +} + +#[test] +fn sleep_waits_at_least_its_duration() { + let started = Instant::now(); + block_on(sleep(Duration::from_millis(60))); + assert!(started.elapsed() >= Duration::from_millis(60)); +} + +#[test] +fn timeout_passes_a_fast_future_through() { + let out = block_on(timeout(Duration::from_secs(5), async { + sleep(Duration::from_millis(5)).await; + "done" + })); + assert_eq!(out, Ok("done")); +} + +#[test] +fn timeout_fires_on_a_slow_future() { + let started = Instant::now(); + let out = block_on(timeout( + Duration::from_millis(50), + sleep(Duration::from_secs(30)), + )); + assert!(out.is_err()); + assert!(started.elapsed() < Duration::from_secs(5)); +} + +#[test] +fn nested_block_on_gets_its_own_loop_and_restores_the_outer_one() { + let out = block_on(async { + sleep(Duration::from_millis(5)).await; + let inner = block_on(async { + sleep(Duration::from_millis(5)).await; + 1 + }); + // The outer loop is current again: its leaves still work. + sleep(Duration::from_millis(5)).await; + inner + 1 + }); + assert_eq!(out, 2); +} + +#[test] +fn a_waker_fired_from_another_thread_wakes_the_loop() { + let lock = std::sync::Arc::new(Mutex::new(0u32)); + let held = block_on(lock.lock_arc()); + let releaser = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + drop(held); + }); + let started = Instant::now(); + // Nothing on this loop can complete by itself: only the other thread's + // unlock (an async-lock waker) can end this wait. + let value = block_on(async { *lock.lock().await + 1 }); + releaser.join().unwrap(); + assert_eq!(value, 1); + assert!(started.elapsed() < Duration::from_secs(5)); +} + +#[test] +#[should_panic(expected = "outside rt::block_on")] +fn a_leaf_polled_outside_block_on_panics() { + let mut fut = std::pin::pin!(sleep(Duration::from_secs(1))); + let waker = Waker::noop(); + let _ = fut.as_mut().poll(&mut Context::from_waker(waker)); +} + +#[cfg(unix)] +mod unix { + use super::*; + + #[test] + fn output_captures_both_streams_and_the_exit_code() { + let out = block_on( + Command::new("/bin/sh") + .args(["-c", "printf out; printf err 1>&2; exit 3"]) + .output(), + ) + .unwrap(); + assert_eq!(out.stdout, b"out"); + assert_eq!(out.stderr, b"err"); + assert_eq!(out.status.code(), Some(3)); + assert!(!out.status.success()); + assert_eq!(out.status.to_string(), "exit status: 3"); + } + + #[test] + fn output_reads_large_streams_to_eof() { + // Larger than any pipe buffer on both streams at once: a reader that + // only drained one pipe, or stopped at exit, would deadlock or truncate. + let out = block_on( + Command::new("/bin/sh") + .args([ + "-c", + "head -c 300000 /dev/zero; head -c 200000 /dev/zero 1>&2", + ]) + .output(), + ) + .unwrap(); + assert!(out.status.success()); + assert_eq!(out.stdout.len(), 300_000); + assert_eq!(out.stderr.len(), 200_000); + } + + #[test] + fn stdin_is_null_for_output() { + let out = block_on(Command::new("/bin/cat").output()).unwrap(); + assert!(out.status.success()); + assert!(out.stdout.is_empty()); + } + + #[test] + fn a_missing_program_is_a_spawn_error_or_exit_127() { + match block_on(Command::new("perry-rt-no-such-program-xyz").output()) { + Err(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound, "{e}"), + Ok(out) => assert_eq!(out.status.code(), Some(127)), + } + } + + #[test] + fn status_reports_the_exit_code() { + let status = block_on(Command::new("/bin/sh").args(["-c", "exit 0"]).status()).unwrap(); + assert!(status.success()); + } + + #[test] + fn a_timed_out_child_is_terminated_not_orphaned() { + let dir = std::env::temp_dir().join(format!("perry-rt-kill-{}", std::process::id())); + let _ = std::fs::remove_file(&dir); + // Writes its pid, then would sleep for a minute. + let script = format!("echo $$ > '{}'; exec sleep 60", dir.display()); + let started = Instant::now(); + let out = block_on(timeout( + Duration::from_millis(300), + Command::new("/bin/sh").args(["-c", &script]).output(), + )); + assert!(out.is_err(), "the timeout must fire"); + assert!(started.elapsed() < Duration::from_secs(10)); + let pid: i32 = std::fs::read_to_string(&dir) + .expect("child wrote its pid") + .trim() + .parse() + .unwrap(); + let _ = std::fs::remove_file(&dir); + // The loop has been dropped, so the child must be gone. A zombie + // (`Z`) counts as gone: it is no longer running. + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let stat = std::process::Command::new("ps") + .args(["-o", "stat=", "-p", &pid.to_string()]) + .output() + .unwrap(); + let stat = String::from_utf8_lossy(&stat.stdout).trim().to_string(); + let alive = !stat.is_empty() && !stat.starts_with('Z'); + if !alive { + break; + } + assert!( + Instant::now() < deadline, + "child {pid} survived its timeout" + ); + std::thread::sleep(Duration::from_millis(50)); + } + } +} diff --git a/crates/perry-container-compose/src/rt/time.rs b/crates/perry-container-compose/src/rt/time.rs new file mode 100644 index 0000000000..4b3f667316 --- /dev/null +++ b/crates/perry-container-compose/src/rt/time.rs @@ -0,0 +1,155 @@ +//! Timers: [`sleep`] and [`timeout`] on turnloop one-shot timers. + +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use turnloop::Token; + +use super::{try_with_reactor, with_current, with_reactor, Event}; + +/// Wait until `duration` has elapsed. +pub fn sleep(duration: Duration) -> Sleep { + Sleep { + deadline: Instant::now() + duration, + timer: None, + fired: false, + } +} + +/// A turnloop timer registered on first poll. +struct Registration { + reactor: u64, + token: u64, + handle: turnloop::Handle, +} + +/// Future returned by [`sleep`]. +#[must_use = "futures do nothing unless awaited"] +pub struct Sleep { + deadline: Instant, + timer: Option, + fired: bool, +} + +impl Sleep { + fn release(&mut self) { + if let Some(timer) = self.timer.take() { + try_with_reactor(timer.reactor, |reactor| { + reactor.forget(timer.token); + reactor.close(timer.handle); + }); + } + } +} + +impl Future for Sleep { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + let this = self.get_mut(); + if this.fired { + return Poll::Ready(()); + } + let fired = match &this.timer { + None => { + let remaining = this.deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + true + } else { + let timer = with_current(|reactor| { + let token = reactor.register(); + let at = reactor.driver.now() + remaining; + match reactor.driver.timer(at, None, Token(token)) { + Ok(handle) => { + // Arm the waker before the first turn can fire it. + let _ = reactor.take_event(token, cx); + Ok(Registration { + reactor: reactor.id(), + token, + handle, + }) + } + Err(e) => { + reactor.forget(token); + Err(e) + } + } + }); + match timer { + Ok(timer) => { + this.timer = Some(timer); + false + } + Err(e) => panic!("perry_container_compose::rt::sleep: timer: {e}"), + } + } + } + Some(timer) => with_reactor(timer.reactor, |reactor| { + matches!( + reactor.take_event(timer.token, cx), + Some(Event::Timer | Event::Failed(_)) + ) + }), + }; + if fired { + this.fired = true; + this.release(); + Poll::Ready(()) + } else { + Poll::Pending + } + } +} + +impl Drop for Sleep { + fn drop(&mut self) { + self.release(); + } +} + +/// Error returned by [`timeout`] when the deadline passes first. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Elapsed(()); + +impl fmt::Display for Elapsed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("deadline has elapsed") + } +} + +impl std::error::Error for Elapsed {} + +/// Require `future` to finish within `duration`. The future is polled first, +/// so one that is already ready wins even at a zero duration; on expiry it is +/// dropped, which for [`super::Command`] terminates the child. +pub fn timeout(duration: Duration, future: F) -> Timeout { + Timeout { + future: Box::pin(future), + sleep: sleep(duration), + } +} + +/// Future returned by [`timeout`]. +#[must_use = "futures do nothing unless awaited"] +pub struct Timeout { + future: Pin>, + sleep: Sleep, +} + +impl Future for Timeout { + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + if let Poll::Ready(output) = this.future.as_mut().poll(cx) { + return Poll::Ready(Ok(output)); + } + match Pin::new(&mut this.sleep).poll(cx) { + Poll::Ready(()) => Poll::Ready(Err(Elapsed(()))), + Poll::Pending => Poll::Pending, + } + } +} diff --git a/crates/perry-container-compose/src/workload.rs b/crates/perry-container-compose/src/workload.rs index 57aadec2a5..0d84270467 100644 --- a/crates/perry-container-compose/src/workload.rs +++ b/crates/perry-container-compose/src/workload.rs @@ -2,6 +2,7 @@ use crate::backend::ContainerBackend; use crate::error::{ComposeError, Result}; +use crate::rt::Mutex; use crate::types::{ContainerInfo, ContainerLogs, ContainerSpec}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -9,7 +10,6 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::sync::LazyLock as Lazy; -use tokio::sync::Mutex; // ============ Types ============ diff --git a/crates/perry-container-compose/tests/backend_tests.rs b/crates/perry-container-compose/tests/backend_tests.rs index 4d1671ebbb..088ea5837d 100644 --- a/crates/perry-container-compose/tests/backend_tests.rs +++ b/crates/perry-container-compose/tests/backend_tests.rs @@ -20,15 +20,18 @@ fn test_docker_protocol_run_args() { } // Feature: perry-container | Layer: unit | Req: 16.1 | Property: - -#[tokio::test] -async fn test_detect_backend_env_override() { - std::env::set_var("PERRY_CONTAINER_BACKEND", "docker"); - let result = detect_backend().await; - // This might still fail if docker isn't installed, but it should try ONLY docker - if let Err(perry_container_compose::error::ComposeError::NoBackendFound { probed }) = result { - assert_eq!(probed.len(), 1); - assert_eq!(probed[0].name, "docker"); - } +#[test] +fn test_detect_backend_env_override() { + perry_container_compose::rt::block_on(async { + std::env::set_var("PERRY_CONTAINER_BACKEND", "docker"); + let result = detect_backend().await; + // This might still fail if docker isn't installed, but it should try ONLY docker + if let Err(perry_container_compose::error::ComposeError::NoBackendFound { probed }) = result + { + assert_eq!(probed.len(), 1); + assert_eq!(probed[0].name, "docker"); + } + }) } // Coverage Table: diff --git a/crates/perry-container-compose/tests/container_ops.rs b/crates/perry-container-compose/tests/container_ops.rs index b6dbd94de5..6d435a6bab 100644 --- a/crates/perry-container-compose/tests/container_ops.rs +++ b/crates/perry-container-compose/tests/container_ops.rs @@ -5,83 +5,91 @@ use std::sync::Arc; mod common; use common::MockBackend; -#[tokio::test] -async fn test_container_run_success() { - let mock = MockBackend::default(); - let state_ref = Arc::clone(&mock.state); - let backend: Arc = Arc::new(mock); - let spec = ContainerSpec { - image: "alpine".into(), - name: Some("test-container".into()), - ..Default::default() - }; +#[test] +fn test_container_run_success() { + perry_container_compose::rt::block_on(async { + let mock = MockBackend::default(); + let state_ref = Arc::clone(&mock.state); + let backend: Arc = Arc::new(mock); + let spec = ContainerSpec { + image: "alpine".into(), + name: Some("test-container".into()), + ..Default::default() + }; - let handle = backend.run(&spec).await.expect("run failed"); - assert_eq!(handle.id, "test-container"); + let handle = backend.run(&spec).await.expect("run failed"); + assert_eq!(handle.id, "test-container"); - let state = state_ref.lock().unwrap(); - assert!(state.containers.contains_key("test-container")); - assert_eq!(state.actions, vec!["run:test-container"]); + let state = state_ref.lock().unwrap(); + assert!(state.containers.contains_key("test-container")); + assert_eq!(state.actions, vec!["run:test-container"]); + }) } -#[tokio::test] -async fn test_container_lifecycle() { - let mock = MockBackend::default(); - let state_ref = Arc::clone(&mock.state); - let backend: Arc = Arc::new(mock); - let spec = ContainerSpec { - image: "nginx".into(), - name: Some("web".into()), - ..Default::default() - }; +#[test] +fn test_container_lifecycle() { + perry_container_compose::rt::block_on(async { + let mock = MockBackend::default(); + let state_ref = Arc::clone(&mock.state); + let backend: Arc = Arc::new(mock); + let spec = ContainerSpec { + image: "nginx".into(), + name: Some("web".into()), + ..Default::default() + }; - backend.run(&spec).await.unwrap(); - backend.stop("web", Some(10)).await.unwrap(); - backend.remove("web", true).await.unwrap(); + backend.run(&spec).await.unwrap(); + backend.stop("web", Some(10)).await.unwrap(); + backend.remove("web", true).await.unwrap(); - let state = state_ref.lock().unwrap(); - assert!(state.containers.is_empty()); - assert_eq!(state.actions, vec!["run:web", "stop:web", "remove:web"]); + let state = state_ref.lock().unwrap(); + assert!(state.containers.is_empty()); + assert_eq!(state.actions, vec!["run:web", "stop:web", "remove:web"]); + }) } -#[tokio::test] -async fn test_container_exec() { - let backend: Arc = Arc::new(MockBackend::default()); - let logs = backend - .exec("web", &["ls".into()], None, None) - .await - .unwrap(); - assert_eq!(logs.stdout, "exec"); +#[test] +fn test_container_exec() { + perry_container_compose::rt::block_on(async { + let backend: Arc = Arc::new(MockBackend::default()); + let logs = backend + .exec("web", &["ls".into()], None, None) + .await + .unwrap(); + assert_eq!(logs.stdout, "exec"); + }) } -#[tokio::test] -async fn test_network_volume_lifecycle() { - let mock = MockBackend::default(); - let state_ref = Arc::clone(&mock.state); - let backend: Arc = Arc::new(mock); - use perry_container_compose::types::{ComposeNetwork, ComposeVolume}; +#[test] +fn test_network_volume_lifecycle() { + perry_container_compose::rt::block_on(async { + let mock = MockBackend::default(); + let state_ref = Arc::clone(&mock.state); + let backend: Arc = Arc::new(mock); + use perry_container_compose::types::{ComposeNetwork, ComposeVolume}; - backend - .create_network("test-net", &ComposeNetwork::default()) - .await - .unwrap(); - backend - .create_volume("test-vol", &ComposeVolume::default()) - .await - .unwrap(); + backend + .create_network("test-net", &ComposeNetwork::default()) + .await + .unwrap(); + backend + .create_volume("test-vol", &ComposeVolume::default()) + .await + .unwrap(); - { - let state = state_ref.lock().unwrap(); - assert_eq!(state.networks, vec!["test-net"]); - assert_eq!(state.volumes, vec!["test-vol"]); - } + { + let state = state_ref.lock().unwrap(); + assert_eq!(state.networks, vec!["test-net"]); + assert_eq!(state.volumes, vec!["test-vol"]); + } - backend.remove_network("test-net").await.unwrap(); - backend.remove_volume("test-vol").await.unwrap(); + backend.remove_network("test-net").await.unwrap(); + backend.remove_volume("test-vol").await.unwrap(); - { - let state = state_ref.lock().unwrap(); - assert!(state.networks.is_empty()); - assert!(state.volumes.is_empty()); - } + { + let state = state_ref.lock().unwrap(); + assert!(state.networks.is_empty()); + assert!(state.volumes.is_empty()); + } + }) } diff --git a/crates/perry-container-compose/tests/exec_raw_timeout.rs b/crates/perry-container-compose/tests/exec_raw_timeout.rs index 37b13d137b..6e1455d2fb 100644 --- a/crates/perry-container-compose/tests/exec_raw_timeout.rs +++ b/crates/perry-container-compose/tests/exec_raw_timeout.rs @@ -1,6 +1,6 @@ //! Tests for the v0.5.380 `exec_raw` timeout — pre-fix every CLI call //! could hang forever if the daemon was wedged. Pinning the timeout -//! behavior so a future refactor that strips the `tokio::time::timeout` +//! behavior so a future refactor that strips the `rt::timeout` //! wrapper trips a CI failure. //! //! These tests use a real binary (`/bin/sleep` on Unix) rather than a @@ -128,84 +128,88 @@ impl CliProtocol for PassthroughProtocol { // and break timing assumptions. Consolidate into one sequential test // rather than depend on a serial-test crate (avoids the dep + the // per-test setup cost of `serial_test::serial` macro overhead). -#[tokio::test] -async fn exec_raw_timeout_behavior() { - let bin = PathBuf::from("/bin/sleep"); - if !bin.exists() { - eprintln!("skip: /bin/sleep not present on this runner"); - return; - } +#[test] +fn exec_raw_timeout_behavior() { + perry_container_compose::rt::block_on(async { + let bin = PathBuf::from("/bin/sleep"); + if !bin.exists() { + eprintln!("skip: /bin/sleep not present on this runner"); + return; + } - // Phase 1: timeout fires when command hangs. - std::env::set_var("PERRY_CONTAINER_OP_TIMEOUT_SECS", "1"); - let proto = PassthroughProtocol { - args: vec!["30".into()], - }; - let backend = CliBackend::new(bin.clone(), Box::new(proto)); - let started = std::time::Instant::now(); - let result = backend.pull_image("ignored").await; - let elapsed = started.elapsed(); - assert!(result.is_err(), "expected timeout error in {:?}", elapsed); - assert!( - elapsed < std::time::Duration::from_secs(4), - "timeout did not fire promptly — took {:?}", - elapsed - ); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("hung") - || err_msg.contains("timeout") - || err_msg.contains("PERRY_CONTAINER_OP_TIMEOUT_SECS"), - "timeout error message should explain the timeout + the env var; got: {}", - err_msg - ); + // Phase 1: timeout fires when command hangs. + std::env::set_var("PERRY_CONTAINER_OP_TIMEOUT_SECS", "1"); + let proto = PassthroughProtocol { + args: vec!["30".into()], + }; + let backend = CliBackend::new(bin.clone(), Box::new(proto)); + let started = std::time::Instant::now(); + let result = backend.pull_image("ignored").await; + let elapsed = started.elapsed(); + assert!(result.is_err(), "expected timeout error in {:?}", elapsed); + assert!( + elapsed < std::time::Duration::from_secs(4), + "timeout did not fire promptly — took {:?}", + elapsed + ); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("hung") + || err_msg.contains("timeout") + || err_msg.contains("PERRY_CONTAINER_OP_TIMEOUT_SECS"), + "timeout error message should explain the timeout + the env var; got: {}", + err_msg + ); - // Phase 2: timeout does NOT fire for fast commands. - std::env::set_var("PERRY_CONTAINER_OP_TIMEOUT_SECS", "10"); - let proto = PassthroughProtocol { - args: vec!["0".into()], - }; - let backend = CliBackend::new(bin, Box::new(proto)); - let result = backend.pull_image("ignored").await; - assert!( - result.is_ok(), - "fast command must succeed within timeout; got {:?}", - result - ); + // Phase 2: timeout does NOT fire for fast commands. + std::env::set_var("PERRY_CONTAINER_OP_TIMEOUT_SECS", "10"); + let proto = PassthroughProtocol { + args: vec!["0".into()], + }; + let backend = CliBackend::new(bin, Box::new(proto)); + let result = backend.pull_image("ignored").await; + assert!( + result.is_ok(), + "fast command must succeed within timeout; got {:?}", + result + ); - std::env::remove_var("PERRY_CONTAINER_OP_TIMEOUT_SECS"); + std::env::remove_var("PERRY_CONTAINER_OP_TIMEOUT_SECS"); + }) } -#[tokio::test] -async fn exec_raw_truncates_long_stderr_in_error_message() { - // Pre-fix a multi-MB image-pull failure log ended up verbatim in - // Error.message. Now `exec_raw` truncates at 4 KiB. Generate a - // long-stderr failure via /usr/bin/yes (writes "y\n" forever) + - // exit nonzero (use a pipefail trick via /bin/sh -c). - // - // We use /bin/sh -c "yes Y | head -c 100000 1>&2; exit 1" - // → produces 100 KB of stderr then exits 1. The error message - // should contain "[truncated, ...]". - let bin = PathBuf::from("/bin/sh"); - if !bin.exists() { - return; - } - let proto = PassthroughProtocol { - args: vec!["-c".into(), "yes Y | head -c 100000 1>&2; exit 1".into()], - }; - let backend = CliBackend::new(bin, Box::new(proto)); - let result = backend.pull_image("ignored").await; - assert!(result.is_err()); - let msg = format!("{}", result.unwrap_err()); - assert!( - msg.contains("[truncated"), - "long stderr must be truncated in error message; got msg of len {}", - msg.len() - ); - // Sanity: total error message must be much shorter than 100 KB. - assert!( - msg.len() < 10_000, - "truncation didn't actually shorten the message; len={}", - msg.len() - ); +#[test] +fn exec_raw_truncates_long_stderr_in_error_message() { + perry_container_compose::rt::block_on(async { + // Pre-fix a multi-MB image-pull failure log ended up verbatim in + // Error.message. Now `exec_raw` truncates at 4 KiB. Generate a + // long-stderr failure via /usr/bin/yes (writes "y\n" forever) + + // exit nonzero (use a pipefail trick via /bin/sh -c). + // + // We use /bin/sh -c "yes Y | head -c 100000 1>&2; exit 1" + // → produces 100 KB of stderr then exits 1. The error message + // should contain "[truncated, ...]". + let bin = PathBuf::from("/bin/sh"); + if !bin.exists() { + return; + } + let proto = PassthroughProtocol { + args: vec!["-c".into(), "yes Y | head -c 100000 1>&2; exit 1".into()], + }; + let backend = CliBackend::new(bin, Box::new(proto)); + let result = backend.pull_image("ignored").await; + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("[truncated"), + "long stderr must be truncated in error message; got msg of len {}", + msg.len() + ); + // Sanity: total error message must be much shorter than 100 KB. + assert!( + msg.len() < 10_000, + "truncation didn't actually shorten the message; len={}", + msg.len() + ); + }) } diff --git a/crates/perry-container-compose/tests/functional_orchestration.rs b/crates/perry-container-compose/tests/functional_orchestration.rs index 713639b5de..f6cbd77c34 100644 --- a/crates/perry-container-compose/tests/functional_orchestration.rs +++ b/crates/perry-container-compose/tests/functional_orchestration.rs @@ -95,576 +95,606 @@ fn engine(spec: ComposeSpec, project: &str, mock: Arc) -> Arc = calls - .iter() - .filter_map(|c| match c { - RecordedCall::RemoveNetwork(n) => Some(n), - _ => None, - }) - .collect(); - assert!( - !removed_networks.is_empty(), - "rollback must remove session-created networks; got calls: {:?}", - calls - ); - // The runtime name is project-namespaced — `proj_appnet`. - assert!( - removed_networks.iter().any(|n| n.as_str() == "proj_appnet"), - "expected to remove `proj_appnet`; got removed: {:?}", - removed_networks - ); +#[test] +fn rollback_removes_session_networks_and_containers_on_partial_failure() { + perry_container_compose::rt::block_on(async { + // Two-service stack where the second `run` is scripted to fail. + // Verify rollback removes the first container AND the network we + // created for the stack — both ordered. + let mock = Arc::new(MockBackend::new()); + mock.set_inspect_not_found().await; // every container is fresh + mock.script_run_failure_after(1).await; // second run() returns Err + + let spec = spec_with_networks( + &[("svc1", svc_with_net("alpine", "appnet"))], + // intentionally minimal so service `svc1` is the only one; + // test the "single-service rollback" path which is the simplest + // version of the partial-failure invariant. + &[("appnet", Some(ComposeNetwork::default()))], + ); + let eng = engine(spec, "proj", mock.clone()); + + // up() with an only-service whose run fails → rollback should + // remove the network we created. + let result = eng.clone().up(&[], false, false, false).await; + assert!(result.is_err(), "up should fail when run fails"); + + let calls = mock.calls().await; + let removed_networks: Vec<&String> = calls + .iter() + .filter_map(|c| match c { + RecordedCall::RemoveNetwork(n) => Some(n), + _ => None, + }) + .collect(); + assert!( + !removed_networks.is_empty(), + "rollback must remove session-created networks; got calls: {:?}", + calls + ); + // The runtime name is project-namespaced — `proj_appnet`. + assert!( + removed_networks.iter().any(|n| n.as_str() == "proj_appnet"), + "expected to remove `proj_appnet`; got removed: {:?}", + removed_networks + ); + }) } // ────────────────────────────────────────────────────────────────────── // A.5: Project namespacing — Tier 1.1 // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn volumes_are_project_namespaced_on_create() { - let mock = Arc::new(MockBackend::new()); - mock.set_inspect_not_found().await; // volumes don't exist yet - let spec = spec_with_volumes( - &[("web", svc_with_vol("nginx", "appdata:/var/www"))], - &[("appdata", Some(ComposeVolume::default()))], - ); - let eng = engine(spec, "myapp", mock.clone()); - let _ = eng.clone().up(&[], false, false, false).await; - - let calls = mock.calls().await; - let created_vols: Vec<&String> = calls - .iter() - .filter_map(|c| match c { - RecordedCall::CreateVolume(n) => Some(n), - _ => None, - }) - .collect(); - assert!( - created_vols.iter().any(|n| n.as_str() == "myapp_appdata"), - "volumes must be project-namespaced; got: {:?}", - created_vols - ); - assert!( - !created_vols.iter().any(|n| n.as_str() == "appdata"), - "raw volume name must NOT appear (would collide across stacks): {:?}", - created_vols - ); +#[test] +fn volumes_are_project_namespaced_on_create() { + perry_container_compose::rt::block_on(async { + let mock = Arc::new(MockBackend::new()); + mock.set_inspect_not_found().await; // volumes don't exist yet + let spec = spec_with_volumes( + &[("web", svc_with_vol("nginx", "appdata:/var/www"))], + &[("appdata", Some(ComposeVolume::default()))], + ); + let eng = engine(spec, "myapp", mock.clone()); + let _ = eng.clone().up(&[], false, false, false).await; + + let calls = mock.calls().await; + let created_vols: Vec<&String> = calls + .iter() + .filter_map(|c| match c { + RecordedCall::CreateVolume(n) => Some(n), + _ => None, + }) + .collect(); + assert!( + created_vols.iter().any(|n| n.as_str() == "myapp_appdata"), + "volumes must be project-namespaced; got: {:?}", + created_vols + ); + assert!( + !created_vols.iter().any(|n| n.as_str() == "appdata"), + "raw volume name must NOT appear (would collide across stacks): {:?}", + created_vols + ); + }) } -#[tokio::test] -async fn networks_are_project_namespaced_on_create() { - let mock = Arc::new(MockBackend::new()); - mock.set_inspect_not_found().await; - let spec = spec_with_networks( - &[("web", svc_with_net("nginx", "appnet"))], - &[("appnet", Some(ComposeNetwork::default()))], - ); - let eng = engine(spec, "myapp", mock.clone()); - let _ = eng.clone().up(&[], false, false, false).await; - - let calls = mock.calls().await; - let created_nets: Vec<&String> = calls - .iter() - .filter_map(|c| match c { - RecordedCall::CreateNetwork(n) => Some(n), - _ => None, - }) - .collect(); - assert!( - created_nets.iter().any(|n| n.as_str() == "myapp_appnet"), - "networks must be project-namespaced; got: {:?}", - created_nets - ); +#[test] +fn networks_are_project_namespaced_on_create() { + perry_container_compose::rt::block_on(async { + let mock = Arc::new(MockBackend::new()); + mock.set_inspect_not_found().await; + let spec = spec_with_networks( + &[("web", svc_with_net("nginx", "appnet"))], + &[("appnet", Some(ComposeNetwork::default()))], + ); + let eng = engine(spec, "myapp", mock.clone()); + let _ = eng.clone().up(&[], false, false, false).await; + + let calls = mock.calls().await; + let created_nets: Vec<&String> = calls + .iter() + .filter_map(|c| match c { + RecordedCall::CreateNetwork(n) => Some(n), + _ => None, + }) + .collect(); + assert!( + created_nets.iter().any(|n| n.as_str() == "myapp_appnet"), + "networks must be project-namespaced; got: {:?}", + created_nets + ); + }) } -#[tokio::test] -async fn two_stacks_with_same_volume_key_dont_collide() { - // Both stacks declare a volume named "data" — with namespacing, - // they resolve to "stack1_data" and "stack2_data" respectively. - let mock1 = Arc::new(MockBackend::new()); - mock1.set_inspect_not_found().await; - let s1 = spec_with_volumes( - &[("web", svc_with_vol("alpine", "data:/data"))], - &[("data", Some(ComposeVolume::default()))], - ); - let _ = engine(s1, "stack1", mock1.clone()) - .clone() - .up(&[], false, false, false) - .await; - let v1: Vec = mock1 - .calls() - .await - .into_iter() - .filter_map(|c| match c { - RecordedCall::CreateVolume(n) => Some(n), - _ => None, - }) - .collect(); - - let mock2 = Arc::new(MockBackend::new()); - mock2.set_inspect_not_found().await; - let s2 = spec_with_volumes( - &[("web", svc_with_vol("alpine", "data:/data"))], - &[("data", Some(ComposeVolume::default()))], - ); - let _ = engine(s2, "stack2", mock2.clone()) - .clone() - .up(&[], false, false, false) - .await; - let v2: Vec = mock2 - .calls() - .await - .into_iter() - .filter_map(|c| match c { - RecordedCall::CreateVolume(n) => Some(n), - _ => None, - }) - .collect(); - - assert!(v1.iter().any(|n| n == "stack1_data")); - assert!(v2.iter().any(|n| n == "stack2_data")); - assert_ne!( - v1, v2, - "two stacks declaring `data` must produce distinct namespaced names" - ); +#[test] +fn two_stacks_with_same_volume_key_dont_collide() { + perry_container_compose::rt::block_on(async { + // Both stacks declare a volume named "data" — with namespacing, + // they resolve to "stack1_data" and "stack2_data" respectively. + let mock1 = Arc::new(MockBackend::new()); + mock1.set_inspect_not_found().await; + let s1 = spec_with_volumes( + &[("web", svc_with_vol("alpine", "data:/data"))], + &[("data", Some(ComposeVolume::default()))], + ); + let _ = engine(s1, "stack1", mock1.clone()) + .clone() + .up(&[], false, false, false) + .await; + let v1: Vec = mock1 + .calls() + .await + .into_iter() + .filter_map(|c| match c { + RecordedCall::CreateVolume(n) => Some(n), + _ => None, + }) + .collect(); + + let mock2 = Arc::new(MockBackend::new()); + mock2.set_inspect_not_found().await; + let s2 = spec_with_volumes( + &[("web", svc_with_vol("alpine", "data:/data"))], + &[("data", Some(ComposeVolume::default()))], + ); + let _ = engine(s2, "stack2", mock2.clone()) + .clone() + .up(&[], false, false, false) + .await; + let v2: Vec = mock2 + .calls() + .await + .into_iter() + .filter_map(|c| match c { + RecordedCall::CreateVolume(n) => Some(n), + _ => None, + }) + .collect(); + + assert!(v1.iter().any(|n| n == "stack1_data")); + assert!(v2.iter().any(|n| n == "stack2_data")); + assert_ne!( + v1, v2, + "two stacks declaring `data` must produce distinct namespaced names" + ); + }) } // ────────────────────────────────────────────────────────────────────── // A.6: external: true respect — Tier 1.2 // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn external_volumes_skipped_on_create() { - let mock = Arc::new(MockBackend::new()); - mock.set_inspect_not_found().await; - let ext_vol = ComposeVolume { - external: Some(true), - ..Default::default() - }; - let spec = spec_with_volumes( - &[("web", svc_with_vol("alpine", "shared-cache:/cache"))], - &[("shared-cache", Some(ext_vol))], - ); - let _ = engine(spec, "myapp", mock.clone()) - .clone() - .up(&[], false, false, false) - .await; +#[test] +fn external_volumes_skipped_on_create() { + perry_container_compose::rt::block_on(async { + let mock = Arc::new(MockBackend::new()); + mock.set_inspect_not_found().await; + let ext_vol = ComposeVolume { + external: Some(true), + ..Default::default() + }; + let spec = spec_with_volumes( + &[("web", svc_with_vol("alpine", "shared-cache:/cache"))], + &[("shared-cache", Some(ext_vol))], + ); + let _ = engine(spec, "myapp", mock.clone()) + .clone() + .up(&[], false, false, false) + .await; - let calls = mock.calls().await; - assert!( + let calls = mock.calls().await; + assert!( !calls .iter() .any(|c| matches!(c, RecordedCall::CreateVolume(n) if n == "myapp_shared-cache" || n == "shared-cache")), "external volume must not be created by us; got: {:?}", calls ); + }) } -#[tokio::test] -async fn external_networks_not_removed_by_down() { - // External network exists at up-time (mock returns Running for - // inspect), so engine doesn't add it to session_networks. On - // down(), it must NOT be removed. - let mock = Arc::new(MockBackend::new()); - let ext_net = ComposeNetwork { - external: Some(true), - ..Default::default() - }; - let s = spec_with_networks( - &[("web", svc_with_net("alpine", "shared-net"))], - &[("shared-net", Some(ext_net))], - ); - let eng = engine(s, "myapp", mock.clone()); - let _ = eng.clone().up(&[], false, false, false).await; - // Now down — should NOT remove "shared-net" or "myapp_shared-net". - let _ = eng.down(&[], false, false).await; +#[test] +fn external_networks_not_removed_by_down() { + perry_container_compose::rt::block_on(async { + // External network exists at up-time (mock returns Running for + // inspect), so engine doesn't add it to session_networks. On + // down(), it must NOT be removed. + let mock = Arc::new(MockBackend::new()); + let ext_net = ComposeNetwork { + external: Some(true), + ..Default::default() + }; + let s = spec_with_networks( + &[("web", svc_with_net("alpine", "shared-net"))], + &[("shared-net", Some(ext_net))], + ); + let eng = engine(s, "myapp", mock.clone()); + let _ = eng.clone().up(&[], false, false, false).await; + // Now down — should NOT remove "shared-net" or "myapp_shared-net". + let _ = eng.down(&[], false, false).await; - let calls = mock.calls().await; - assert!( - !calls - .iter() - .any(|c| matches!(c, RecordedCall::RemoveNetwork(n) if n.contains("shared-net"))), - "external network must NEVER be removed; got calls: {:?}", - calls - ); + let calls = mock.calls().await; + assert!( + !calls + .iter() + .any(|c| matches!(c, RecordedCall::RemoveNetwork(n) if n.contains("shared-net"))), + "external network must NEVER be removed; got calls: {:?}", + calls + ); + }) } -#[tokio::test] -async fn external_volumes_not_removed_when_volumes_true() { - let mock = Arc::new(MockBackend::new()); - let ext_vol = ComposeVolume { - external: Some(true), - ..Default::default() - }; - let s = spec_with_volumes( - &[("web", svc_with_vol("alpine", "team-cache:/cache"))], - &[("team-cache", Some(ext_vol))], - ); - let eng = engine(s, "myapp", mock.clone()); - let _ = eng.clone().up(&[], false, false, false).await; - // Down with volumes: true — even then, external must survive. - let _ = eng.down(&[], false, /* remove_volumes */ true).await; +#[test] +fn external_volumes_not_removed_when_volumes_true() { + perry_container_compose::rt::block_on(async { + let mock = Arc::new(MockBackend::new()); + let ext_vol = ComposeVolume { + external: Some(true), + ..Default::default() + }; + let s = spec_with_volumes( + &[("web", svc_with_vol("alpine", "team-cache:/cache"))], + &[("team-cache", Some(ext_vol))], + ); + let eng = engine(s, "myapp", mock.clone()); + let _ = eng.clone().up(&[], false, false, false).await; + // Down with volumes: true — even then, external must survive. + let _ = eng.down(&[], false, /* remove_volumes */ true).await; - let calls = mock.calls().await; - assert!( - !calls - .iter() - .any(|c| matches!(c, RecordedCall::RemoveVolume(n) if n.contains("team-cache"))), - "external volume must NEVER be removed even with volumes=true; got: {:?}", - calls - ); + let calls = mock.calls().await; + assert!( + !calls + .iter() + .any(|c| matches!(c, RecordedCall::RemoveVolume(n) if n.contains("team-cache"))), + "external volume must NEVER be removed even with volumes=true; got: {:?}", + calls + ); + }) } // ────────────────────────────────────────────────────────────────────── // A.7: Container-name caching — Tier 1's bug A5 fix // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn exec_targets_the_same_container_name_that_up_created() { - // Pre-fix: service::service_container_name() regenerated a fresh - // random suffix on every call, so post-up exec/logs/down looked - // for a different container name than what was created. - let mock = Arc::new(MockBackend::new()); - mock.set_inspect_not_found().await; // creates fresh on up() - let spec = spec(&[("web", svc("nginx"))]); - let eng = engine(spec, "myapp", mock.clone()); - let _ = eng.clone().up(&[], false, false, false).await; - - // Capture the name we actually `Run`'d. - let calls = mock.calls().await; - let run_name = calls - .iter() - .find_map(|c| match c { - RecordedCall::Run(spec) => spec.name.clone(), - _ => None, - }) - .expect("expected at least one Run call"); - - // Now exec — engine must target the SAME name. - let _ = eng - .exec("web", &["echo".into(), "hi".into()], None, None) - .await; - let calls2 = mock.calls().await; - let exec_target = calls2 - .iter() - .rev() - .find_map(|c| match c { - RecordedCall::Exec(name, _) => Some(name.clone()), - _ => None, - }) - .expect("expected an Exec call"); - assert_eq!( - exec_target, run_name, - "exec must target the same container name as run" - ); +#[test] +fn exec_targets_the_same_container_name_that_up_created() { + perry_container_compose::rt::block_on(async { + // Pre-fix: service::service_container_name() regenerated a fresh + // random suffix on every call, so post-up exec/logs/down looked + // for a different container name than what was created. + let mock = Arc::new(MockBackend::new()); + mock.set_inspect_not_found().await; // creates fresh on up() + let spec = spec(&[("web", svc("nginx"))]); + let eng = engine(spec, "myapp", mock.clone()); + let _ = eng.clone().up(&[], false, false, false).await; + + // Capture the name we actually `Run`'d. + let calls = mock.calls().await; + let run_name = calls + .iter() + .find_map(|c| match c { + RecordedCall::Run(spec) => spec.name.clone(), + _ => None, + }) + .expect("expected at least one Run call"); + + // Now exec — engine must target the SAME name. + let _ = eng + .exec("web", &["echo".into(), "hi".into()], None, None) + .await; + let calls2 = mock.calls().await; + let exec_target = calls2 + .iter() + .rev() + .find_map(|c| match c { + RecordedCall::Exec(name, _) => Some(name.clone()), + _ => None, + }) + .expect("expected an Exec call"); + assert_eq!( + exec_target, run_name, + "exec must target the same container name as run" + ); + }) } // ────────────────────────────────────────────────────────────────────── // A.8: Volume preservation across down() — Tier 1.2 + 1.4 + bug A8 // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn down_preserves_volumes_by_default() { - let mock = Arc::new(MockBackend::new()); - mock.set_inspect_not_found().await; - let spec = spec_with_volumes( - &[("db", svc_with_vol("postgres:16-alpine", "pgdata:/data"))], - &[("pgdata", Some(ComposeVolume::default()))], - ); - let eng = engine(spec, "myapp", mock.clone()); - let _ = eng.clone().up(&[], false, false, false).await; - // down with volumes=false (default for `compose down`) - let _ = eng.down(&[], false, /* remove_volumes */ false).await; +#[test] +fn down_preserves_volumes_by_default() { + perry_container_compose::rt::block_on(async { + let mock = Arc::new(MockBackend::new()); + mock.set_inspect_not_found().await; + let spec = spec_with_volumes( + &[("db", svc_with_vol("postgres:16-alpine", "pgdata:/data"))], + &[("pgdata", Some(ComposeVolume::default()))], + ); + let eng = engine(spec, "myapp", mock.clone()); + let _ = eng.clone().up(&[], false, false, false).await; + // down with volumes=false (default for `compose down`) + let _ = eng.down(&[], false, /* remove_volumes */ false).await; - let calls = mock.calls().await; - assert!( - !calls - .iter() - .any(|c| matches!(c, RecordedCall::RemoveVolume(_))), - "down(remove_volumes=false) must NOT remove volumes; got: {:?}", - calls - ); + let calls = mock.calls().await; + assert!( + !calls + .iter() + .any(|c| matches!(c, RecordedCall::RemoveVolume(_))), + "down(remove_volumes=false) must NOT remove volumes; got: {:?}", + calls + ); + }) } -#[tokio::test] -async fn down_with_volumes_true_removes_namespaced_volumes() { - let mock = Arc::new(MockBackend::new()); - mock.set_inspect_not_found().await; - let spec = spec_with_volumes( - &[("db", svc_with_vol("postgres", "pgdata:/data"))], - &[("pgdata", Some(ComposeVolume::default()))], - ); - let eng = engine(spec, "myapp", mock.clone()); - let _ = eng.clone().up(&[], false, false, false).await; - let _ = eng.down(&[], false, /* remove_volumes */ true).await; - - let calls = mock.calls().await; - let removed = calls - .iter() - .filter_map(|c| match c { - RecordedCall::RemoveVolume(n) => Some(n.as_str()), - _ => None, - }) - .collect::>(); - assert!( - removed.contains(&"myapp_pgdata"), - "expected myapp_pgdata removed; got: {:?}", - removed - ); +#[test] +fn down_with_volumes_true_removes_namespaced_volumes() { + perry_container_compose::rt::block_on(async { + let mock = Arc::new(MockBackend::new()); + mock.set_inspect_not_found().await; + let spec = spec_with_volumes( + &[("db", svc_with_vol("postgres", "pgdata:/data"))], + &[("pgdata", Some(ComposeVolume::default()))], + ); + let eng = engine(spec, "myapp", mock.clone()); + let _ = eng.clone().up(&[], false, false, false).await; + let _ = eng.down(&[], false, /* remove_volumes */ true).await; + + let calls = mock.calls().await; + let removed = calls + .iter() + .filter_map(|c| match c { + RecordedCall::RemoveVolume(n) => Some(n.as_str()), + _ => None, + }) + .collect::>(); + assert!( + removed.contains(&"myapp_pgdata"), + "expected myapp_pgdata removed; got: {:?}", + removed + ); + }) } // ────────────────────────────────────────────────────────────────────── // A.9: Idempotency-on-spec-change — Tier 2.7 // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn up_recreates_container_when_spec_hash_drifts() { - let mock = Arc::new(MockBackend::new()); - - // Phase 1: fresh up with image=postgres:15 - mock.set_inspect_not_found().await; - let s1 = spec(&[("db", svc("postgres:15"))]); - let _ = engine(s1, "myapp", mock.clone()) - .clone() - .up(&[], false, false, false) - .await; - - // Snapshot how many Run calls we've seen so far. - let runs_before: usize = mock - .calls() - .await - .iter() - .filter(|c| matches!(c, RecordedCall::Run(_))) - .count(); - assert_eq!(runs_before, 1, "phase 1 should produce exactly one Run"); - - // Phase 2: same project + service KEY but DIFFERENT image. Now the - // container is "running" so existing inspect succeeds, but the - // spec_hash label on it is the OLD one. Engine must recreate. - mock.set_inspect_running(true).await; - mock.set_existing_spec_hash_old().await; // mock returns the wrong hash - let s2 = spec(&[("db", svc("postgres:16-alpine"))]); // <- changed - let _ = engine(s2, "myapp", mock.clone()) - .clone() - .up(&[], false, false, false) - .await; - - // After phase 2 we expect a Stop + Remove (of the old container) + - // a fresh Run (of the new image). - let calls = mock.calls().await; - let later_runs = calls - .iter() - .filter(|c| matches!(c, RecordedCall::Run(_))) - .count(); - assert!( - later_runs >= 2, - "spec drift should trigger a fresh Run; total Runs: {}", - later_runs - ); - // Verify the Stop + Remove appeared between the two Runs. - let positions: Vec<_> = calls - .iter() - .enumerate() - .filter_map(|(i, c)| match c { - RecordedCall::Run(_) => Some(("run", i)), - RecordedCall::Stop(_, _) => Some(("stop", i)), - RecordedCall::Remove(_, _) => Some(("remove", i)), - _ => None, - }) - .collect(); - let stop_idx = positions - .iter() - .find(|(t, _)| *t == "stop") - .map(|(_, i)| *i); - let last_run_idx = positions - .iter() - .rev() - .find(|(t, _)| *t == "run") - .map(|(_, i)| *i); - if let (Some(s), Some(r)) = (stop_idx, last_run_idx) { +#[test] +fn up_recreates_container_when_spec_hash_drifts() { + perry_container_compose::rt::block_on(async { + let mock = Arc::new(MockBackend::new()); + + // Phase 1: fresh up with image=postgres:15 + mock.set_inspect_not_found().await; + let s1 = spec(&[("db", svc("postgres:15"))]); + let _ = engine(s1, "myapp", mock.clone()) + .clone() + .up(&[], false, false, false) + .await; + + // Snapshot how many Run calls we've seen so far. + let runs_before: usize = mock + .calls() + .await + .iter() + .filter(|c| matches!(c, RecordedCall::Run(_))) + .count(); + assert_eq!(runs_before, 1, "phase 1 should produce exactly one Run"); + + // Phase 2: same project + service KEY but DIFFERENT image. Now the + // container is "running" so existing inspect succeeds, but the + // spec_hash label on it is the OLD one. Engine must recreate. + mock.set_inspect_running(true).await; + mock.set_existing_spec_hash_old().await; // mock returns the wrong hash + let s2 = spec(&[("db", svc("postgres:16-alpine"))]); // <- changed + let _ = engine(s2, "myapp", mock.clone()) + .clone() + .up(&[], false, false, false) + .await; + + // After phase 2 we expect a Stop + Remove (of the old container) + + // a fresh Run (of the new image). + let calls = mock.calls().await; + let later_runs = calls + .iter() + .filter(|c| matches!(c, RecordedCall::Run(_))) + .count(); assert!( - s < r, - "Stop must precede the recreate-Run; got positions {:?}", - positions + later_runs >= 2, + "spec drift should trigger a fresh Run; total Runs: {}", + later_runs ); - } + // Verify the Stop + Remove appeared between the two Runs. + let positions: Vec<_> = calls + .iter() + .enumerate() + .filter_map(|(i, c)| match c { + RecordedCall::Run(_) => Some(("run", i)), + RecordedCall::Stop(_, _) => Some(("stop", i)), + RecordedCall::Remove(_, _) => Some(("remove", i)), + _ => None, + }) + .collect(); + let stop_idx = positions + .iter() + .find(|(t, _)| *t == "stop") + .map(|(_, i)| *i); + let last_run_idx = positions + .iter() + .rev() + .find(|(t, _)| *t == "run") + .map(|(_, i)| *i); + if let (Some(s), Some(r)) = (stop_idx, last_run_idx) { + assert!( + s < r, + "Stop must precede the recreate-Run; got positions {:?}", + positions + ); + } + }) } -#[tokio::test] -async fn up_skips_when_spec_hash_matches() { - let mock = Arc::new(MockBackend::new()); - - // Phase 1: fresh up - mock.set_inspect_not_found().await; - let s1 = spec(&[("db", svc("postgres:16-alpine"))]); - let _ = engine(s1.clone(), "myapp", mock.clone()) - .clone() - .up(&[], false, false, false) - .await; - - // Phase 2: same project + same spec → inspect returns running with - // matching spec_hash → skip path fires, no new Run. - mock.set_inspect_running(true).await; - mock.set_existing_spec_hash_match(&s1.services["db"]).await; - let _ = engine(s1, "myapp", mock.clone()) - .clone() - .up(&[], false, false, false) - .await; - - let runs: usize = mock - .calls() - .await - .iter() - .filter(|c| matches!(c, RecordedCall::Run(_))) - .count(); - assert_eq!( - runs, 1, - "matching spec_hash must skip recreate; total Runs: {}", - runs - ); +#[test] +fn up_skips_when_spec_hash_matches() { + perry_container_compose::rt::block_on(async { + let mock = Arc::new(MockBackend::new()); + + // Phase 1: fresh up + mock.set_inspect_not_found().await; + let s1 = spec(&[("db", svc("postgres:16-alpine"))]); + let _ = engine(s1.clone(), "myapp", mock.clone()) + .clone() + .up(&[], false, false, false) + .await; + + // Phase 2: same project + same spec → inspect returns running with + // matching spec_hash → skip path fires, no new Run. + mock.set_inspect_running(true).await; + mock.set_existing_spec_hash_match(&s1.services["db"]).await; + let _ = engine(s1, "myapp", mock.clone()) + .clone() + .up(&[], false, false, false) + .await; + + let runs: usize = mock + .calls() + .await + .iter() + .filter(|c| matches!(c, RecordedCall::Run(_))) + .count(); + assert_eq!( + runs, 1, + "matching spec_hash must skip recreate; total Runs: {}", + runs + ); + }) } // ────────────────────────────────────────────────────────────────────── // A.10: Service-key network alias propagation — Tier 2.1 // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn run_spec_carries_service_key_as_network_alias() { - let mock = Arc::new(MockBackend::new()); - mock.set_inspect_not_found().await; - let spec = spec_with_networks( - &[ - ("db", svc_with_net("postgres", "appnet")), - ("api", svc_with_net("myapi", "appnet")), - ], - &[("appnet", Some(ComposeNetwork::default()))], - ); - let _ = engine(spec, "myapp", mock.clone()) - .clone() - .up(&[], false, false, false) - .await; - - let calls = mock.calls().await; - let run_specs: Vec<_> = calls - .iter() - .filter_map(|c| match c { - RecordedCall::Run(spec) => Some(spec.clone()), - _ => None, - }) - .collect(); - assert_eq!( - run_specs.len(), - 2, - "expected 2 Run calls; got {}", - run_specs.len() - ); +#[test] +fn run_spec_carries_service_key_as_network_alias() { + perry_container_compose::rt::block_on(async { + let mock = Arc::new(MockBackend::new()); + mock.set_inspect_not_found().await; + let spec = spec_with_networks( + &[ + ("db", svc_with_net("postgres", "appnet")), + ("api", svc_with_net("myapi", "appnet")), + ], + &[("appnet", Some(ComposeNetwork::default()))], + ); + let _ = engine(spec, "myapp", mock.clone()) + .clone() + .up(&[], false, false, false) + .await; - let (db_aliases, api_aliases) = run_specs.iter().fold((vec![], vec![]), |mut acc, s| { - let aliases = s.network_aliases.clone().unwrap_or_default(); - if s.image.contains("postgres") { - acc.0 = aliases; - } else if s.image.contains("myapi") { - acc.1 = aliases; - } - acc - }); - assert!( - db_aliases.contains(&"db".to_string()), - "service `db`'s spec must carry `db` as a network alias; got {:?}", - db_aliases - ); - assert!( - api_aliases.contains(&"api".to_string()), - "service `api`'s spec must carry `api` as a network alias; got {:?}", - api_aliases - ); + let calls = mock.calls().await; + let run_specs: Vec<_> = calls + .iter() + .filter_map(|c| match c { + RecordedCall::Run(spec) => Some(spec.clone()), + _ => None, + }) + .collect(); + assert_eq!( + run_specs.len(), + 2, + "expected 2 Run calls; got {}", + run_specs.len() + ); + + let (db_aliases, api_aliases) = run_specs.iter().fold((vec![], vec![]), |mut acc, s| { + let aliases = s.network_aliases.clone().unwrap_or_default(); + if s.image.contains("postgres") { + acc.0 = aliases; + } else if s.image.contains("myapi") { + acc.1 = aliases; + } + acc + }); + assert!( + db_aliases.contains(&"db".to_string()), + "service `db`'s spec must carry `db` as a network alias; got {:?}", + db_aliases + ); + assert!( + api_aliases.contains(&"api".to_string()), + "service `api`'s spec must carry `api` as a network alias; got {:?}", + api_aliases + ); + }) } // ────────────────────────────────────────────────────────────────────── // A.11: Dependency ordering (smoke pin) // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn services_run_in_topological_order() { - use perry_container_compose::types::DependsOnSpec; - let mock = Arc::new(MockBackend::new()); - mock.set_inspect_not_found().await; - - let mut db = svc("postgres:16-alpine"); - let mut api = svc("myapi"); - api.depends_on = Some(DependsOnSpec::List(vec!["db".to_string()])); - - let s = spec(&[("api", api), ("db", db)]); - let _ = engine(s, "myapp", mock.clone()) - .clone() - .up(&[], false, false, false) - .await; - - // Capture run-order: db must come before api regardless of - // declaration order in the spec. - let calls = mock.calls().await; - let run_order: Vec<&str> = calls - .iter() - .filter_map(|c| match c { - RecordedCall::Run(spec) => spec.image.split(':').next(), - _ => None, - }) - .collect(); - let db_idx = run_order.iter().position(|s| *s == "postgres"); - let api_idx = run_order.iter().position(|s| *s == "myapi"); - assert!( - db_idx.is_some() && api_idx.is_some(), - "both services must run; got: {:?}", - run_order - ); - assert!( - db_idx.unwrap() < api_idx.unwrap(), - "topological sort: db must precede api; got: {:?}", - run_order - ); +#[test] +fn services_run_in_topological_order() { + perry_container_compose::rt::block_on(async { + use perry_container_compose::types::DependsOnSpec; + let mock = Arc::new(MockBackend::new()); + mock.set_inspect_not_found().await; + + let mut db = svc("postgres:16-alpine"); + let mut api = svc("myapi"); + api.depends_on = Some(DependsOnSpec::List(vec!["db".to_string()])); + + let s = spec(&[("api", api), ("db", db)]); + let _ = engine(s, "myapp", mock.clone()) + .clone() + .up(&[], false, false, false) + .await; + + // Capture run-order: db must come before api regardless of + // declaration order in the spec. + let calls = mock.calls().await; + let run_order: Vec<&str> = calls + .iter() + .filter_map(|c| match c { + RecordedCall::Run(spec) => spec.image.split(':').next(), + _ => None, + }) + .collect(); + let db_idx = run_order.iter().position(|s| *s == "postgres"); + let api_idx = run_order.iter().position(|s| *s == "myapi"); + assert!( + db_idx.is_some() && api_idx.is_some(), + "both services must run; got: {:?}", + run_order + ); + assert!( + db_idx.unwrap() < api_idx.unwrap(), + "topological sort: db must precede api; got: {:?}", + run_order + ); + }) } diff --git a/crates/perry-container-compose/tests/live_runtime_tests.rs b/crates/perry-container-compose/tests/live_runtime_tests.rs index b09506d327..866234f4f6 100644 --- a/crates/perry-container-compose/tests/live_runtime_tests.rs +++ b/crates/perry-container-compose/tests/live_runtime_tests.rs @@ -48,19 +48,13 @@ impl ProjectCleanup { impl Drop for ProjectCleanup { fn drop(&mut self) { - // Spin up a small dedicated runtime so Drop can await — the - // outer #[tokio::test] runtime is already shutting down. + // Drive the async teardown on a dedicated thread with its own + // `rt::block_on` loop — Drop may run while the test's own + // block_on is unwinding. let project = self.project.clone(); let backend = self.backend.clone(); let _ = std::thread::spawn(move || { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(_) => return, - }; - rt.block_on(async { + let _ = perry_container_compose::rt::try_block_on(async { let opts = CleanupOptions { volumes: true, networks: true, @@ -111,386 +105,398 @@ fn unique_port() -> u16 { // Test 1: run + remove of a one-shot alpine container // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn live_run_and_remove_alpine() { - if !live_tests_enabled() { - eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); - return; - } - let backend = make_backend().await; - let project = project_name("run_remove"); - // RAII cleanup — even if assertions panic, drop drains every - // container labelled with our project name so we don't leak. - let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); - - use perry_container_compose::types::ContainerSpec; - let mut labels = std::collections::HashMap::new(); - labels.insert("perry.compose.project".into(), project.clone()); - let spec = ContainerSpec { - image: "alpine:3.19".into(), - name: Some(format!("{}-oneshot", project)), - cmd: Some(vec!["echo".into(), "hello-from-perry-test".into()]), - rm: Some(false), - labels: Some(labels), - ..Default::default() - }; - - let handle = backend.run(&spec).await.expect("run alpine"); - let exit_code = backend.wait(&handle.id).await.expect("wait"); - assert_eq!(exit_code, 0, "alpine echo should exit 0; got {}", exit_code); +#[test] +fn live_run_and_remove_alpine() { + perry_container_compose::rt::block_on(async { + if !live_tests_enabled() { + eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); + return; + } + let backend = make_backend().await; + let project = project_name("run_remove"); + // RAII cleanup — even if assertions panic, drop drains every + // container labelled with our project name so we don't leak. + let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); + + use perry_container_compose::types::ContainerSpec; + let mut labels = std::collections::HashMap::new(); + labels.insert("perry.compose.project".into(), project.clone()); + let spec = ContainerSpec { + image: "alpine:3.19".into(), + name: Some(format!("{}-oneshot", project)), + cmd: Some(vec!["echo".into(), "hello-from-perry-test".into()]), + rm: Some(false), + labels: Some(labels), + ..Default::default() + }; + + let handle = backend.run(&spec).await.expect("run alpine"); + let exit_code = backend.wait(&handle.id).await.expect("wait"); + assert_eq!(exit_code, 0, "alpine echo should exit 0; got {}", exit_code); + }) } // ────────────────────────────────────────────────────────────────────── // Test 2: full compose lifecycle with healthcheck + alias // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn live_compose_up_with_healthcheck_and_alias() { - if !live_tests_enabled() { - eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); - return; - } - let backend = make_backend().await; - let project = project_name("compose_alias"); - let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); - let port = unique_port(); - - let mut services = IndexMap::new(); - services.insert( - "cache".to_string(), - ComposeService { - image: Some("redis:7-alpine".to_string()), - ports: Some(vec![perry_container_compose::types::PortSpec::Short( - serde_yaml::Value::String(format!("{}:6379", port)), - )]), - networks: Some(ServiceNetworks::List(vec!["appnet".into()])), - ..Default::default() - }, - ); - - let mut networks = IndexMap::new(); - networks.insert("appnet".to_string(), Some(ComposeNetwork::default())); - - let spec = ComposeSpec { - services, - networks: Some(networks), - ..Default::default() - }; - - let eng = Arc::new(ComposeEngine::new(spec, project.clone(), backend.clone())); - let handle = eng - .clone() - .up(&[], false, false, false) - .await - .expect("up should succeed"); - assert!(handle.stack_id > 0); +#[test] +fn live_compose_up_with_healthcheck_and_alias() { + perry_container_compose::rt::block_on(async { + if !live_tests_enabled() { + eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); + return; + } + let backend = make_backend().await; + let project = project_name("compose_alias"); + let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); + let port = unique_port(); + + let mut services = IndexMap::new(); + services.insert( + "cache".to_string(), + ComposeService { + image: Some("redis:7-alpine".to_string()), + ports: Some(vec![perry_container_compose::types::PortSpec::Short( + serde_yaml::Value::String(format!("{}:6379", port)), + )]), + networks: Some(ServiceNetworks::List(vec!["appnet".into()])), + ..Default::default() + }, + ); - // Verify the cache is reachable on its published port. - tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + let mut networks = IndexMap::new(); + networks.insert("appnet".to_string(), Some(ComposeNetwork::default())); - // Cleanup — preserve volumes (none declared here anyway). - eng.down(&[], false, /* remove_volumes */ false) - .await - .expect("down"); - - // Confirm no containers labelled with our project name remain. - let leftover = backend.list(true).await.unwrap_or_default(); - let ours: Vec<_> = leftover - .iter() - .filter(|c| c.labels.get("perry.compose.project") == Some(&project)) - .collect(); - assert!( - ours.is_empty(), - "after down(): expected no containers labelled {}; got {} leftover", - project, - ours.len() - ); + let spec = ComposeSpec { + services, + networks: Some(networks), + ..Default::default() + }; + + let eng = Arc::new(ComposeEngine::new(spec, project.clone(), backend.clone())); + let handle = eng + .clone() + .up(&[], false, false, false) + .await + .expect("up should succeed"); + assert!(handle.stack_id > 0); + + // Verify the cache is reachable on its published port. + perry_container_compose::rt::sleep(std::time::Duration::from_millis(1500)).await; + + // Cleanup — preserve volumes (none declared here anyway). + eng.down(&[], false, /* remove_volumes */ false) + .await + .expect("down"); + + // Confirm no containers labelled with our project name remain. + let leftover = backend.list(true).await.unwrap_or_default(); + let ours: Vec<_> = leftover + .iter() + .filter(|c| c.labels.get("perry.compose.project") == Some(&project)) + .collect(); + assert!( + ours.is_empty(), + "after down(): expected no containers labelled {}; got {} leftover", + project, + ours.len() + ); + }) } // ────────────────────────────────────────────────────────────────────── // Test 3: down(volumes: false) preserves named volumes // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn live_down_preserves_volumes_by_default() { - if !live_tests_enabled() { - eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); - return; - } - let backend = make_backend().await; - let project = project_name("preserve_vols"); - let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); - - let mut services = IndexMap::new(); - services.insert( - "db".to_string(), - ComposeService { - image: Some("alpine:3.19".to_string()), - command: Some(serde_yaml::Value::Sequence(vec![ - serde_yaml::Value::String("sh".into()), - serde_yaml::Value::String("-c".into()), - serde_yaml::Value::String("true".into()), - ])), - volumes: Some(vec![serde_yaml::Value::String("data:/var/data".into())]), - ..Default::default() - }, - ); - let mut volumes = IndexMap::new(); - volumes.insert("data".to_string(), Some(ComposeVolume::default())); - - let spec = ComposeSpec { - services, - volumes: Some(volumes), - ..Default::default() - }; - let eng = Arc::new(ComposeEngine::new( - spec.clone(), - project.clone(), - backend.clone(), - )); - let _ = eng.clone().up(&[], false, false, false).await.expect("up"); - - // The volume's runtime name is project-namespaced. - let expected_vol = format!("{}_data", project); - - // down without volumes — must preserve. - eng.down(&[], false, false).await.expect("down preserve"); - - // The mock can't peek at docker volumes directly without going - // through the FFI; rely on the backend trait's create+inspect - // shape via a fresh engine on the same project — `up()` will - // SKIP the volume create because inspect_volume succeeds. - let eng2 = Arc::new(ComposeEngine::new(spec, project.clone(), backend.clone())); - let _ = eng2 - .clone() - .up(&[], false, false, false) - .await - .expect("redeploy must succeed against existing volumes"); +#[test] +fn live_down_preserves_volumes_by_default() { + perry_container_compose::rt::block_on(async { + if !live_tests_enabled() { + eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); + return; + } + let backend = make_backend().await; + let project = project_name("preserve_vols"); + let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); - // Now drop with volumes:true — clean up for next test. - eng2.down(&[], false, true).await.expect("destroy"); + let mut services = IndexMap::new(); + services.insert( + "db".to_string(), + ComposeService { + image: Some("alpine:3.19".to_string()), + command: Some(serde_yaml::Value::Sequence(vec![ + serde_yaml::Value::String("sh".into()), + serde_yaml::Value::String("-c".into()), + serde_yaml::Value::String("true".into()), + ])), + volumes: Some(vec![serde_yaml::Value::String("data:/var/data".into())]), + ..Default::default() + }, + ); + let mut volumes = IndexMap::new(); + volumes.insert("data".to_string(), Some(ComposeVolume::default())); - let _ = expected_vol; // referenced for clarity in panic messages + let spec = ComposeSpec { + services, + volumes: Some(volumes), + ..Default::default() + }; + let eng = Arc::new(ComposeEngine::new( + spec.clone(), + project.clone(), + backend.clone(), + )); + let _ = eng.clone().up(&[], false, false, false).await.expect("up"); + + // The volume's runtime name is project-namespaced. + let expected_vol = format!("{}_data", project); + + // down without volumes — must preserve. + eng.down(&[], false, false).await.expect("down preserve"); + + // The mock can't peek at docker volumes directly without going + // through the FFI; rely on the backend trait's create+inspect + // shape via a fresh engine on the same project — `up()` will + // SKIP the volume create because inspect_volume succeeds. + let eng2 = Arc::new(ComposeEngine::new(spec, project.clone(), backend.clone())); + let _ = eng2 + .clone() + .up(&[], false, false, false) + .await + .expect("redeploy must succeed against existing volumes"); + + // Now drop with volumes:true — clean up for next test. + eng2.down(&[], false, true).await.expect("destroy"); + + let _ = expected_vol; // referenced for clarity in panic messages + }) } // ────────────────────────────────────────────────────────────────────── // Test 4: external network is NOT removed by down() // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn live_external_network_survives_down() { - if !live_tests_enabled() { - eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); - return; - } - let backend = make_backend().await; - let project = project_name("ext_net"); - let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); - let net_name = format!("{}-shared", project); - - // Pre-create the "external" network out-of-band via the same - // backend (the test stand-in for "user pre-created infra"). - backend - .create_network(&net_name, &ComposeNetwork::default()) - .await - .expect("pre-create shared net"); - - let mut services = IndexMap::new(); - services.insert( - "web".to_string(), - ComposeService { - image: Some("alpine:3.19".to_string()), - command: Some(serde_yaml::Value::Sequence(vec![ - serde_yaml::Value::String("sh".into()), - serde_yaml::Value::String("-c".into()), - serde_yaml::Value::String("true".into()), - ])), - networks: Some(ServiceNetworks::List(vec!["shared".into()])), - ..Default::default() - }, - ); - let mut networks = IndexMap::new(); - networks.insert( - "shared".to_string(), - Some(ComposeNetwork { - external: Some(true), - name: Some(net_name.clone()), - ..Default::default() - }), - ); - - let spec = ComposeSpec { - services, - networks: Some(networks), - ..Default::default() - }; - let eng = Arc::new(ComposeEngine::new(spec, project, backend.clone())); - let _ = eng.clone().up(&[], false, false, false).await.expect("up"); - eng.down(&[], false, false).await.expect("down"); - - // The external network MUST still exist after down. - let still_there = backend.inspect_network(&net_name).await.is_ok(); - assert!( - still_there, - "external network {} must survive down(); it didn't", - net_name - ); - - // Manual cleanup — we created the external net, so we tear it down. - let _ = backend.remove_network(&net_name).await; -} +#[test] +fn live_external_network_survives_down() { + perry_container_compose::rt::block_on(async { + if !live_tests_enabled() { + eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); + return; + } + let backend = make_backend().await; + let project = project_name("ext_net"); + let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); + let net_name = format!("{}-shared", project); + + // Pre-create the "external" network out-of-band via the same + // backend (the test stand-in for "user pre-created infra"). + backend + .create_network(&net_name, &ComposeNetwork::default()) + .await + .expect("pre-create shared net"); -// ────────────────────────────────────────────────────────────────────── -// Test 5: cross-service DNS via `--network-alias` works -// ────────────────────────────────────────────────────────────────────── + let mut services = IndexMap::new(); + services.insert( + "web".to_string(), + ComposeService { + image: Some("alpine:3.19".to_string()), + command: Some(serde_yaml::Value::Sequence(vec![ + serde_yaml::Value::String("sh".into()), + serde_yaml::Value::String("-c".into()), + serde_yaml::Value::String("true".into()), + ])), + networks: Some(ServiceNetworks::List(vec!["shared".into()])), + ..Default::default() + }, + ); + let mut networks = IndexMap::new(); + networks.insert( + "shared".to_string(), + Some(ComposeNetwork { + external: Some(true), + name: Some(net_name.clone()), + ..Default::default() + }), + ); -#[tokio::test] -async fn live_cross_service_dns_resolves_service_key() { - if !live_tests_enabled() { - eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); - return; - } - let backend = make_backend().await; - let project = project_name("svc_dns"); - let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); - - let mut services = IndexMap::new(); - services.insert( - "ping_target".to_string(), - ComposeService { - image: Some("alpine:3.19".to_string()), - command: Some(serde_yaml::Value::Sequence(vec![ - serde_yaml::Value::String("sleep".into()), - serde_yaml::Value::String("60".into()), - ])), - networks: Some(ServiceNetworks::List(vec!["dnsnet".into()])), - ..Default::default() - }, - ); - services.insert( - "ping_caller".to_string(), - ComposeService { - image: Some("alpine:3.19".to_string()), - command: Some(serde_yaml::Value::Sequence(vec![ - serde_yaml::Value::String("sleep".into()), - serde_yaml::Value::String("60".into()), - ])), - networks: Some(ServiceNetworks::List(vec!["dnsnet".into()])), + let spec = ComposeSpec { + services, + networks: Some(networks), ..Default::default() - }, - ); - let mut networks = IndexMap::new(); - networks.insert("dnsnet".to_string(), Some(ComposeNetwork::default())); - - let spec = ComposeSpec { - services, - networks: Some(networks), - ..Default::default() - }; - let eng = Arc::new(ComposeEngine::new(spec, project, backend.clone())); - let _ = eng.clone().up(&[], false, false, false).await.expect("up"); - - // Give docker DNS a moment to register aliases. - tokio::time::sleep(std::time::Duration::from_millis(800)).await; - - // From `ping_caller`, resolve the service KEY `ping_target`. If - // service-key alias registration works, this returns 0 with an IP. - let result = eng - .exec( - "ping_caller", - &["sh".into(), "-c".into(), "getent hosts ping_target".into()], - None, - None, - ) - .await; - eng.down(&[], false, false).await.ok(); - - match result { - Ok(logs) => { - assert!( - !logs.stdout.is_empty(), - "service-key DNS alias must resolve; got empty stdout, stderr={:?}", - logs.stderr - ); - } - Err(e) => panic!("exec failed: {}", e), - } + }; + let eng = Arc::new(ComposeEngine::new(spec, project, backend.clone())); + let _ = eng.clone().up(&[], false, false, false).await.expect("up"); + eng.down(&[], false, false).await.expect("down"); + + // The external network MUST still exist after down. + let still_there = backend.inspect_network(&net_name).await.is_ok(); + assert!( + still_there, + "external network {} must survive down(); it didn't", + net_name + ); + + // Manual cleanup — we created the external net, so we tear it down. + let _ = backend.remove_network(&net_name).await; + }) } // ────────────────────────────────────────────────────────────────────── -// Test 6: two stacks with the same volume key don't collide +// Test 5: cross-service DNS via `--network-alias` works // ────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn live_two_stacks_dont_collide_on_volume_keys() { - if !live_tests_enabled() { - eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); - return; - } - let backend = make_backend().await; - let project1 = project_name("collision_a"); - let project2 = project_name("collision_b"); - let _cleanup1 = ProjectCleanup::new(project1.clone(), backend.clone()); - let _cleanup2 = ProjectCleanup::new(project2.clone(), backend.clone()); +#[test] +fn live_cross_service_dns_resolves_service_key() { + perry_container_compose::rt::block_on(async { + if !live_tests_enabled() { + eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); + return; + } + let backend = make_backend().await; + let project = project_name("svc_dns"); + let _cleanup = ProjectCleanup::new(project.clone(), backend.clone()); - fn build_spec() -> ComposeSpec { let mut services = IndexMap::new(); services.insert( - "data".to_string(), + "ping_target".to_string(), ComposeService { image: Some("alpine:3.19".to_string()), command: Some(serde_yaml::Value::Sequence(vec![ - serde_yaml::Value::String("sh".into()), - serde_yaml::Value::String("-c".into()), - serde_yaml::Value::String("true".into()), + serde_yaml::Value::String("sleep".into()), + serde_yaml::Value::String("60".into()), ])), - volumes: Some(vec![serde_yaml::Value::String("shared-key:/data".into())]), + networks: Some(ServiceNetworks::List(vec!["dnsnet".into()])), ..Default::default() }, ); - let mut volumes = IndexMap::new(); - volumes.insert("shared-key".to_string(), Some(ComposeVolume::default())); - ComposeSpec { + services.insert( + "ping_caller".to_string(), + ComposeService { + image: Some("alpine:3.19".to_string()), + command: Some(serde_yaml::Value::Sequence(vec![ + serde_yaml::Value::String("sleep".into()), + serde_yaml::Value::String("60".into()), + ])), + networks: Some(ServiceNetworks::List(vec!["dnsnet".into()])), + ..Default::default() + }, + ); + let mut networks = IndexMap::new(); + networks.insert("dnsnet".to_string(), Some(ComposeNetwork::default())); + + let spec = ComposeSpec { services, - volumes: Some(volumes), + networks: Some(networks), ..Default::default() + }; + let eng = Arc::new(ComposeEngine::new(spec, project, backend.clone())); + let _ = eng.clone().up(&[], false, false, false).await.expect("up"); + + // Give docker DNS a moment to register aliases. + perry_container_compose::rt::sleep(std::time::Duration::from_millis(800)).await; + + // From `ping_caller`, resolve the service KEY `ping_target`. If + // service-key alias registration works, this returns 0 with an IP. + let result = eng + .exec( + "ping_caller", + &["sh".into(), "-c".into(), "getent hosts ping_target".into()], + None, + None, + ) + .await; + eng.down(&[], false, false).await.ok(); + + match result { + Ok(logs) => { + assert!( + !logs.stdout.is_empty(), + "service-key DNS alias must resolve; got empty stdout, stderr={:?}", + logs.stderr + ); + } + Err(e) => panic!("exec failed: {}", e), } - } + }) +} - let eng1 = Arc::new(ComposeEngine::new( - build_spec(), - project1.clone(), - backend.clone(), - )); - let eng2 = Arc::new(ComposeEngine::new( - build_spec(), - project2.clone(), - backend.clone(), - )); - - eng1.clone() - .up(&[], false, false, false) - .await - .expect("p1 up"); - eng2.clone() - .up(&[], false, false, false) - .await - .expect("p2 up"); - - // Volume names must be project-namespaced and distinct. - let v1 = format!("{}_shared-key", project1); - let v2 = format!("{}_shared-key", project2); - assert_ne!(v1, v2); - assert!( - backend.inspect_volume(&v1).await.is_ok(), - "{} should exist", - v1 - ); - assert!( - backend.inspect_volume(&v2).await.is_ok(), - "{} should exist", - v2 - ); - // ProjectCleanup drops at function exit and tears both stacks down - // — no manual `eng1.down(...)` / `eng2.down(...)` boilerplate. +// ────────────────────────────────────────────────────────────────────── +// Test 6: two stacks with the same volume key don't collide +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn live_two_stacks_dont_collide_on_volume_keys() { + perry_container_compose::rt::block_on(async { + if !live_tests_enabled() { + eprintln!("[skipped] PERRY_INTEGRATION_TESTS=1 not set"); + return; + } + let backend = make_backend().await; + let project1 = project_name("collision_a"); + let project2 = project_name("collision_b"); + let _cleanup1 = ProjectCleanup::new(project1.clone(), backend.clone()); + let _cleanup2 = ProjectCleanup::new(project2.clone(), backend.clone()); + + fn build_spec() -> ComposeSpec { + let mut services = IndexMap::new(); + services.insert( + "data".to_string(), + ComposeService { + image: Some("alpine:3.19".to_string()), + command: Some(serde_yaml::Value::Sequence(vec![ + serde_yaml::Value::String("sh".into()), + serde_yaml::Value::String("-c".into()), + serde_yaml::Value::String("true".into()), + ])), + volumes: Some(vec![serde_yaml::Value::String("shared-key:/data".into())]), + ..Default::default() + }, + ); + let mut volumes = IndexMap::new(); + volumes.insert("shared-key".to_string(), Some(ComposeVolume::default())); + ComposeSpec { + services, + volumes: Some(volumes), + ..Default::default() + } + } + + let eng1 = Arc::new(ComposeEngine::new( + build_spec(), + project1.clone(), + backend.clone(), + )); + let eng2 = Arc::new(ComposeEngine::new( + build_spec(), + project2.clone(), + backend.clone(), + )); + + eng1.clone() + .up(&[], false, false, false) + .await + .expect("p1 up"); + eng2.clone() + .up(&[], false, false, false) + .await + .expect("p2 up"); + + // Volume names must be project-namespaced and distinct. + let v1 = format!("{}_shared-key", project1); + let v2 = format!("{}_shared-key", project2); + assert_ne!(v1, v2); + assert!( + backend.inspect_volume(&v1).await.is_ok(), + "{} should exist", + v1 + ); + assert!( + backend.inspect_volume(&v2).await.is_ok(), + "{} should exist", + v2 + ); + // ProjectCleanup drops at function exit and tears both stacks down + // — no manual `eng1.down(...)` / `eng2.down(...)` boilerplate. + }) } diff --git a/crates/perry-container-compose/tests/orchestration.rs b/crates/perry-container-compose/tests/orchestration.rs index ea36c97fe5..c8662e2b17 100644 --- a/crates/perry-container-compose/tests/orchestration.rs +++ b/crates/perry-container-compose/tests/orchestration.rs @@ -5,192 +5,200 @@ use std::sync::Arc; mod common; use common::MockBackend; -#[tokio::test] -async fn test_compose_up_success() { - let mut spec = ComposeSpec::default(); - spec.services.insert( - "web".into(), - ComposeService { - image: Some("nginx".into()), - ..Default::default() - }, - ); - spec.services.insert( - "db".into(), - ComposeService { - image: Some("postgres".into()), - ..Default::default() - }, - ); - - let backend = Arc::new(MockBackend::default()); - let engine = Arc::new(ComposeEngine::new( - spec, - "test-project".into(), - backend.clone(), - )); - - let handle = Arc::clone(&engine) - .up(&[], true, false, false) - .await - .expect("up failed"); - - assert_eq!(handle.project_name, "test-project"); - assert_eq!(handle.services.len(), 2); - - let state = backend.state.lock().unwrap(); - assert_eq!(state.containers.len(), 2); +#[test] +fn test_compose_up_success() { + perry_container_compose::rt::block_on(async { + let mut spec = ComposeSpec::default(); + spec.services.insert( + "web".into(), + ComposeService { + image: Some("nginx".into()), + ..Default::default() + }, + ); + spec.services.insert( + "db".into(), + ComposeService { + image: Some("postgres".into()), + ..Default::default() + }, + ); + + let backend = Arc::new(MockBackend::default()); + let engine = Arc::new(ComposeEngine::new( + spec, + "test-project".into(), + backend.clone(), + )); + + let handle = Arc::clone(&engine) + .up(&[], true, false, false) + .await + .expect("up failed"); + + assert_eq!(handle.project_name, "test-project"); + assert_eq!(handle.services.len(), 2); + + let state = backend.state.lock().unwrap(); + assert_eq!(state.containers.len(), 2); + }) } -#[tokio::test] -async fn test_compose_up_rollback_on_failure() { - let mut spec = ComposeSpec::default(); - spec.services.insert( - "db".into(), - ComposeService { - image: Some("postgres".into()), - ..Default::default() - }, - ); - spec.services.insert( - "web".into(), - ComposeService { - image: Some("nginx".into()), - ..Default::default() - }, - ); - - let backend = Arc::new(MockBackend::default()); - { - let mut state = backend.state.lock().unwrap(); - // Since we don't know the exact generated name, we fail if the image name 'nginx' is in the spec - state.fail_on_run = Some("nginx".into()); - } - - let engine = Arc::new(ComposeEngine::new( - spec, - "fail-project".into(), - backend.clone(), - )); - let result = Arc::clone(&engine).up(&[], true, false, false).await; - - assert!( - result.is_err(), - "Result should be an error because 'web' service (nginx) was set to fail" - ); - - let state = backend.state.lock().unwrap(); - // Should have started db, tried web, then stopped/removed db - assert!( - state.containers.is_empty(), - "Containers should be empty after rollback, but found: {:?}", - state.containers - ); - - let actions: Vec<_> = state - .actions - .iter() - .map(|s| s.split(':').next().unwrap()) - .collect(); - assert!(actions.contains(&"run")); // db - assert!(actions.contains(&"stop")); // db rollback - assert!(actions.contains(&"remove")); // db rollback +#[test] +fn test_compose_up_rollback_on_failure() { + perry_container_compose::rt::block_on(async { + let mut spec = ComposeSpec::default(); + spec.services.insert( + "db".into(), + ComposeService { + image: Some("postgres".into()), + ..Default::default() + }, + ); + spec.services.insert( + "web".into(), + ComposeService { + image: Some("nginx".into()), + ..Default::default() + }, + ); + + let backend = Arc::new(MockBackend::default()); + { + let mut state = backend.state.lock().unwrap(); + // Since we don't know the exact generated name, we fail if the image name 'nginx' is in the spec + state.fail_on_run = Some("nginx".into()); + } + + let engine = Arc::new(ComposeEngine::new( + spec, + "fail-project".into(), + backend.clone(), + )); + let result = Arc::clone(&engine).up(&[], true, false, false).await; + + assert!( + result.is_err(), + "Result should be an error because 'web' service (nginx) was set to fail" + ); + + let state = backend.state.lock().unwrap(); + // Should have started db, tried web, then stopped/removed db + assert!( + state.containers.is_empty(), + "Containers should be empty after rollback, but found: {:?}", + state.containers + ); + + let actions: Vec<_> = state + .actions + .iter() + .map(|s| s.split(':').next().unwrap()) + .collect(); + assert!(actions.contains(&"run")); // db + assert!(actions.contains(&"stop")); // db rollback + assert!(actions.contains(&"remove")); // db rollback + }) } -#[tokio::test] -async fn test_compose_down_cleans_resources() { - let mut spec = ComposeSpec::default(); - spec.services.insert( - "web".into(), - ComposeService { - image: Some("nginx".into()), - ..Default::default() - }, - ); - - let backend = Arc::new(MockBackend::default()); - let engine = Arc::new(ComposeEngine::new( - spec, - "down-project".into(), - backend.clone(), - )); - - let _handle = Arc::clone(&engine) - .up(&[], true, false, false) - .await - .unwrap(); - - // down() should use resolve_startup_order and clean up - engine.down(&[], false, true).await.expect("down failed"); - - let state = backend.state.lock().unwrap(); - // In our MockBackend, remove just deletes the container from the map. - assert!( - state.containers.is_empty(), - "Containers should be empty, but found: {:?}", - state.containers - ); +#[test] +fn test_compose_down_cleans_resources() { + perry_container_compose::rt::block_on(async { + let mut spec = ComposeSpec::default(); + spec.services.insert( + "web".into(), + ComposeService { + image: Some("nginx".into()), + ..Default::default() + }, + ); + + let backend = Arc::new(MockBackend::default()); + let engine = Arc::new(ComposeEngine::new( + spec, + "down-project".into(), + backend.clone(), + )); + + let _handle = Arc::clone(&engine) + .up(&[], true, false, false) + .await + .unwrap(); + + // down() should use resolve_startup_order and clean up + engine.down(&[], false, true).await.expect("down failed"); + + let state = backend.state.lock().unwrap(); + // In our MockBackend, remove just deletes the container from the map. + assert!( + state.containers.is_empty(), + "Containers should be empty, but found: {:?}", + state.containers + ); + }) } -#[tokio::test] -async fn test_compose_project_name_scopes_volumes_networks_and_labels() { - // The project name (ComposeSpec.name via the FFI; the second - // ComposeEngine::new arg here) must namespace non-external volumes - // and networks as `_` and stamp the - // `perry.compose.project` label on every container. Typed TS - // callers couldn't set it before ComposeSpec.name landed in the - // d.ts — every stack silently collided under "perry-stack". - use perry_container_compose::types::ServiceNetworks; - - let mut spec = ComposeSpec::default(); - spec.services.insert( - "web".into(), - ComposeService { - image: Some("nginx".into()), - volumes: Some(vec![serde_yaml::Value::String("data:/var/www".into())]), - networks: Some(ServiceNetworks::List(vec!["appnet".into()])), - ..Default::default() - }, - ); - spec.volumes = Some({ - let mut m = indexmap::IndexMap::new(); - m.insert("data".to_string(), None); - m - }); - spec.networks = Some({ - let mut m = indexmap::IndexMap::new(); - m.insert("appnet".to_string(), None); - m - }); - - let backend = Arc::new(MockBackend::default()); - let engine = Arc::new(ComposeEngine::new(spec, "myproj".into(), backend.clone())); - Arc::clone(&engine) - .up(&[], true, false, false) - .await - .expect("up failed"); - - let state = backend.state.lock().unwrap(); - assert!( - state.volumes.contains(&"myproj_data".to_string()), - "volume must be project-scoped as myproj_data; got {:?}", - state.volumes - ); - assert!( - state.networks.contains(&"myproj_appnet".to_string()), - "network must be project-scoped as myproj_appnet; got {:?}", - state.networks - ); - let web = state - .containers - .values() - .next() - .expect("one container expected"); - assert_eq!( - web.labels.get("perry.compose.project"), - Some(&"myproj".to_string()), - "container must carry the project label" - ); +#[test] +fn test_compose_project_name_scopes_volumes_networks_and_labels() { + perry_container_compose::rt::block_on(async { + // The project name (ComposeSpec.name via the FFI; the second + // ComposeEngine::new arg here) must namespace non-external volumes + // and networks as `_` and stamp the + // `perry.compose.project` label on every container. Typed TS + // callers couldn't set it before ComposeSpec.name landed in the + // d.ts — every stack silently collided under "perry-stack". + use perry_container_compose::types::ServiceNetworks; + + let mut spec = ComposeSpec::default(); + spec.services.insert( + "web".into(), + ComposeService { + image: Some("nginx".into()), + volumes: Some(vec![serde_yaml::Value::String("data:/var/www".into())]), + networks: Some(ServiceNetworks::List(vec!["appnet".into()])), + ..Default::default() + }, + ); + spec.volumes = Some({ + let mut m = indexmap::IndexMap::new(); + m.insert("data".to_string(), None); + m + }); + spec.networks = Some({ + let mut m = indexmap::IndexMap::new(); + m.insert("appnet".to_string(), None); + m + }); + + let backend = Arc::new(MockBackend::default()); + let engine = Arc::new(ComposeEngine::new(spec, "myproj".into(), backend.clone())); + Arc::clone(&engine) + .up(&[], true, false, false) + .await + .expect("up failed"); + + let state = backend.state.lock().unwrap(); + assert!( + state.volumes.contains(&"myproj_data".to_string()), + "volume must be project-scoped as myproj_data; got {:?}", + state.volumes + ); + assert!( + state.networks.contains(&"myproj_appnet".to_string()), + "network must be project-scoped as myproj_appnet; got {:?}", + state.networks + ); + let web = state + .containers + .values() + .next() + .expect("one container expected"); + assert_eq!( + web.labels.get("perry.compose.project"), + Some(&"myproj".to_string()), + "container must carry the project label" + ); + }) } /// Seed the mock backend with a container that looks like a leftover @@ -216,137 +224,143 @@ fn seed_orphan(backend: &MockBackend, id: &str, project: &str, service: &str) { ); } -#[tokio::test] -async fn test_compose_down_remove_orphans_removes_stale_service_containers() { - // A container from a previous deploy whose service key - // ("old-worker") was deleted from the spec must be stopped + - // removed when down() runs with remove_orphans = true. Pre-fix the - // flag was parsed at the FFI and discarded (`_remove_orphans`). - let mut spec = ComposeSpec::default(); - spec.services.insert( - "web".into(), - ComposeService { - image: Some("nginx".into()), - ..Default::default() - }, - ); - - let backend = Arc::new(MockBackend::default()); - seed_orphan(&backend, "orphan-ctr", "orphan-proj", "old-worker"); - - let engine = Arc::new(ComposeEngine::new( - spec, - "orphan-proj".into(), - backend.clone(), - )); - let _ = Arc::clone(&engine) - .up(&[], true, false, false) - .await - .expect("up failed"); - - engine.down(&[], true, false).await.expect("down failed"); - - let state = backend.state.lock().unwrap(); - assert!( - !state.containers.contains_key("orphan-ctr"), - "orphan must be removed by down(remove_orphans: true); got {:?}", - state.containers.keys().collect::>() - ); - assert!( - state.actions.iter().any(|a| a == "remove:orphan-ctr"), - "expected an explicit remove of the orphan; actions: {:?}", - state.actions - ); +#[test] +fn test_compose_down_remove_orphans_removes_stale_service_containers() { + perry_container_compose::rt::block_on(async { + // A container from a previous deploy whose service key + // ("old-worker") was deleted from the spec must be stopped + + // removed when down() runs with remove_orphans = true. Pre-fix the + // flag was parsed at the FFI and discarded (`_remove_orphans`). + let mut spec = ComposeSpec::default(); + spec.services.insert( + "web".into(), + ComposeService { + image: Some("nginx".into()), + ..Default::default() + }, + ); + + let backend = Arc::new(MockBackend::default()); + seed_orphan(&backend, "orphan-ctr", "orphan-proj", "old-worker"); + + let engine = Arc::new(ComposeEngine::new( + spec, + "orphan-proj".into(), + backend.clone(), + )); + let _ = Arc::clone(&engine) + .up(&[], true, false, false) + .await + .expect("up failed"); + + engine.down(&[], true, false).await.expect("down failed"); + + let state = backend.state.lock().unwrap(); + assert!( + !state.containers.contains_key("orphan-ctr"), + "orphan must be removed by down(remove_orphans: true); got {:?}", + state.containers.keys().collect::>() + ); + assert!( + state.actions.iter().any(|a| a == "remove:orphan-ctr"), + "expected an explicit remove of the orphan; actions: {:?}", + state.actions + ); + }) } -#[tokio::test] -async fn test_compose_down_without_remove_orphans_keeps_orphans() { - // Default behavior is unchanged: remove_orphans = false leaves the - // stale container alone (only current-spec services are removed). - let mut spec = ComposeSpec::default(); - spec.services.insert( - "web".into(), - ComposeService { - image: Some("nginx".into()), - ..Default::default() - }, - ); - - let backend = Arc::new(MockBackend::default()); - seed_orphan(&backend, "orphan-ctr", "orphan-proj", "old-worker"); - - let engine = Arc::new(ComposeEngine::new( - spec, - "orphan-proj".into(), - backend.clone(), - )); - let _ = Arc::clone(&engine) - .up(&[], true, false, false) - .await - .expect("up failed"); - - engine.down(&[], false, false).await.expect("down failed"); - - let state = backend.state.lock().unwrap(); - assert!( - state.containers.contains_key("orphan-ctr"), - "down without remove_orphans must NOT touch the orphan" - ); +#[test] +fn test_compose_down_without_remove_orphans_keeps_orphans() { + perry_container_compose::rt::block_on(async { + // Default behavior is unchanged: remove_orphans = false leaves the + // stale container alone (only current-spec services are removed). + let mut spec = ComposeSpec::default(); + spec.services.insert( + "web".into(), + ComposeService { + image: Some("nginx".into()), + ..Default::default() + }, + ); + + let backend = Arc::new(MockBackend::default()); + seed_orphan(&backend, "orphan-ctr", "orphan-proj", "old-worker"); + + let engine = Arc::new(ComposeEngine::new( + spec, + "orphan-proj".into(), + backend.clone(), + )); + let _ = Arc::clone(&engine) + .up(&[], true, false, false) + .await + .expect("up failed"); + + engine.down(&[], false, false).await.expect("down failed"); + + let state = backend.state.lock().unwrap(); + assert!( + state.containers.contains_key("orphan-ctr"), + "down without remove_orphans must NOT touch the orphan" + ); + }) } -#[tokio::test] -async fn test_compose_down_remove_orphans_scoped_to_project_and_labels() { - // The orphan sweep must be strictly label-scoped: containers from - // OTHER projects and containers without Perry's compose labels are - // never candidates, even with remove_orphans = true. - use perry_container_compose::types::ContainerInfo; - - let mut spec = ComposeSpec::default(); - spec.services.insert( - "web".into(), - ComposeService { - image: Some("nginx".into()), - ..Default::default() - }, - ); - - let backend = Arc::new(MockBackend::default()); - // Same-key orphan in a DIFFERENT project. - seed_orphan(&backend, "other-proj-ctr", "some-other-proj", "old-worker"); - // Unlabelled container (not created by Perry at all). - backend.state.lock().unwrap().containers.insert( - "unlabelled-ctr".to_string(), - ContainerInfo { - id: "unlabelled-ctr".to_string(), - name: "unlabelled-ctr".to_string(), - image: "busybox".to_string(), - status: "running".to_string(), - ports: vec![], - labels: std::collections::HashMap::new(), - created: "2025-01-01T00:00:00Z".to_string(), - ip_address: String::new(), - }, - ); - - let engine = Arc::new(ComposeEngine::new( - spec, - "orphan-proj".into(), - backend.clone(), - )); - let _ = Arc::clone(&engine) - .up(&[], true, false, false) - .await - .expect("up failed"); - - engine.down(&[], true, false).await.expect("down failed"); - - let state = backend.state.lock().unwrap(); - assert!( - state.containers.contains_key("other-proj-ctr"), - "other project's container must survive the orphan sweep" - ); - assert!( - state.containers.contains_key("unlabelled-ctr"), - "unlabelled container must survive the orphan sweep" - ); +#[test] +fn test_compose_down_remove_orphans_scoped_to_project_and_labels() { + perry_container_compose::rt::block_on(async { + // The orphan sweep must be strictly label-scoped: containers from + // OTHER projects and containers without Perry's compose labels are + // never candidates, even with remove_orphans = true. + use perry_container_compose::types::ContainerInfo; + + let mut spec = ComposeSpec::default(); + spec.services.insert( + "web".into(), + ComposeService { + image: Some("nginx".into()), + ..Default::default() + }, + ); + + let backend = Arc::new(MockBackend::default()); + // Same-key orphan in a DIFFERENT project. + seed_orphan(&backend, "other-proj-ctr", "some-other-proj", "old-worker"); + // Unlabelled container (not created by Perry at all). + backend.state.lock().unwrap().containers.insert( + "unlabelled-ctr".to_string(), + ContainerInfo { + id: "unlabelled-ctr".to_string(), + name: "unlabelled-ctr".to_string(), + image: "busybox".to_string(), + status: "running".to_string(), + ports: vec![], + labels: std::collections::HashMap::new(), + created: "2025-01-01T00:00:00Z".to_string(), + ip_address: String::new(), + }, + ); + + let engine = Arc::new(ComposeEngine::new( + spec, + "orphan-proj".into(), + backend.clone(), + )); + let _ = Arc::clone(&engine) + .up(&[], true, false, false) + .await + .expect("up failed"); + + engine.down(&[], true, false).await.expect("down failed"); + + let state = backend.state.lock().unwrap(); + assert!( + state.containers.contains_key("other-proj-ctr"), + "other project's container must survive the orphan sweep" + ); + assert!( + state.containers.contains_key("unlabelled-ctr"), + "unlabelled container must survive the orphan sweep" + ); + }) } diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index ac8c4f8a48..0412e496a3 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -294,15 +294,18 @@ async-bridge = [] # Async runtime (tokio) - internal feature. The bridge plus the tokio # current-thread runtime (`common::tokio_bridge`), for the features that still -# hand it tokio futures: bundled net / tls / ws sockets, reqwest fetch, the -# container engine, and the `perry_ffi_spawn_async` / `_with_reactor` ABI that +# hand it tokio futures: bundled net / tls / ws sockets, reqwest fetch, and the +# `perry_ffi_spawn_async` / `_with_reactor` ABI that # perry-ext-net / perry-ext-http / the db wrappers' decline paths use. async-runtime = ["async-bridge", "dep:tokio"] # OCI container subsystem (perry/container, perry/compose, perry/workloads). # Pulls in perry-container-compose and exposes the `js_container_*` and -# `js_compose_*` FFI exports that the codegen dispatch table targets. -container = ["dep:perry-container-compose", "async-runtime"] +# `js_compose_*` FFI exports that the codegen dispatch table targets. No tokio +# (tokio lane K): the compose engine runs on `perry_container_compose::rt`'s +# turnloop loops (`container/executor.rs`) and settles through the promise +# bridge alone. +container = ["dep:perry-container-compose", "async-bridge"] [dependencies] perry-ffi.workspace = true diff --git a/crates/perry-stdlib/src/common/async_bridge.rs b/crates/perry-stdlib/src/common/async_bridge.rs index 418c285e18..116c278c53 100644 --- a/crates/perry-stdlib/src/common/async_bridge.rs +++ b/crates/perry-stdlib/src/common/async_bridge.rs @@ -191,7 +191,7 @@ thread_local! { static GC_SCANNER_REGISTERED: std::cell::Cell = const { std::cell::Cell::new(false) }; } -pub(super) fn ensure_gc_scanner_registered() { +pub(crate) fn ensure_gc_scanner_registered() { GC_SCANNER_REGISTERED.with(|registered| { if registered.get() { return; diff --git a/crates/perry-stdlib/src/container/backend_ctl.rs b/crates/perry-stdlib/src/container/backend_ctl.rs index 10dfc11be8..009016ed71 100644 --- a/crates/perry-stdlib/src/container/backend_ctl.rs +++ b/crates/perry-stdlib/src/container/backend_ctl.rs @@ -22,9 +22,9 @@ use std::sync::OnceLock; /// at module scope (before any `await` has triggered `get_global_backend`) /// gets the live name instead of the misleading `"unknown"` sentinel. /// -/// The synchronous probe uses `tokio::runtime::Handle::try_current()` + -/// `block_in_place` when called from inside a tokio worker, falling back -/// to a one-shot `Runtime::new().block_on(...)` otherwise. Returns +/// The synchronous probe drives `get_global_backend()` with +/// `perry_container_compose::rt::try_block_on`, a one-shot turnloop loop on +/// the calling thread (nesting inside another `block_on` is fine). Returns /// `"unknown"` only when detection genuinely fails (no backend installed /// + non-interactive). Detection latency is bounded by the same 2-second /// per-candidate timeout as `detect_backend()`. @@ -34,38 +34,11 @@ pub unsafe extern "C" fn js_container_getBackend() -> *const StringHeader { return string_to_js(b.backend_name()); } - // No backend yet — try to populate the singleton synchronously. - // Strategy: - // 1. If we're inside a tokio worker, `block_in_place` lets us call - // the async detect_backend() without deadlocking the runtime. - // 2. If we're on the main thread with no runtime active, spin up - // a fresh single-threaded runtime for the probe. - // 3. On any failure (no runtime + main-thread-bound, detection - // error, etc.), fall back to the legacy "unknown" sentinel. - let resolved = if let Ok(handle) = tokio::runtime::Handle::try_current() { - match handle.runtime_flavor() { - tokio::runtime::RuntimeFlavor::CurrentThread => { - // current_thread runtimes can't `block_in_place`; the only - // safe move is to skip the sync probe and let the next - // async FFI call populate BACKEND. Return "unknown". - None - } - _ => Some(tokio::task::block_in_place(|| { - handle.block_on(get_global_backend()) - })), - } - } else { - // No active runtime — spin up a temp one purely for detection. - // The result is stored in the OnceLock so subsequent FFI calls - // see it; the temp runtime is dropped immediately after. - match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => Some(rt.block_on(get_global_backend())), - Err(_) => None, - } - }; + // No backend yet — populate the singleton synchronously on a one-shot + // loop. The result is stored in the OnceLock so subsequent FFI calls see + // it; the loop is dropped immediately after. On any failure (no loop, + // detection error), fall back to the legacy "unknown" sentinel. + let resolved = perry_container_compose::rt::try_block_on(get_global_backend()).ok(); match resolved { Some(Ok(b)) => string_to_js(b.backend_name()), @@ -78,7 +51,7 @@ pub unsafe extern "C" fn js_container_getBackend() -> *const StringHeader { #[no_mangle] pub unsafe extern "C" fn js_container_detectBackend() -> *mut Promise { let promise = js_promise_new_cross_thread(); - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { match detect_backend().await { @@ -200,7 +173,7 @@ pub unsafe extern "C" fn js_container_selectBackendFor( #[no_mangle] pub unsafe extern "C" fn js_container_getAvailableBackends() -> *mut Promise { let promise = js_promise_new_cross_thread(); - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let probed = perry_container_compose::probe_all_candidates().await; @@ -253,14 +226,14 @@ pub unsafe extern "C" fn js_container_setBackend(name_ptr: *const StringHeader) let name = match string_from_header(name_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid backend name pointer".to_string()) }); return promise; } }; - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { // Reject if BACKEND already initialised — OnceLock can't be @@ -332,14 +305,14 @@ pub unsafe extern "C" fn js_container_setBackends( let names_json = match string_from_header(names_json_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid names array pointer".to_string()) }); return promise; } }; - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { // Reject if BACKEND already initialised — same OnceLock diff --git a/crates/perry-stdlib/src/container/compose_ffi.rs b/crates/perry-stdlib/src/container/compose_ffi.rs index 44bec3be7d..5d3cce93da 100644 --- a/crates/perry-stdlib/src/container/compose_ffi.rs +++ b/crates/perry-stdlib/src/container/compose_ffi.rs @@ -25,7 +25,7 @@ pub unsafe extern "C" fn js_container_compose_start( let engine = match types::get_compose_handle(handle_id as u64) { Some(h) => h.clone(), None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid compose handle".to_string()) }); return promise; @@ -34,7 +34,7 @@ pub unsafe extern "C" fn js_container_compose_start( let services_json = unsafe { string_from_header(services_json_ptr) }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let services: Vec = services_json .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); @@ -63,7 +63,7 @@ pub unsafe extern "C" fn js_container_compose_stop( let engine = match types::get_compose_handle(handle_id as u64) { Some(h) => h.clone(), None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid compose handle".to_string()) }); return promise; @@ -72,7 +72,7 @@ pub unsafe extern "C" fn js_container_compose_stop( let services_json = unsafe { string_from_header(services_json_ptr) }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let services: Vec = services_json .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); @@ -101,7 +101,7 @@ pub unsafe extern "C" fn js_container_compose_restart( let engine = match types::get_compose_handle(handle_id as u64) { Some(h) => h.clone(), None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid compose handle".to_string()) }); return promise; @@ -110,7 +110,7 @@ pub unsafe extern "C" fn js_container_compose_restart( let services_json = unsafe { string_from_header(services_json_ptr) }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let services: Vec = services_json .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); @@ -137,14 +137,14 @@ pub unsafe extern "C" fn js_container_compose_config(handle: f64) -> *mut Promis let engine = match types::get_compose_handle(handle_id as u64) { Some(h) => h.clone(), None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid compose handle".to_string()) }); return promise; } }; - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { engine.config().map_err(|e| e.to_string()) }, |yaml| { @@ -169,15 +169,14 @@ pub unsafe extern "C" fn js_container_composeUp( let spec = match types::parse_compose_spec(spec_ptr) { Ok(s) => s, Err(e) => { - crate::common::spawn_for_promise( - promise as *mut u8, - async move { Err::(e) }, - ); + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { + Err::(e) + }); return promise; } }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let backend = match get_global_backend().await { Ok(b) => Arc::clone(b), Err(e) => return Err::(e.to_string()), @@ -303,14 +302,14 @@ pub unsafe extern "C" fn js_container_compose_down( let engine = match types::take_compose_handle(handle_id as u64) { Some(h) => h, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid compose handle".to_string()) }); return promise; } }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let _backend = match get_global_backend().await { Ok(b) => Arc::clone(b), Err(e) => return Err::(e.to_string()), @@ -336,7 +335,7 @@ pub unsafe extern "C" fn js_container_compose_ps(handle: f64) -> *mut Promise { let engine = match types::get_compose_handle(handle_id as u64) { Some(h) => h.clone(), None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid compose handle".to_string()) }); return promise; @@ -348,7 +347,7 @@ pub unsafe extern "C" fn js_container_compose_ps(handle: f64) -> *mut Promise { // opaque NaN-boxed integer that user code couldn't iterate; the TS // type `Promise` lied about the actual shape. Now // the Promise resolves to a JSON string the user `JSON.parse`s. - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let _backend = get_global_backend().await.map_err(|e| e.to_string())?; @@ -382,7 +381,7 @@ pub unsafe extern "C" fn js_container_compose_logs( let engine = match types::get_compose_handle(handle_id as u64) { Some(h) => h.clone(), None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid compose handle".to_string()) }); return promise; @@ -398,7 +397,7 @@ pub unsafe extern "C" fn js_container_compose_logs( // Resolve with a JSON-encoded `ContainerLogs` string ({ stdout, // stderr }) — see `compose_ps` for the rationale. - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let _backend = get_global_backend().await.map_err(|e| e.to_string())?; @@ -433,7 +432,7 @@ pub unsafe extern "C" fn js_container_compose_exec( let engine = match types::get_compose_handle(handle_id as u64) { Some(h) => h.clone(), None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid compose handle".to_string()) }); return promise; @@ -444,7 +443,7 @@ pub unsafe extern "C" fn js_container_compose_exec( let cmd_json = unsafe { string_from_header(cmd_json_ptr) }; // Resolve with a JSON-encoded `ContainerLogs` string. - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let service = service_opt.ok_or_else(|| "Invalid service name".to_string())?; diff --git a/crates/perry-stdlib/src/container/executor.rs b/crates/perry-stdlib/src/container/executor.rs new file mode 100644 index 0000000000..68b9588145 --- /dev/null +++ b/crates/perry-stdlib/src/container/executor.rs @@ -0,0 +1,156 @@ +//! The container bindings' executor: one compose-engine future per operation, +//! driven by `perry_container_compose::rt::block_on` on a turnloop worker. +//! +//! Until tokio lane K this module's callers used `crate::common:: +//! spawn_for_promise[_deferred]`, which put the future on the tokio +//! current-thread runtime and made `container` imply `async-runtime`. The +//! compose engine no longer uses tokio: its leaves (the backend CLI child +//! processes, timeouts, the async mutex) run on whatever +//! `rt::block_on` loop polls them. So each operation takes a thread from +//! turnloop's `Occupancy::Long` worker set (the same class +//! `perry_ffi_spawn_blocking` uses — a container operation holds its thread +//! for as long as a `docker pull` takes, and must not starve the bounded set +//! bcrypt / zlib run on), creates its own loop there, and settles the promise +//! through the tokio-free `async_bridge` queue — exactly the settle path the +//! tokio arm used. A thread with no event loop, or a refusal at the long +//! set's ceiling, falls back to one plain OS thread. +//! +//! Only the promise bridge (`async-bridge`) is required, so a `container` +//! build links no tokio. + +use std::future::Future; + +use crate::common::async_bridge::{ + blocking_thread_stack_size, ensure_gc_scanner_registered, ensure_pump_registered, + pin_promise_for_native_resolution, queue_deferred_resolution, queue_promise_resolution, + InflightGuard, +}; + +/// Reject `ptr` with `message`, building the string on the main thread. +fn queue_rejection(ptr: usize, message: String) { + queue_deferred_resolution(ptr, false, move || { + let str_ptr = perry_runtime::js_string_from_bytes(message.as_ptr(), message.len() as u32); + // STRING_TAG, not POINTER_TAG, for proper type identification. + perry_runtime::JSValue::string_ptr(str_ptr).bits() + }); +} + +/// Run `future` to completion off the calling thread; `settle` receives its +/// output, or `Err` when no loop could be created for it. With `keep_alive` +/// the program's event loop stays alive until `settle` has run. +fn run_detached( + future: F, + keep_alive: bool, + settle: impl FnOnce(Result) + Send + 'static, +) where + T: Send + 'static, + F: Future> + Send + 'static, +{ + ensure_pump_registered(); + // Held for exactly the operation's run, and released even when the job is + // dropped unrun, so the event loop stays alive until the result is queued. + let inflight = keep_alive.then(InflightGuard::new); + let run = move || { + let result = perry_container_compose::rt::try_block_on(future) + .unwrap_or_else(|e| Err(format!("container runtime unavailable: {e}"))); + settle(result); + drop(inflight); + }; + // `submit_long` consumes `run` only when it accepts the job; a refusal + // hands it back through the slot for the thread fallback. + let slot = std::sync::Arc::new(std::sync::Mutex::new(Some(run))); + let pool_slot = slot.clone(); + let accepted = perry_runtime::turnloop_pool::submit_long( + move || { + if let Some(run) = take(&pool_slot) { + run(); + } + }, + |_delivery| {}, + ) + .is_ok(); + if accepted { + return; + } + let thread_slot = slot.clone(); + let spawned = std::thread::Builder::new() + .name("perry-container".to_string()) + .stack_size(blocking_thread_stack_size()) + .spawn(move || { + if let Some(run) = take(&thread_slot) { + run(); + } + }); + if spawned.is_err() { + // No thread at all: run inline rather than leave a promise pending. + if let Some(run) = take(&slot) { + run(); + } + } +} + +fn take(slot: &std::sync::Mutex>) -> Option { + slot.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() +} + +/// Settle `promise_ptr` with `future`'s bits, or reject with its error +/// string. The bits must not be a heap value — use +/// [`spawn_for_promise_deferred`] for strings, arrays and objects. +/// +/// # Safety +/// `promise_ptr` must point to a live Perry Promise. +pub(crate) unsafe fn spawn_for_promise(promise_ptr: *mut u8, future: F) +where + F: Future> + Send + 'static, +{ + ensure_gc_scanner_registered(); + let ptr = promise_ptr as usize; + // Issue #859: pin before the promise crosses to another thread. + pin_promise_for_native_resolution(ptr); + run_detached(future, true, move |result| match result { + Ok(bits) => queue_promise_resolution(ptr, true, bits), + Err(message) => queue_rejection(ptr, message), + }); +} + +/// Settle `promise_ptr` with `converter(output)`, run on the main thread so it +/// may allocate JS values, or reject with the future's error string. +/// +/// # Safety +/// `promise_ptr` must point to a live Perry Promise. +pub(crate) unsafe fn spawn_for_promise_deferred( + promise_ptr: *mut u8, + future: F, + converter: C, +) where + T: Send + 'static, + F: Future> + Send + 'static, + C: FnOnce(T) -> u64 + Send + 'static, +{ + ensure_gc_scanner_registered(); + let ptr = promise_ptr as usize; + pin_promise_for_native_resolution(ptr); + run_detached(future, true, move |result| match result { + Ok(data) => queue_deferred_resolution(ptr, true, move || converter(data)), + Err(message) => queue_rejection(ptr, message), + }); +} + +/// Run `future` in the background with no promise attached (the backend +/// pre-warm and the signal-cleanup watcher). Like the raw tokio `spawn` it +/// replaces, it does not keep the program alive: the watcher never finishes. +pub(crate) fn spawn_detached(future: F) +where + F: Future + Send + 'static, +{ + run_detached( + async move { + future.await; + Ok(()) + }, + false, + |_: Result<(), String>| {}, + ); +} diff --git a/crates/perry-stdlib/src/container/images.rs b/crates/perry-stdlib/src/container/images.rs index 225ba2f129..eff748b476 100644 --- a/crates/perry-stdlib/src/container/images.rs +++ b/crates/perry-stdlib/src/container/images.rs @@ -24,14 +24,14 @@ pub unsafe extern "C" fn js_container_pullImage( let reference = match string_from_header(reference_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid image reference".to_string()) }); return promise; } }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { if let Err(e) = maybe_verify_image(&reference).await { return Err::(e); } @@ -55,7 +55,7 @@ pub unsafe extern "C" fn js_container_listImages() -> *mut Promise { let promise = js_promise_new_cross_thread(); // Resolves with a JSON-encoded `ImageInfo[]` string. - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let backend = get_global_backend().await.map_err(|e| e.to_string())?; @@ -83,7 +83,7 @@ pub unsafe extern "C" fn js_container_build( let spec_json = string_from_header(spec_ptr).unwrap_or_else(|| "{}".to_string()); let image_name = string_from_header(image_name_ptr).unwrap_or_default(); - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let spec: perry_container_compose::types::ComposeServiceBuild = serde_json::from_str(&spec_json).map_err(|e| format!("Invalid build spec: {}", e))?; @@ -113,14 +113,14 @@ pub unsafe extern "C" fn js_container_removeImage( let reference = match string_from_header(reference_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid image reference".to_string()) }); return promise; } }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let backend = match get_global_backend().await { Ok(b) => Arc::clone(b), Err(e) => return Err::(e.to_string()), diff --git a/crates/perry-stdlib/src/container/lifecycle.rs b/crates/perry-stdlib/src/container/lifecycle.rs index a82d0e2ed8..76d64c6ad8 100644 --- a/crates/perry-stdlib/src/container/lifecycle.rs +++ b/crates/perry-stdlib/src/container/lifecycle.rs @@ -22,15 +22,14 @@ pub unsafe extern "C" fn js_container_run(spec_ptr: *const StringHeader) -> *mut let spec = match types::parse_container_spec(spec_ptr) { Ok(s) => s, Err(e) => { - crate::common::spawn_for_promise( - promise as *mut u8, - async move { Err::(e) }, - ); + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { + Err::(e) + }); return promise; } }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { if let Err(e) = maybe_verify_image(&spec.image).await { return Err::(e); } @@ -71,15 +70,14 @@ pub unsafe extern "C" fn js_container_create(spec_ptr: *const StringHeader) -> * let spec = match types::parse_container_spec(spec_ptr) { Ok(s) => s, Err(e) => { - crate::common::spawn_for_promise( - promise as *mut u8, - async move { Err::(e) }, - ); + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { + Err::(e) + }); return promise; } }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { if let Err(e) = maybe_verify_image(&spec.image).await { return Err::(e); } @@ -116,14 +114,14 @@ pub unsafe extern "C" fn js_container_start(id_ptr: *const StringHeader) -> *mut let id = match string_from_header(id_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid container ID".to_string()) }); return promise; } }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let backend = match get_global_backend().await { Ok(b) => Arc::clone(b), Err(e) => return Err::(e.to_string()), @@ -149,14 +147,14 @@ pub unsafe extern "C" fn js_container_stop( let id = match string_from_header(id_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid container ID".to_string()) }); return promise; } }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let timeout_opt = if timeout < 0 { None } else { @@ -187,14 +185,14 @@ pub unsafe extern "C" fn js_container_remove( let id = match string_from_header(id_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid container ID".to_string()) }); return promise; } }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let backend = match get_global_backend().await { Ok(b) => Arc::clone(b), Err(e) => return Err::(e.to_string()), @@ -234,7 +232,7 @@ pub unsafe extern "C" fn js_container_downByProject( let project = match string_from_header(project_ptr) { Some(s) if !s.is_empty() => s, _ => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("project name required".to_string()) }); return promise; @@ -242,7 +240,7 @@ pub unsafe extern "C" fn js_container_downByProject( }; let opts_json = string_from_header(opts_ptr); - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { use perry_container_compose::compose::{down_by_project, CleanupOptions}; @@ -274,7 +272,7 @@ pub unsafe extern "C" fn js_container_downAll(opts_ptr: *const StringHeader) -> let promise = js_promise_new_cross_thread(); let opts_json = string_from_header(opts_ptr); - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { use perry_container_compose::compose::{down_all, CleanupOptions}; @@ -306,14 +304,14 @@ pub unsafe extern "C" fn js_container_removeIfExists( let id = match string_from_header(id_ptr) { Some(s) if !s.is_empty() => s, _ => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("container ID required".to_string()) }); return promise; } }; - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { use perry_container_compose::compose::remove_if_exists; @@ -365,7 +363,7 @@ pub(crate) fn parse_cleanup_options( pub unsafe extern "C" fn js_container_list(all: i32) -> *mut Promise { let promise = js_promise_new_cross_thread(); - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let backend = get_global_backend().await.map_err(|e| e.to_string())?; @@ -390,7 +388,7 @@ pub unsafe extern "C" fn js_container_inspect(id_ptr: *const StringHeader) -> *m let id = match string_from_header(id_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid container ID".to_string()) }); return promise; @@ -398,7 +396,7 @@ pub unsafe extern "C" fn js_container_inspect(id_ptr: *const StringHeader) -> *m }; // Resolves with a JSON-encoded `ContainerInfo` string. - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let backend = get_global_backend().await.map_err(|e| e.to_string())?; diff --git a/crates/perry-stdlib/src/container/logs_exec.rs b/crates/perry-stdlib/src/container/logs_exec.rs index d03577dd33..5dccc7d886 100644 --- a/crates/perry-stdlib/src/container/logs_exec.rs +++ b/crates/perry-stdlib/src/container/logs_exec.rs @@ -22,7 +22,7 @@ pub unsafe extern "C" fn js_container_logs(id_ptr: *const StringHeader, tail: i3 let id = match string_from_header(id_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid container ID".to_string()) }); return promise; @@ -32,7 +32,7 @@ pub unsafe extern "C" fn js_container_logs(id_ptr: *const StringHeader, tail: i3 let tail_opt = if tail >= 0 { Some(tail as u32) } else { None }; // Resolves with a JSON-encoded `ContainerLogs` string. - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let backend = get_global_backend().await.map_err(|e| e.to_string())?; @@ -65,7 +65,7 @@ pub unsafe extern "C" fn js_container_exec( let id = match string_from_header(id_ptr) { Some(s) => s, None => { - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { Err::("Invalid container ID".to_string()) }); return promise; @@ -77,7 +77,7 @@ pub unsafe extern "C" fn js_container_exec( let workdir = string_from_header(workdir_ptr); // Resolves with a JSON-encoded `ContainerLogs` string. - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let cmd: Vec = cmd_json diff --git a/crates/perry-stdlib/src/container/mod.rs b/crates/perry-stdlib/src/container/mod.rs index 56d92c61c1..cfac4e182d 100644 --- a/crates/perry-stdlib/src/container/mod.rs +++ b/crates/perry-stdlib/src/container/mod.rs @@ -11,6 +11,8 @@ pub mod verification; // Topical FFI sub-modules split out of this trunk (pure code move). mod backend_ctl; mod compose_ffi; +// Drives each operation's compose future on a turnloop worker (tokio lane K). +pub(crate) mod executor; mod images; mod lifecycle; mod logs_exec; @@ -56,7 +58,8 @@ use std::sync::OnceLock; // Global backend instance - initialised once at first use pub(crate) static BACKEND: OnceLock> = OnceLock::new(); -static BACKEND_INIT_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +static BACKEND_INIT_MUTEX: perry_container_compose::rt::Mutex<()> = + perry_container_compose::rt::Mutex::new(()); /// Get or initialise the global backend instance. /// @@ -231,18 +234,19 @@ pub(crate) async fn maybe_verify_image(image: &str) -> Result<(), String> { /// 1. Forces `libperry_stdlib`'s container symbols to be retained (any /// user code calling `js_container_module_init()` will pull in the /// transitively-referenced FFI symbols and prevent dead-strip). -/// 2. Pre-warms the backend singleton when called from a tokio context — -/// avoids paying the probe latency on the first user `run()` call. +/// 2. Pre-warms the backend singleton when called from inside an async +/// context (an `rt::block_on`) — avoids paying the probe latency on the +/// first user `run()` call. /// /// Backend probing is async + may invoke the interactive `BackendInstaller`, /// so we must not block here. Instead we spawn the probe as a detached -/// tokio task; if a tokio runtime isn't yet running (called from `main` -/// before any async setup), the task simply doesn't run and the first -/// real FFI call will trigger probe-on-demand the same way it always has. +/// background task; outside an async context (called from `main` before any +/// async setup) nothing is spawned and the first real FFI call will trigger +/// probe-on-demand the same way it always has. #[no_mangle] pub extern "C" fn js_container_module_init() { - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async { + if perry_container_compose::rt::in_context() { + executor::spawn_detached(async { let _ = get_global_backend().await; }); } @@ -280,44 +284,20 @@ fn install_default_signal_cleanup() { if std::env::var("PERRY_NO_DEFAULT_SIGINT_CLEANUP").is_ok() { return; } - // Need a tokio runtime handle to drive the async `down()` calls - // from inside the signal handler. If there's no current runtime - // (the user invoked module_init before any async work), skip the - // install — the user will set up their own teardown if they need - // signal handling at all. - let rt = match tokio::runtime::Handle::try_current() { - Ok(h) => h, - Err(_) => return, - }; - rt.spawn(async { - // Listen for both SIGINT (Ctrl-C) and SIGTERM (kill) on Unix; - // Windows only delivers Ctrl-C / Ctrl-Break which tokio maps to - // ctrl_c() / ctrl_break(). The select! exits as soon as either - // arrives, then the cleanup runs once. - #[cfg(unix)] - { - use tokio::signal::unix::{signal, SignalKind}; - let mut sigint = match signal(SignalKind::interrupt()) { - Ok(s) => s, - Err(_) => return, - }; - let mut sigterm = match signal(SignalKind::terminate()) { - Ok(s) => s, - Err(_) => return, - }; - let exit_code = tokio::select! { - _ = sigint.recv() => 130, // 128 + SIGINT(2) - _ = sigterm.recv() => 143, // 128 + SIGTERM(15) - }; + // Same gate as the pre-warm above: only from inside an async context + // (the user invoked module_init before any async work → skip the + // install; the user will set up their own teardown if they need signal + // handling at all). + if !perry_container_compose::rt::in_context() { + return; + } + executor::spawn_detached(async { + // SIGINT (Ctrl-C) or SIGTERM (kill) on Unix; Windows delivers only + // console Ctrl-C. Whichever arrives first runs the cleanup once, then + // the process exits 128 + signo (130 / 143). + if let Ok(signal) = perry_container_compose::rt::shutdown_signal().await { drain_compose_handles().await; - std::process::exit(exit_code); - } - #[cfg(not(unix))] - { - if tokio::signal::ctrl_c().await.is_ok() { - drain_compose_handles().await; - std::process::exit(130); - } + std::process::exit(signal.exit_code()); } }); } @@ -355,22 +335,21 @@ mod smoke_tests { use logs_exec::js_container_logs; /// Task 27.1: `js_container_module_init` must be callable without panic - /// outside an active tokio runtime. The link-anchor purpose mustn't - /// depend on async setup. + /// outside an async context. The link-anchor purpose mustn't depend on + /// async setup. #[test] - fn module_init_is_safe_to_call_outside_tokio() { + fn module_init_is_safe_to_call_outside_an_async_context() { js_container_module_init(); } - /// Task 27.1: when called inside a tokio runtime, module_init schedules + /// Task 27.1: when called inside an async context, module_init schedules /// the backend probe without blocking the caller. The detached probe /// task may fail (no backend installed in CI); we only assert the call /// itself returns synchronously without panic and that the runtime is /// still alive afterwards. #[test] - fn module_init_inside_tokio_runtime_does_not_block() { - let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); - rt.block_on(async { + fn module_init_inside_an_async_context_does_not_block() { + perry_container_compose::rt::block_on(async { js_container_module_init(); // If we reach here without hanging, the call returned // synchronously — invariant proved. diff --git a/crates/perry-stdlib/src/container/verification.rs b/crates/perry-stdlib/src/container/verification.rs index 4abd83e9de..2a1639013a 100644 --- a/crates/perry-stdlib/src/container/verification.rs +++ b/crates/perry-stdlib/src/container/verification.rs @@ -26,7 +26,7 @@ pub async fn fetch_image_digest(reference: &str) -> Result { } pub async fn run_cosign_verify(reference: &str, digest: &str) -> VerificationResult { - let output = tokio::process::Command::new("cosign") + let output = perry_container_compose::rt::Command::new("cosign") .args([ "verify", "--certificate-identity", diff --git a/crates/perry-stdlib/src/container/workload.rs b/crates/perry-stdlib/src/container/workload.rs index 51da2d0074..4dfa9661b2 100644 --- a/crates/perry-stdlib/src/container/workload.rs +++ b/crates/perry-stdlib/src/container/workload.rs @@ -74,7 +74,7 @@ pub unsafe extern "C" fn js_workload_runGraph( let graph_json = string_from_header(graph_json_ptr).unwrap_or_else(|| "{}".to_string()); let opts_json = string_from_header(opts_json_ptr).unwrap_or_else(|| "{}".to_string()); - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let graph: perry_container_compose::WorkloadGraph = serde_json::from_str(&graph_json) .map_err(|e| format!("Failed to parse graph: {}", e))?; let opts: perry_container_compose::RunGraphOptions = serde_json::from_str(&opts_json) @@ -107,7 +107,7 @@ pub unsafe extern "C" fn js_workload_inspectGraph(handle_id: i64) -> *mut Promis let promise = js_promise_new_cross_thread(); let id = handle_id as u64; - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let engine = match types::WORKLOAD_HANDLES.get().and_then(|m| m.get(&id)) { @@ -139,7 +139,7 @@ pub unsafe extern "C" fn js_workload_handle_down(handle_id: i64, force: i32) -> let promise = js_promise_new_cross_thread(); let id = handle_id as u64; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let engine = match types::WORKLOAD_HANDLES.get().and_then(|m| m.get(&id)) { Some(e) => e.clone(), None => return Err("Invalid workload handle".to_string()), @@ -166,7 +166,7 @@ pub unsafe extern "C" fn js_workload_handle_status(handle_id: i64) -> *mut Promi let promise = js_promise_new_cross_thread(); let id = handle_id as u64; - crate::common::spawn_for_promise_deferred( + crate::container::executor::spawn_for_promise_deferred( promise as *mut u8, async move { let engine = match types::WORKLOAD_HANDLES.get().and_then(|m| m.get(&id)) { @@ -204,7 +204,7 @@ pub unsafe extern "C" fn js_workload_handle_logs( let node_id = string_from_header(node_id_ptr).unwrap_or_default(); let tail_opt = if tail >= 0 { Some(tail as u32) } else { None }; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let engine = match types::WORKLOAD_HANDLES.get().and_then(|m| m.get(&id)) { Some(e) => e.clone(), None => return Err("Invalid workload handle".to_string()), @@ -235,7 +235,7 @@ pub unsafe extern "C" fn js_workload_handle_exec( let node_id = string_from_header(node_id_ptr).unwrap_or_default(); let cmd_json = string_from_header(cmd_json_ptr).unwrap_or_else(|| "[]".to_string()); - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let cmd: Vec = serde_json::from_str(&cmd_json).unwrap_or_default(); let engine = match types::WORKLOAD_HANDLES.get().and_then(|m| m.get(&id)) { Some(e) => e.clone(), @@ -261,7 +261,7 @@ pub unsafe extern "C" fn js_workload_handle_ps(handle_id: i64) -> *mut Promise { let promise = js_promise_new_cross_thread(); let id = handle_id as u64; - crate::common::spawn_for_promise(promise as *mut u8, async move { + crate::container::executor::spawn_for_promise(promise as *mut u8, async move { let engine = match types::WORKLOAD_HANDLES.get().and_then(|m| m.get(&id)) { Some(e) => e.clone(), None => return Err("Invalid workload handle".to_string()), diff --git a/crates/perry-stdlib/tests/container_backend_selection.rs b/crates/perry-stdlib/tests/container_backend_selection.rs index 9572db08ed..9caaa84a8a 100644 --- a/crates/perry-stdlib/tests/container_backend_selection.rs +++ b/crates/perry-stdlib/tests/container_backend_selection.rs @@ -287,56 +287,58 @@ fn select_backend_for_null_spec_returns_null() { } } -#[tokio::test] -async fn probe_all_candidates_returns_full_priority_list() { - // The contract for `probe_all_candidates()` (the Rust function - // backing `getAvailableBackends()`): - // - // 1. Always returns one entry per `platform_candidates()` name - // 2. Never short-circuits — full list even if first candidate is - // installed (distinguishing from detect_backend's behavior) - // 3. Order matches the priority list - // 4. Every entry has the consistent shape: name + available + reason - // 5. available=true ↔ reason is empty - // 6. available=false ↔ reason explains why - // - // We call the Rust function directly here. The FFI wrapper - // (`js_container_getAvailableBackends`) is a thin - // `spawn_for_promise_deferred` over this function — its correctness - // follows from the wrapping pattern, which other tests in the suite - // exercise via the existing setBackend / detectBackend FFIs. - - let priority = perry_container_compose::platform_candidates(); - let probed = perry_container_compose::probe_all_candidates().await; +#[test] +fn probe_all_candidates_returns_full_priority_list() { + perry_container_compose::rt::block_on(async { + // The contract for `probe_all_candidates()` (the Rust function + // backing `getAvailableBackends()`): + // + // 1. Always returns one entry per `platform_candidates()` name + // 2. Never short-circuits — full list even if first candidate is + // installed (distinguishing from detect_backend's behavior) + // 3. Order matches the priority list + // 4. Every entry has the consistent shape: name + available + reason + // 5. available=true ↔ reason is empty + // 6. available=false ↔ reason explains why + // + // We call the Rust function directly here. The FFI wrapper + // (`js_container_getAvailableBackends`) is a thin + // `spawn_for_promise_deferred` over this function — its correctness + // follows from the wrapping pattern, which other tests in the suite + // exercise via the existing setBackend / detectBackend FFIs. - assert_eq!( - probed.len(), - priority.len(), - "must return ONE entry per platform candidate; expected {} got {}", - priority.len(), - probed.len() - ); + let priority = perry_container_compose::platform_candidates(); + let probed = perry_container_compose::probe_all_candidates().await; - for (i, entry) in probed.iter().enumerate() { assert_eq!( - entry.name, priority[i], - "entry {i} must match priority list at same index" + probed.len(), + priority.len(), + "must return ONE entry per platform candidate; expected {} got {}", + priority.len(), + probed.len() ); - assert!(!entry.name.is_empty(), "every entry must name a backend"); - if entry.available { - assert!( - entry.reason.is_empty(), - "available=true entry must have empty reason; got {:?}", - entry.reason - ); - } else { - assert!( - !entry.reason.is_empty(), - "available=false entry must explain why; got empty for {:?}", - entry.name + + for (i, entry) in probed.iter().enumerate() { + assert_eq!( + entry.name, priority[i], + "entry {i} must match priority list at same index" ); + assert!(!entry.name.is_empty(), "every entry must name a backend"); + if entry.available { + assert!( + entry.reason.is_empty(), + "available=true entry must have empty reason; got {:?}", + entry.reason + ); + } else { + assert!( + !entry.reason.is_empty(), + "available=false entry must explain why; got empty for {:?}", + entry.name + ); + } } - } + }) } #[test] diff --git a/crates/perry-stdlib/tests/container_ffi_tests.rs b/crates/perry-stdlib/tests/container_ffi_tests.rs index afaae76e2a..54e5a8170a 100644 --- a/crates/perry-stdlib/tests/container_ffi_tests.rs +++ b/crates/perry-stdlib/tests/container_ffi_tests.rs @@ -63,35 +63,41 @@ unsafe fn await_promise_sync(promise: *mut Promise) -> Result { // ========== js_container_run ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_run_null() { - unsafe { - let p = perry_stdlib::container::js_container_run(null()); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_run_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_run(null()); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_list ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_list_contract() { - unsafe { - let p = perry_stdlib::container::js_container_list(1); - let _ = await_promise_sync(p); - } +#[test] +fn test_js_container_list_contract() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_list(1); + let _ = await_promise_sync(p); + } + }) } // ========== js_container_listImages ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_list_images_contract() { - unsafe { - let p = perry_stdlib::container::js_container_listImages(); - let _ = await_promise_sync(p); - } +#[test] +fn test_js_container_list_images_contract() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_listImages(); + let _ = await_promise_sync(p); + } + }) } // ========== js_container_getBackend ========== @@ -108,189 +114,222 @@ fn test_js_container_get_backend_contract() { // ========== js_container_detectBackend ========== // Feature: perry-container | Layer: ffi-contract | Req: 1.8 | Property: - -#[tokio::test] -async fn test_js_container_detect_backend_contract() { - unsafe { - let p = perry_stdlib::container::js_container_detectBackend(); - let _ = await_promise_sync(p); - } +#[test] +fn test_js_container_detect_backend_contract() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_detectBackend(); + let _ = await_promise_sync(p); + } + }) } // ========== js_container_compose_ps ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_compose_ps_contract() { - unsafe { - let p = perry_stdlib::container::js_container_compose_ps(0.0); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_compose_ps_contract() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_compose_ps(0.0); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_compose_logs ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_compose_logs_null() { - unsafe { - let p = perry_stdlib::container::js_container_compose_logs(0.0, null(), 10.0); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_compose_logs_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_compose_logs(0.0, null(), 10.0); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_compose_exec ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_compose_exec_null() { - unsafe { - let p = perry_stdlib::container::js_container_compose_exec(0.0, null(), null()); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_compose_exec_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_compose_exec(0.0, null(), null()); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_run_malformed() { - unsafe { - let header = make_string_header("{ bad json"); - let p = perry_stdlib::container::js_container_run(header.as_ptr() as *const StringHeader); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_run_malformed() { + perry_container_compose::rt::block_on(async { + unsafe { + let header = make_string_header("{ bad json"); + let p = + perry_stdlib::container::js_container_run(header.as_ptr() as *const StringHeader); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_create ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_create_null() { - unsafe { - let p = perry_stdlib::container::js_container_create(null()); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_create_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_create(null()); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_start ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_start_null() { - unsafe { - let p = perry_stdlib::container::js_container_start(null()); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_start_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_start(null()); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_stop ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_stop_null() { - unsafe { - let p = perry_stdlib::container::js_container_stop(null(), 10); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_stop_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_stop(null(), 10); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_remove ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_remove_null() { - unsafe { - let p = perry_stdlib::container::js_container_remove(null(), 1); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_remove_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_remove(null(), 1); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_inspect ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_inspect_null() { - unsafe { - let p = perry_stdlib::container::js_container_inspect(null()); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_inspect_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_inspect(null()); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_logs ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_logs_null() { - unsafe { - let p = perry_stdlib::container::js_container_logs(null(), 10); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_logs_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_logs(null(), 10); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_exec ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_exec_null() { - unsafe { - let p = perry_stdlib::container::js_container_exec(null(), null(), null(), null()); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_exec_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_exec(null(), null(), null(), null()); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_pullImage ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_pull_image_null() { - unsafe { - let p = perry_stdlib::container::js_container_pullImage(null()); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_pull_image_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_pullImage(null()); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_removeImage ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_remove_image_null() { - unsafe { - let p = perry_stdlib::container::js_container_removeImage(null(), 0); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_remove_image_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_removeImage(null(), 0); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_composeUp ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_compose_up_null() { - unsafe { - let p = perry_stdlib::container::js_container_composeUp(null()); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_compose_up_null() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_composeUp(null()); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } // ========== js_container_compose_down ========== // Feature: perry-container | Layer: ffi-contract | Req: 11.7 | Property: - -#[tokio::test] -async fn test_js_container_compose_down_contract() { - unsafe { - let p = perry_stdlib::container::js_container_compose_down(0.0, null()); - let res = await_promise_sync(p); - assert!(res.is_err()); - } +#[test] +fn test_js_container_compose_down_contract() { + perry_container_compose::rt::block_on(async { + unsafe { + let p = perry_stdlib::container::js_container_compose_down(0.0, null()); + let res = await_promise_sync(p); + assert!(res.is_err()); + } + }) } diff --git a/crates/perry-stdlib/tests/container_verification_tests.rs b/crates/perry-stdlib/tests/container_verification_tests.rs index da86cca4a7..de0fbeac6b 100644 --- a/crates/perry-stdlib/tests/container_verification_tests.rs +++ b/crates/perry-stdlib/tests/container_verification_tests.rs @@ -1,19 +1,20 @@ #![cfg(feature = "container")] use perry_stdlib::container::verification::*; -use tokio; // Feature: perry-container | Layer: unit | Req: 15.4 | Property: 10 -#[tokio::test] -async fn test_get_chainguard_image() { - assert_eq!( - get_chainguard_image("git").unwrap(), - "cgr.dev/chainguard/git" - ); - assert_eq!( - get_chainguard_image("python").unwrap(), - "cgr.dev/chainguard/python" - ); - assert!(get_chainguard_image("unknown-tool").is_none()); +#[test] +fn test_get_chainguard_image() { + perry_container_compose::rt::block_on(async { + assert_eq!( + get_chainguard_image("git").unwrap(), + "cgr.dev/chainguard/git" + ); + assert_eq!( + get_chainguard_image("python").unwrap(), + "cgr.dev/chainguard/python" + ); + assert!(get_chainguard_image("unknown-tool").is_none()); + }) } // Feature: perry-container | Layer: unit | Req: 14.1 | Property: - diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index 4895663d7e..1e0a1c4553 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -580,7 +580,7 @@ pub(crate) fn build_optimized_libs( // so tokio was in every stdlib-linking binary. The bridge is tokio-free // now, and `async-runtime` is selected only by a feature that hands tokio // a future (Cargo implies it: web-fetch, bundled net/tls/ws, the - // external net/ws/http pumps, container) or by a shared-tokio wrapper + // external net/ws/http pumps) or by a shared-tokio wrapper // (above). A program that needs none of those links no tokio. features.insert("async-bridge"); let feature_arg = features_to_cargo_arg(&features); diff --git a/docs/turnloop/p8-report.md b/docs/turnloop/p8-report.md index 18adfdc3d1..17e54709ff 100644 --- a/docs/turnloop/p8-report.md +++ b/docs/turnloop/p8-report.md @@ -73,7 +73,9 @@ report: * **They are incomplete, and nothing could tell you.** No lane report names the `perry` CLI, `perry-container-compose` or `perry-ui-gtk4`. Between them those hold **6 of the 46 edges** — a seventh of the problem, invisible because no - lane's scope included a crate with no JS surface, and no lane was measuring + lane's scope included a crate outside the default runtime build (the CLI and + gtk4 have no JS surface; perry-container-compose is reached only through + perry-stdlib's non-default `container` feature), and no lane was measuring edges in the first place. * **They scope the same blocker differently each time.** Every lane from P1 on says some version of "a worker agent has no loop". None says that it is *one @@ -140,7 +142,7 @@ is tracked — is in `scripts/tokio_inventory.json` and renders with | crate | tokio-family deps | reached from JS by | status | |---|---|---|---| | `perry` | reqwest, tokio, tokio-tungstenite | **nothing** — the CLI's `publish`/`login`/`verify`/`audit`/`run --remote`/`setup`/update-check | never linked into a compiled program | -| `perry-container-compose` | tokio (normal + dev) | **nothing** — the separate `perry-compose` binary | not in `full`; no JS surface | +| `perry-container-compose` | ~~tokio (normal + dev)~~ | `import … from 'perry/container'` / `'perry/compose'` / `'perry/workloads'` (perry-stdlib's non-default `container` feature), and the separate `perry-compose` binary | **done (lane K)** — the engine runs on turnloop through its own `rt` module; neither edge remains | | `perry-ext-axios` | reqwest, tokio | `import axios` (its own `js_axios_*` symbols; it does **not** take the global `fetch` with it — measured) | never migrated | | `perry-ext-fetch` | reqwest, tokio | `import 'node-fetch'` (and the bare `fetch` alias) — and it defines the **same `js_fetch_*` symbols** perry-stdlib owns | never migrated; the overlap SIGSEGVs, see defect 1 | | `perry-ext-fastify` | hyper, hyper-util, tokio, tokio-tungstenite | `import Fastify` | never migrated — own accept loop, no edge to perry-ext-http | @@ -595,10 +597,10 @@ fifteenth item late. | **H** | **`perry-stdlib`'s bundled `pg`/`mysql2`/`ioredis`/`mongodb`, its `ws` module and its hyper framework server** — all compiled out of every default build, so this is a policy call about whether the fallback stays, not a transport one | **6** — `perry-stdlib`'s `sqlx`, `redis`, `mongodb`, `hyper`, `hyper-util`, `tokio-rustls` | small as code, a decision as policy. It is the cheapest lockfile reduction in the tree | | **I** | **lettre's async transport** — lets `bundled-nodemailer` drop `tokio1` / `tokio1-rustls-tls` / `pool` and keep only the MIME builder, which stays forever (`turnloop-smtp` re-exports it). Gated on A. | **3** — `perry-ext-nodemailer` × 2, `perry-stdlib`'s `lettre` | small | | **J** | **The `perry` CLI** — `publish`, `login`, `verify`, `audit`, `run --remote`, `setup`, the update check, telemetry, compat reports. 14 `reqwest::Client` constructions (7 blocking, 7 async) across 11 files, 7 `Runtime::new` sites, 2 WebSocket clients | **3** — `perry` × 3 | medium, and it needs multipart in `turnloop-http`'s client, which does not have it | -| **K** | **`perry-compose`** | **2** — `perry-container-compose` normal + dev | a rewrite of a 14.8k-line async tool with no JS surface | +| **K** | **`perry-container-compose`** — the engine behind `perry/container`, `perry/compose` and `perry/workloads` (perry-stdlib's `container` feature) and the `perry-compose` binary | **2** — `perry-container-compose` normal + dev | **done.** Not a rewrite: the async code stays async; its leaves (the backend CLI child processes, timeouts, the async mutex) moved onto a turnloop-backed `rt::block_on`, and `container` needs only the promise bridge | | **L** | **`perry-stdlib`'s `tokio`** — the `async-runtime` feature, `common::async_bridge`, and the `perry_ffi_spawn_blocking*` / `spawn_async` C ABI | **1** — the last edge | falls out of A–K; see below | | **M** | ~~**`perry-ui-gtk4`** — `ksni` and `mpris-server` *require* tokio~~ — **this was wrong, and the edge is gone.** Neither crate requires tokio. `ksni`'s `async-io` feature is a first-class alternative to its `tokio` default (the two are mutually exclusive — `ksni::compat` has a `compile_error!` if both are on) and carries its own executor thread; `mpris-server`'s `tokio` feature is opt-in, is not in its defaults, and only forwards to `zbus/tokio`, which zbus needs no more than any of its other executor backends. Perry had asked for both features and then kept a direct tokio dependency to feed them. Removed with tray and MPRIS intact — `docs/turnloop/gtk4-report.md` | **1** | **done.** No crate replaced, no capability dropped | -| **N** | **`perry-ui-android`'s `tungstenite`** — sync 0.24 on its own thread. **Not a tokio edge**; listed because it pins the third tungstenite major in the tree, which is part of E's cost | **1** | small, and only worth doing with E | +| **N** | **`perry-ui-android`'s `tungstenite`** — synchronous tungstenite (0.24 when this was written, 0.30 since #11065) on a std background thread per connection, with no tokio anywhere in its graph. **Not a tokio edge**; listed because it pins the third tungstenite major in the tree, which is part of E's cost | **1** | small, and only worth doing with E | | | | **46** | | ### Why L is genuinely last, and not a layer you can lift out first diff --git a/scripts/tokio_inventory.json b/scripts/tokio_inventory.json index fca9e0cbbc..7c58bb574d 100644 --- a/scripts/tokio_inventory.json +++ b/scripts/tokio_inventory.json @@ -25,30 +25,6 @@ "that says how much code sits behind an edge." ], "edges": [ - { - "crate": "perry-container-compose", - "dep": "tokio", - "kind": "dev", - "optional": false, - "target": null, - "surface": "none. The separate `perry-compose` binary (a Docker-Compose-like driver for Apple Container / Podman). perry-stdlib reaches it only under the non-default `container` feature, which `full` does not enable.", - "reached_when": "running `perry-compose`, or a build that explicitly enables `perry-stdlib/container`", - "blocker": "async end-to-end across ~14.8k lines (`async-trait` backends, `tokio::process::Command`, `tokio::time::timeout`). A rewrite of a non-JS tool; no event-loop transport is involved.", - "issue": "unfiled \u2014 P8", - "plan": "K" - }, - { - "crate": "perry-container-compose", - "dep": "tokio", - "kind": "normal", - "optional": false, - "target": null, - "surface": "none. The separate `perry-compose` binary (a Docker-Compose-like driver for Apple Container / Podman). perry-stdlib reaches it only under the non-default `container` feature, which `full` does not enable.", - "reached_when": "running `perry-compose`, or a build that explicitly enables `perry-stdlib/container`", - "blocker": "async end-to-end across ~14.8k lines (`async-trait` backends, `tokio::process::Command`, `tokio::time::timeout`). A rewrite of a non-JS tool; no event-loop transport is involved.", - "issue": "unfiled \u2014 P8", - "plan": "K" - }, { "crate": "perry-ext-http", "dep": "tokio", @@ -116,8 +92,8 @@ "optional": true, "target": null, "surface": "the tokio HALF of the async bridge only (turnloop P8 lane L split it out): `common::tokio_bridge` \u2014 the current-thread `RUNTIME`, its wait-driver tick, `spawn` / `spawn_for_promise*` \u2014 plus the `perry_ffi_spawn_async` / `perry_ffi_spawn_blocking_with_reactor` C ABI and `perry_ffi_spawn_blocking`'s tokio-pool arm. The promise bridge itself (`common::async_bridge`'s settle queue and pump, and the promise / pool / blocking `perry_ffi_*` shims) is tokio-free under the `async-bridge` feature, which is what the auto-optimize driver now force-enables.", - "reached_when": "the `async-runtime` feature, which is selected only by (1) a Cargo feature whose code hands tokio a future \u2014 `web-fetch` (reqwest), `bundled-net` / `tls-runtime` / `external-tls-server` / `external-net-tls` / `bundled-ws` (tokio sockets), `external-net-pump` / `external-ws-pump` / `external-http-server-pump` / `external-http-client-pump` (perry-ext-net / -ws / -http hand futures to `perry_ffi_spawn_async`), `container`; or (2) the auto-optimize driver, for every shared-tokio wrapper (`binding_needs_shared_tokio`: net, ws, http, https, http2, undici, fastify, mongodb, ioredis, redis, nodemailer) and for pg / mysql2. `full` still implies it, so every PERRY_NO_AUTO_OPTIMIZE / prebuilt-archive build links tokio. It is NO LONGER forced onto every auto-optimized program: one that uses only crypto, bcrypt, argon2, zlib (bundled or perry-ext-zlib), readline, worker_threads, timers or a UI backend links no tokio.", - "blocker": "each selector in `reached_when` has to go; then `tokio_bridge.rs` and the three `cfg(feature = \"async-runtime\")` shims in `perry_ffi_async.rs` delete whole and `async-runtime` collapses into `async-bridge` \u2014 nothing in `async_bridge.rs` has to move. In order: G (#11101) leaves `web-fetch` tokio-free, after which it needs only `async-bridge`; H (#11102) plus P1 put the bundled net / tls / ws sockets on turnloop handles; A moved perry-ext-net off tokio and tokio-rustls (#11105, landed in merge train 266); perry-ext-http's `perry_ffi_spawn_async` / `_with_reactor` use is what remains of that step; B removes the db wrappers' decline paths, which call `Handle::current()` inside `perry_ffi_spawn_blocking` (with perry-ext-net's `upgradeTLS` reply wait, the only reason that shim still needs tokio's pool in a tokio build); K takes `container` off tokio. PerryTS/turnloop#42 is NOT a blocker any more: `Occupancy::Long` shipped in turnloop 0.1.0-alpha.5 (Perry pins alpha.6), and the tokio-free `perry_ffi_spawn_blocking` already runs on it through `turnloop_pool::submit_long`.", + "reached_when": "the `async-runtime` feature, which is selected only by (1) a Cargo feature whose code hands tokio a future \u2014 `web-fetch` (reqwest), `bundled-net` / `tls-runtime` / `external-tls-server` / `external-net-tls` / `bundled-ws` (tokio sockets), `external-net-pump` / `external-ws-pump` / `external-http-server-pump` / `external-http-client-pump` (perry-ext-net / -ws / -http hand futures to `perry_ffi_spawn_async`); or (2) the auto-optimize driver, for every shared-tokio wrapper (`binding_needs_shared_tokio`: net, ws, http, https, http2, undici, fastify, mongodb, ioredis, redis, nodemailer) and for pg / mysql2. `full` still implies it, so every PERRY_NO_AUTO_OPTIMIZE / prebuilt-archive build links tokio. It is NO LONGER forced onto every auto-optimized program: one that uses only crypto, bcrypt, argon2, zlib (bundled or perry-ext-zlib), readline, worker_threads, timers or a UI backend links no tokio.", + "blocker": "each selector in `reached_when` has to go; then `tokio_bridge.rs` and the three `cfg(feature = \"async-runtime\")` shims in `perry_ffi_async.rs` delete whole and `async-runtime` collapses into `async-bridge` \u2014 nothing in `async_bridge.rs` has to move. In order: G (#11101) leaves `web-fetch` tokio-free, after which it needs only `async-bridge`; H (#11102) plus P1 put the bundled net / tls / ws sockets on turnloop handles; A moved perry-ext-net off tokio and tokio-rustls (#11105, landed in merge train 266); perry-ext-http's `perry_ffi_spawn_async` / `_with_reactor` use is what remains of that step; B removes the db wrappers' decline paths, which call `Handle::current()` inside `perry_ffi_spawn_blocking` (with perry-ext-net's `upgradeTLS` reply wait, the only reason that shim still needs tokio's pool in a tokio build). K took `container` off it: the compose engine runs on turnloop through `perry_container_compose::rt`, so `container` implies only `async-bridge`. PerryTS/turnloop#42 is NOT a blocker any more: `Occupancy::Long` shipped in turnloop 0.1.0-alpha.5 (Perry pins alpha.6), and the tokio-free `perry_ffi_spawn_blocking` already runs on it through `turnloop_pool::submit_long`.", "issue": "unfiled \u2014 P8; PerryTS/turnloop#42 closed (Occupancy::Long, turnloop 0.1.0-alpha.5+); lane L split the bridge from the runtime", "plan": "L" }, @@ -127,9 +103,9 @@ "kind": "normal", "optional": false, "target": "cfg(target_os = \"android\")", - "surface": "`perry/ui` WebSocket on Android (`crates/perry-ui-android/src/ws.rs`)", + "surface": "`perry/ui` WebSocket on Android (`crates/perry-ui-android/src/ws.rs`) \u2014 synchronous tungstenite 0.30 (`tungstenite::connect` over a blocking `std::net::TcpStream`), one std background thread per connection. No tokio: `cargo tree -p perry-ui-android -i tokio --target aarch64-linux-android` finds no tokio package in its graph.", "reached_when": "an Android target build only", - "blocker": "SYNC tungstenite on its own thread \u2014 not a tokio edge at all. #11065 bumped this crate's pin from 0.24 to 0.30, so the tree-wide tungstenite major-version split is gone: this edge now resolves to the same 0.30.0 as turnloop-websocket. `turnloop_websocket::Connection` works over a blocking `std::net::TcpStream` as happily as over anything else, so the migration off tungstenite entirely is still mechanical \u2014 but it cannot be built or run from the shared Linux box, and an unexecutable migration is not one to land blind.", + "blocker": "Nothing tokio-shaped: this is not a tokio edge at all, it is listed only because tungstenite is in the tokio family this inventory tracks. #11065 bumped this crate's pin from 0.24 to 0.30, so the tree-wide tungstenite major-version split is gone: this edge now resolves to the same 0.30.0 as turnloop-websocket. `turnloop_websocket::Connection` works over a blocking `std::net::TcpStream` as happily as over anything else, so the migration off tungstenite entirely is still mechanical \u2014 but it cannot be built or run from the shared Linux box, and an unexecutable migration is not one to land blind.", "issue": "unfiled \u2014 P8", "plan": "N" } @@ -159,13 +135,13 @@ }, "source_sites": { "perry": 3, - "perry-container-compose": 14, + "perry-container-compose": 27, "perry-ext-ads": 5, "perry-ext-http": 7, "perry-ext-ioredis": 13, "perry-ext-mongodb": 29, "perry-ffi": 2, - "perry-stdlib": 80, + "perry-stdlib": 69, "perry-ui-gtk4": 6 } } From e6972e7fd13ce965d82998f072eb61b01c78328a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 24 Sep 2026 10:34:55 +0000 Subject: [PATCH 2/2] changelog: fragment for #11209 --- changelog.d/11209-container-compose-on-turnloop.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11209-container-compose-on-turnloop.md diff --git a/changelog.d/11209-container-compose-on-turnloop.md b/changelog.d/11209-container-compose-on-turnloop.md new file mode 100644 index 0000000000..ecb43fcf75 --- /dev/null +++ b/changelog.d/11209-container-compose-on-turnloop.md @@ -0,0 +1 @@ +- **perry-container-compose runs on turnloop; no tokio (tokio lane K)** (#11209) — the compose engine behind `perry/container`, `perry/compose` and `perry/workloads` keeps its async code but gets its leaves and executor from a new `perry_container_compose::rt` module: `Command` (the docker / podman / apple-container CLI via turnloop's `Driver::spawn`, stdout and stderr read to EOF with multishot reads, completing on the reaped exit status), `sleep` / `timeout` (turnloop timers), `shutdown_signal` (SIGINT / SIGTERM), `Mutex` (async-lock, already in `Cargo.lock`) and `block_on` / `try_block_on`, which own one turnloop loop per call. The `perry-compose` binary and all 61 former `#[tokio::test]`s (compose + stdlib container tests) run on `rt::block_on`. perry-stdlib's `container` feature now implies only `async-bridge`: `container/executor.rs` runs each operation on turnloop's `Occupancy::Long` pool (plain-thread fallback) and settles through the tokio-free promise bridge. `cargo tree -p perry-container-compose -i tokio -e normal,dev,build` and `cargo tree -p perry-stdlib --no-default-features --features container -i tokio` both come back empty, and `scripts/tokio_inventory.json` loses both group-K edges. Behaviour change: a CLI call aborted by `PERRY_CONTAINER_OP_TIMEOUT_SECS` is now terminated instead of being left running (tokio's `output()` did not kill on drop). Also repairs the six `set_backend[s]_rejects_*` tests in `container_backend_selection.rs`, which never settled on the tokio path because their pump did not tick tokio.