diff --git a/.claude/skills/running-tests.md b/.claude/skills/running-tests.md new file mode 100644 index 00000000..1802b2aa --- /dev/null +++ b/.claude/skills/running-tests.md @@ -0,0 +1,36 @@ +# Running Tests + +## Prerequisites + +Before running tests, you must build the release binaries: + +```bash +cargo build --release +``` + +## Running Integration Tests + +Use the following command to run integration tests: + +```bash +yarn test:integration +``` + +For example: +- Run all tests: `yarn test:integration` +- Run a specific test file: `yarn test:integration path/to/test.ts` +- Run tests matching a pattern: `yarn test:integration -t "pattern"` +- Run in watch mode: `yarn test:integration --watch` + +## Reading spawn logs + +You can access the logs of any Yarn command spawned within a test by adding the `JEST_LOG_SPAWNS=1` environment variable. + +## Creating temporary projects + +You can setup temporary projects by: + +1. Creating a new temporary folder +2. Adding an empty `package.json` file +3. Running `yarn switch link path/to/target/Release/yarn-bin` inside this temporary folder +4. Subsequent `yarn` commands should then use the local binary diff --git a/Cargo.lock b/Cargo.lock index 6757ad9f..ead3b413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1036,6 +1036,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1193,6 +1199,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "git-url-parse" version = "0.4.6" @@ -1265,6 +1284,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1558,6 +1586,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1758,6 +1792,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" @@ -2280,6 +2320,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -3417,6 +3467,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3560,6 +3622,23 @@ dependencies = [ "termcolor", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3626,6 +3705,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3640,11 +3725,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.1", "js-sys", "wasm-bindgen", ] @@ -3698,6 +3783,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -3757,6 +3851,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "wax" version = "0.6.0" @@ -4129,6 +4257,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -4261,6 +4471,7 @@ checksum = "1966f8ac2c1f76987d69a74d0e0f929241c10e78136434e3be70ff7f58f64214" name = "zpm" version = "0.0.0" dependencies = [ + "async-trait", "base64", "brotli", "bytes", @@ -4283,6 +4494,7 @@ dependencies = [ "indexmap 2.13.0", "interprocess", "itertools 0.14.0", + "libc", "open", "p256", "rand", @@ -4299,8 +4511,10 @@ dependencies = [ "spki", "thiserror 2.0.18", "tokio", + "tokio-tungstenite", "tower", "url", + "uuid", "wax", "zpm-allocator", "zpm-config", @@ -4438,14 +4652,18 @@ dependencies = [ "assert_cmd", "chrono", "clipanion", + "futures", + "libc", "regex", "reqwest", "rkyv", "serde", + "serde_json", "serde_plain", "serde_with", "thiserror 2.0.18", "tokio", + "tokio-tungstenite", "zpm-allocator", "zpm-formats", "zpm-macro-enum", @@ -4489,6 +4707,7 @@ dependencies = [ "fundu", "hex", "indexmap 2.13.0", + "libc", "num", "ouroboros", "rkyv", diff --git a/Cargo.toml b/Cargo.toml index 987fb336..33cff04f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,7 @@ thiserror = "2.0.12" timeago = "0.5.0" tikv-jemallocator = "0.6.0" tokio = { version = "1.39.2", features = ["full"] } +tokio-tungstenite = "0.26" tower = { version = "0.5.2", features = ["limit"] } serde_json = "1.0.145" serde = { version = "1.0.207", features = ["derive"] } diff --git a/packages/zpm-config/schema.json b/packages/zpm-config/schema.json index e9554990..aed1439d 100644 --- a/packages/zpm-config/schema.json +++ b/packages/zpm-config/schema.json @@ -23,6 +23,16 @@ "type": ["zpm_formats::CompressionAlgorithm", "null"], "description": "The compression level to use for the packed file" }, + "daemonOutputBufferMaxLines": { + "type": "usize", + "description": "Maximum number of output lines to keep in memory per task in the daemon", + "default": 1000 + }, + "daemonMaxClosedTasks": { + "type": "usize", + "description": "Maximum number of completed/failed tasks whose output buffers are kept in memory by the daemon", + "default": 100 + }, "defaultSemverRangePrefix": { "type": "zpm_semver::RangeKind", "description": "The default semver range prefix to use for dependencies", diff --git a/packages/zpm-switch/Cargo.toml b/packages/zpm-switch/Cargo.toml index febc8cfd..13df4c05 100644 --- a/packages/zpm-switch/Cargo.toml +++ b/packages/zpm-switch/Cargo.toml @@ -12,11 +12,14 @@ clipanion = { workspace = true, features = ["serde"] } regex = { workspace = true } reqwest = { workspace = true, default-features = false, features = ["hickory-dns", "rustls-tls"] } rkyv = { workspace = true, features = ["bytecheck"] } +serde_json = { workspace = true } serde_plain = { workspace = true } serde_with = { workspace = true } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } +tokio-tungstenite = { workspace = true } +futures = { workspace = true } zpm-allocator = { workspace = true } zpm-formats = { workspace = true } zpm-macro-enum = { workspace = true } @@ -25,5 +28,8 @@ zpm-semver = { workspace = true } zpm-utils = { workspace = true } chrono = { workspace = true, features = ["serde"] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] assert_cmd = { workspace = true } diff --git a/packages/zpm-switch/src/cache.rs b/packages/zpm-switch/src/cache.rs index d0d1c58e..e36ca2c5 100644 --- a/packages/zpm-switch/src/cache.rs +++ b/packages/zpm-switch/src/cache.rs @@ -18,6 +18,11 @@ pub struct CacheKey { pub platform: String, } +fn get_npm_registry_server() -> String { + std::env::var("YARNSW_NPM_REGISTRY_SERVER") + .unwrap_or_else(|_| "https://registry.npmjs.org".to_string()) +} + impl CacheKey { pub fn to_npm_url(&self) -> Option { if self.version.rc.as_ref().map_or(true, |rc| !rc.starts_with(&[VersionRc::String("git".into())])) { @@ -30,7 +35,8 @@ impl CacheKey { ); if self.version >= first_npm_release { - return Some(format!("https://registry.npmjs.org/@yarnpkg/yarn-{}/-/yarn-{}-{}.tgz", self.platform, self.platform, self.version.to_file_string())); + let registry = get_npm_registry_server(); + return Some(format!("{}/@yarnpkg/yarn-{}/-/yarn-{}-{}.tgz", registry, self.platform, self.platform, self.version.to_file_string())); } } diff --git a/packages/zpm-switch/src/commands/mod.rs b/packages/zpm-switch/src/commands/mod.rs index 1a28b3b0..78bc4215 100644 --- a/packages/zpm-switch/src/commands/mod.rs +++ b/packages/zpm-switch/src/commands/mod.rs @@ -16,6 +16,10 @@ enum SwitchExecCli { CacheInstallCommand(switch::cache_install::CacheInstallCommand), CacheListCommand(switch::cache_list::CacheListCommand), ClipanionCommandsCommand(switch::clipanion_commands::ClipanionCommandsCommand), + DaemonKillAllCommand(switch::daemon_kill_all::DaemonKillAllCommand), + DaemonKillCommand(switch::daemon_kill::DaemonKillCommand), + DaemonListCommand(switch::daemon_list::DaemonListCommand), + DaemonOpenCommand(switch::daemon_open::DaemonOpenCommand), ExplicitCommand(switch::explicit::ExplicitCommand), LinksListCommand(switch::links_list::LinksListCommand), LinksClearCommand(switch::links_clear::LinksClearCommand), diff --git a/packages/zpm-switch/src/commands/switch/daemon_kill.rs b/packages/zpm-switch/src/commands/switch/daemon_kill.rs new file mode 100644 index 00000000..8d51fe39 --- /dev/null +++ b/packages/zpm-switch/src/commands/switch/daemon_kill.rs @@ -0,0 +1,70 @@ +use clipanion::cli; +use zpm_utils::{DataType, ToHumanString}; + +use crate::{ + cwd::get_final_cwd, + daemons, + errors::Error, + manifest::find_closest_package_manager, +}; + +#[cli::command] +#[cli::path("switch", "daemon")] +#[cli::category("Daemon management")] +#[derive(Debug)] +pub struct DaemonKillCommand { + #[cli::option("--kill")] + _kill: bool, +} + +impl DaemonKillCommand { + pub async fn execute(&self) -> Result<(), Error> { + let project_cwd = get_final_cwd()?; + + let find_result = find_closest_package_manager(&project_cwd)?; + + let detected_root = find_result + .detected_root_path + .ok_or(Error::NoProjectFound)?; + + let Some(daemon) = daemons::get_daemon(&detected_root)? else { + println!( + "{} No daemon registered for this project", + DataType::Info.colorize("ℹ") + ); + return Ok(()); + }; + + if !daemons::is_process_alive(daemon.pid) { + daemons::unregister_daemon(&detected_root)?; + println!( + "{} Daemon was not running (cleaned up stale entry)", + DataType::Info.colorize("ℹ") + ); + return Ok(()); + } + + let pid = daemon.pid; + let success = tokio::task::spawn_blocking(move || daemons::kill_daemon_gracefully(pid)) + .await + .unwrap_or(false); + + if success { + daemons::unregister_daemon(&detected_root)?; + println!( + "{} Stopped daemon for {} (PID: {})", + DataType::Success.colorize("✓"), + detected_root.to_print_string(), + daemon.pid + ); + } else { + println!( + "{} Failed to stop daemon (PID: {})", + DataType::Error.colorize("✗"), + daemon.pid + ); + } + + Ok(()) + } +} diff --git a/packages/zpm-switch/src/commands/switch/daemon_kill_all.rs b/packages/zpm-switch/src/commands/switch/daemon_kill_all.rs new file mode 100644 index 00000000..069ba372 --- /dev/null +++ b/packages/zpm-switch/src/commands/switch/daemon_kill_all.rs @@ -0,0 +1,88 @@ +use clipanion::cli; +use zpm_utils::{DataType, ToHumanString}; + +use crate::{daemons, errors::Error}; + +#[cli::command] +#[cli::path("switch", "daemon")] +#[cli::category("Daemon management")] +#[derive(Debug)] +pub struct DaemonKillAllCommand { + #[cli::option("--kill-all")] + _kill_all: bool, +} + +impl DaemonKillAllCommand { + pub async fn execute(&self) -> Result<(), Error> { + let all_daemons = daemons::list_daemons()?; + + if all_daemons.is_empty() { + println!( + "{} No daemons registered", + DataType::Info.colorize("ℹ") + ); + return Ok(()); + } + + let mut killed = 0; + let mut failed = 0; + let mut stale = 0; + + for daemon in all_daemons { + if !daemons::is_process_alive(daemon.pid) { + daemons::unregister_daemon(&daemon.project_cwd)?; + stale += 1; + continue; + } + + let pid = daemon.pid; + let success = tokio::task::spawn_blocking(move || daemons::kill_daemon_gracefully(pid)) + .await + .unwrap_or(false); + + if success { + daemons::unregister_daemon(&daemon.project_cwd)?; + println!( + "{} Stopped daemon for {} (PID: {})", + DataType::Success.colorize("✓"), + daemon.project_cwd.to_print_string(), + daemon.pid + ); + killed += 1; + } else { + println!( + "{} Failed to stop daemon for {} (PID: {})", + DataType::Error.colorize("✗"), + daemon.project_cwd.to_print_string(), + daemon.pid + ); + failed += 1; + } + } + + if stale > 0 { + println!( + "{} Cleaned up {} stale daemon entries", + DataType::Info.colorize("ℹ"), + stale + ); + } + + if failed > 0 { + println!( + "\n{} Stopped {} daemons, {} failed", + DataType::Warning.colorize("!"), + killed, + failed + ); + } else if killed > 0 { + println!( + "\n{} Stopped {} daemons", + DataType::Success.colorize("✓"), + killed + ); + } + + Ok(()) + } +} diff --git a/packages/zpm-switch/src/commands/switch/daemon_list.rs b/packages/zpm-switch/src/commands/switch/daemon_list.rs new file mode 100644 index 00000000..c7d3b36b --- /dev/null +++ b/packages/zpm-switch/src/commands/switch/daemon_list.rs @@ -0,0 +1,76 @@ +use clipanion::cli; +use zpm_parsers::JsonDocument; +use zpm_utils::{tree, AbstractValue, ToFileString}; + +use crate::{daemons, errors::Error}; + +#[cli::command] +#[cli::path("switch", "daemon")] +#[cli::category("Daemon management")] +#[derive(Debug)] +pub struct DaemonListCommand { + /// Output the list as JSON + #[cli::option("--json", default = false)] + json: bool, +} + +impl DaemonListCommand { + pub async fn execute(&self) -> Result<(), Error> { + let daemons = daemons::list_live_daemons()?; + + if self.json { + let json_output: Vec<_> = daemons + .iter() + .map(|d| serde_json::json!({ + "cwd": d.project_cwd.to_file_string(), + "version": d.yarn_version.to_file_string(), + "pid": d.pid, + "port": d.port, + })) + .collect(); + + println!("{}", JsonDocument::to_string_pretty(&json_output)?); + return Ok(()); + } + + if daemons.is_empty() { + println!("No live daemons found."); + return Ok(()); + } + + let nodes: Vec<_> = daemons + .iter() + .map(|d| tree::Node { + label: None, + value: Some(AbstractValue::new(d.project_cwd.clone())), + children: Some(tree::TreeNodeChildren::Map(tree::Map::from([ + ("version".to_string(), tree::Node { + label: Some("Yarn version".to_string()), + value: Some(AbstractValue::new(d.yarn_version.clone())), + children: None, + }), + ("pid".to_string(), tree::Node { + label: Some("PID".to_string()), + value: Some(AbstractValue::new(d.pid as u64)), + children: None, + }), + ("port".to_string(), tree::Node { + label: Some("Port".to_string()), + value: Some(AbstractValue::new(d.port as u64)), + children: None, + }), + ]))), + }) + .collect(); + + let root = tree::Node { + label: None, + value: None, + children: Some(tree::TreeNodeChildren::Vec(nodes)), + }; + + print!("{}", root.to_string()); + + Ok(()) + } +} diff --git a/packages/zpm-switch/src/commands/switch/daemon_open.rs b/packages/zpm-switch/src/commands/switch/daemon_open.rs new file mode 100644 index 00000000..42bf23fe --- /dev/null +++ b/packages/zpm-switch/src/commands/switch/daemon_open.rs @@ -0,0 +1,210 @@ +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use clipanion::cli; +use zpm_semver::Version; +use zpm_utils::{Path, ToFileString}; + +use crate::{ + cwd::get_final_cwd, + daemons::{self, DaemonEntry}, + errors::Error, + install::install_package_manager, + links::{get_link, LinkTarget}, + manifest::{find_closest_package_manager, PackageManagerReference}, + yarn::get_default_yarn_version, + yarn_enums::ReleaseLine, +}; + +#[cli::command] +#[cli::path("switch", "daemon")] +#[cli::category("Daemon management")] +#[derive(Debug)] +pub struct DaemonOpenCommand { + #[cli::option("--open")] + _open: bool, +} + +impl DaemonOpenCommand { + pub async fn execute(&self) -> Result<(), Error> { + let project_cwd + = get_final_cwd()?; + + let find_result + = find_closest_package_manager(&project_cwd)?; + + let detected_root + = find_result + .detected_root_path + .ok_or(Error::NoProjectFound)?; + + if let Some(existing) = daemons::get_daemon(&detected_root)? { + if daemons::is_process_alive(existing.pid) { + if self.check_daemon_ready(existing.port).await.is_ok() { + println!("ws://127.0.0.1:{}", existing.port); + return Ok(()); + } + // Process alive but not answering — terminate it and its children before replacing + let pid = existing.pid; + let _ = tokio::task::spawn_blocking(move || daemons::kill_daemon_gracefully(pid)).await; + } + + daemons::unregister_daemon(&detected_root)?; + } + + if let Some(link) = get_link(&detected_root)? { + if let LinkTarget::Local { bin_path } = link.link_target { + return self.start_with_binary(&detected_root, &bin_path, "local").await; + } + } + + let reference = match find_result.detected_package_manager { + Some(package_manager) => package_manager.into_reference("yarn"), + None => get_default_yarn_version(Some(ReleaseLine::Classic)).await, + }?; + + match &reference { + PackageManagerReference::Version(version_ref) => { + let mut binary + = install_package_manager(version_ref).await?; + + self.start_with_command(&detected_root, &mut binary, &version_ref.version.to_file_string()) + .await + }, + + PackageManagerReference::Local(local_ref) => { + self.start_with_binary(&detected_root, &local_ref.path, "local") + .await + }, + } + } + + async fn start_with_binary(&self, detected_root: &Path, bin_path: &Path, version_label: &str) -> Result<(), Error> { + let mut binary + = Command::new(bin_path.to_path_buf()); + + self.start_with_command(detected_root, &mut binary, version_label) + .await + } + + async fn start_with_command(&self, detected_root: &Path, binary: &mut Command, version_label: &str) -> Result<(), Error> { + binary + .arg("debug") + .arg("daemon") + .current_dir(detected_root.to_file_string()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + if let Ok(home) = std::env::var("HOME") { + binary.env("HOME", home); + } + if let Ok(userprofile) = std::env::var("USERPROFILE") { + binary.env("USERPROFILE", userprofile); + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + binary.process_group(0); + } + + let mut child + = binary + .spawn() + .map_err(|e| Error::FailedToStartDaemon(Arc::new(e)))?; + + let pid + = child.id(); + let port + = self.read_port_from_child(&mut child).await?; + + let entry = DaemonEntry { + project_cwd: detected_root.clone(), + yarn_version: version_label.parse().unwrap_or_else(|_| Version::new()), + pid, + port, + }; + + daemons::register_daemon(&entry)?; + + if let Err(e) = self.wait_for_ready(port).await { + // Daemon failed to become ready — kill it and clean up registry + let _ = tokio::task::spawn_blocking(move || daemons::kill_daemon_gracefully(pid)).await; + daemons::unregister_daemon(&detected_root)?; + return Err(e); + } + + println!("ws://127.0.0.1:{}", port); + + Ok(()) + } + + async fn read_port_from_child(&self, child: &mut std::process::Child) -> Result { + use std::io::{BufRead, BufReader}; + + let stdout + = child + .stdout + .take() + .ok_or_else(|| Error::DaemonStartTimeout)?; + + let port + = tokio::task::spawn_blocking(move || { + let reader + = BufReader::new(stdout); + + let mut lines + = reader.lines(); + + if let Some(Ok(line)) = lines.next() { + line.trim().parse::().ok() + } else { + None + } + }) + .await + .map_err(|e| Error::JoinFailed(Arc::new(e)))? + .ok_or(Error::DaemonStartTimeout)?; + + Ok(port) + } + + async fn wait_for_ready(&self, port: u16) -> Result<(), Error> { + let max_attempts + = 100; + let poll_interval + = Duration::from_millis(50); + + for _ in 0..max_attempts { + if self.check_daemon_ready(port).await.is_ok() { + return Ok(()); + } + + tokio::time::sleep(poll_interval).await; + } + + Err(Error::DaemonStartTimeout) + } + + async fn check_daemon_ready(&self, port: u16) -> Result<(), Error> { + let url + = format!("ws://127.0.0.1:{}", port); + + // Just attempt to establish a WebSocket connection - if it succeeds, daemon is ready + let (mut ws, _) = tokio_tungstenite::connect_async(&url) + .await + .map_err(|e| { + Error::DaemonConnectionFailed(Arc::new(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + e.to_string(), + ))) + })?; + + // Close the connection properly to avoid connection resets + ws.close(None).await.ok(); + + Ok(()) + } +} diff --git a/packages/zpm-switch/src/commands/switch/explicit.rs b/packages/zpm-switch/src/commands/switch/explicit.rs index 756e54a8..ae4f1d02 100644 --- a/packages/zpm-switch/src/commands/switch/explicit.rs +++ b/packages/zpm-switch/src/commands/switch/explicit.rs @@ -3,7 +3,7 @@ use std::{process::{Command, ExitStatus, Stdio}, sync::Arc}; use clipanion::cli; use zpm_utils::ToFileString; -use crate::{cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, manifest::{find_closest_package_manager, PackageManagerReference, VersionPackageManagerReference}, yarn::resolve_selector, yarn_enums::Selector}; +use crate::{cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, ipc::YARN_SWITCH_PATH_ENV, manifest::{find_closest_package_manager, PackageManagerReference, VersionPackageManagerReference}, yarn::resolve_selector, yarn_enums::Selector}; /// Call a custom Yarn binary for the current project #[cli::command(proxy)] @@ -28,8 +28,24 @@ impl ExplicitCommand { binary.stdout(Stdio::inherit()); binary.args(args); + if let Ok(switch_path) = std::env::current_exe() { + binary.env(YARN_SWITCH_PATH_ENV, switch_path); + } + + let mut child + = binary.spawn() + .map_err(|err| Error::FailedToExecuteBinary(binary.get_program().to_string_lossy().to_string(), Arc::new(err)))?; + + // Ignore SIGINT while waiting for the child process. + // This ensures the child's exit code is properly propagated + // instead of the parent being killed by SIGINT. + // Note: We must spawn BEFORE setting SIG_IGN, otherwise the child + // inherits the ignored signal disposition and won't receive Ctrl-C. + #[cfg(unix)] + let _guard = zpm_utils::IgnoreSigint::new(); + let exit_code - = binary.status() + = child.wait() .map_err(|err| Error::FailedToExecuteBinary(binary.get_program().to_string_lossy().to_string(), Arc::new(err)))?; Ok(exit_code) diff --git a/packages/zpm-switch/src/commands/switch/mod.rs b/packages/zpm-switch/src/commands/switch/mod.rs index 32bd6f8b..d0cd75ed 100644 --- a/packages/zpm-switch/src/commands/switch/mod.rs +++ b/packages/zpm-switch/src/commands/switch/mod.rs @@ -3,6 +3,10 @@ pub mod cache_clear; pub mod cache_install; pub mod cache_list; pub mod clipanion_commands; +pub mod daemon_kill_all; +pub mod daemon_kill; +pub mod daemon_list; +pub mod daemon_open; pub mod explicit; pub mod links_clear; pub mod links_list; diff --git a/packages/zpm-switch/src/daemons.rs b/packages/zpm-switch/src/daemons.rs new file mode 100644 index 00000000..36fb9aca --- /dev/null +++ b/packages/zpm-switch/src/daemons.rs @@ -0,0 +1,234 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use zpm_parsers::JsonDocument; +use zpm_semver::Version; +use zpm_utils::{Hash64, IoResultExt, Path, ToFileString}; + +use crate::errors::Error; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DaemonEntry { + pub project_cwd: Path, + pub yarn_version: Version, + pub pid: u32, + pub port: u16, +} + +pub fn daemons_dir() -> Result { + let daemons_dir + = Path::home_dir()? + .ok_or(Error::MissingHomeFolder)? + .with_join_str(".yarn/switch/daemons"); + + Ok(daemons_dir) +} + +fn daemon_file_path(project_cwd: &Path) -> Result { + let hash + = Hash64::from_data(project_cwd.to_file_string().as_bytes()); + + let daemon_path + = daemons_dir()? + .with_join_str(format!("{}.json", hash.short())); + + Ok(daemon_path) +} + +pub fn register_daemon(entry: &DaemonEntry) -> Result<(), Error> { + let daemon_path + = daemon_file_path(&entry.project_cwd)?; + + daemon_path + .fs_create_parent()? + .fs_write(JsonDocument::to_string(entry)?)?; + + // Set restrictive permissions (owner read/write only) to protect sensitive daemon info + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + daemon_path.fs_set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + + Ok(()) +} + +pub fn unregister_daemon(project_cwd: &Path) -> Result<(), Error> { + let daemon_path + = daemon_file_path(project_cwd)?; + + daemon_path + .fs_rm() + .ok_missing()?; + + Ok(()) +} + +pub fn get_daemon(project_cwd: &Path) -> Result, Error> { + let daemon_path + = daemon_file_path(project_cwd)?; + + let daemon + = daemon_path + .fs_read_text() + .ok_missing()? + .and_then(|content| JsonDocument::hydrate_from_str::(&content).ok()); + + Ok(daemon) +} + +pub fn list_daemons() -> Result, Error> { + let daemons_dir + = daemons_dir()?; + + let Some(dir_entries) = daemons_dir.fs_read_dir().ok_missing()? else { + return Ok(BTreeSet::new()); + }; + + let daemons + = dir_entries + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().map_or(false, |f| f.is_file())) + .filter_map(|entry| Path::try_from(entry.path()).ok()) + .filter_map(|path| path.fs_read_text().ok()) + .filter_map(|content| JsonDocument::hydrate_from_str::(&content).ok()) + .collect::>(); + + Ok(daemons) +} + +pub fn is_process_alive(pid: u32) -> bool { + #[cfg(unix)] + { + unsafe { libc::kill(pid as i32, 0) == 0 } + } + + #[cfg(windows)] + { + use std::ptr::null_mut; + unsafe { + let handle = winapi::um::processthreadsapi::OpenProcess( + winapi::um::winnt::PROCESS_QUERY_LIMITED_INFORMATION, + 0, + pid, + ); + if handle.is_null() { + false + } else { + winapi::um::handleapi::CloseHandle(handle); + true + } + } + } + + #[cfg(not(any(unix, windows)))] + { + true + } +} + +pub fn kill_process(pid: u32) -> bool { + #[cfg(unix)] + { + unsafe { libc::kill(pid as i32, libc::SIGTERM) == 0 } + } + + #[cfg(windows)] + { + use std::ptr::null_mut; + unsafe { + let handle = winapi::um::processthreadsapi::OpenProcess( + winapi::um::winnt::PROCESS_TERMINATE, + 0, + pid, + ); + if handle.is_null() { + false + } else { + let result = winapi::um::processthreadsapi::TerminateProcess(handle, 1) != 0; + winapi::um::handleapi::CloseHandle(handle); + result + } + } + } + + #[cfg(not(any(unix, windows)))] + { + false + } +} + +/// Kill a daemon process and all its children (process group). +/// Sends SIGTERM first, waits for the process to exit, then sends SIGKILL if needed. +/// Returns true if the process was successfully killed. +pub fn kill_daemon_gracefully(pid: u32) -> bool { + #[cfg(unix)] + { + // First, send SIGTERM to the daemon process itself + // The daemon's signal handler should propagate to children + let term_result = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + if term_result != 0 { + // Process doesn't exist or we don't have permission + return false; + } + + // Wait up to 6 seconds for the daemon to shut down gracefully + // (daemon waits 5s internally for children to exit, plus 1s buffer) + for _ in 0..60 { + std::thread::sleep(std::time::Duration::from_millis(100)); + if !is_process_alive(pid) { + return true; + } + } + + // If still alive after 6 seconds, send SIGKILL to the process group + // Use negative PID to target the entire process group + let pgid = unsafe { libc::getpgid(pid as i32) }; + if pgid > 0 { + let _ = unsafe { libc::killpg(pgid, libc::SIGKILL) }; + } + // Also send SIGKILL directly to the daemon in case it's not the process group leader + let _ = unsafe { libc::kill(pid as i32, libc::SIGKILL) }; + + // Wait a bit for the process to actually die + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_millis(100)); + if !is_process_alive(pid) { + return true; + } + } + + // Return true even if we couldn't verify death - we did our best + true + } + + #[cfg(windows)] + { + // On Windows, just use TerminateProcess (no graceful shutdown) + kill_process(pid) + } + + #[cfg(not(any(unix, windows)))] + { + false + } +} + +pub fn cleanup_stale_daemons() -> Result<(), Error> { + let daemons + = list_daemons()?; + + for daemon in daemons { + if !is_process_alive(daemon.pid) { + unregister_daemon(&daemon.project_cwd)?; + } + } + + Ok(()) +} + +pub fn list_live_daemons() -> Result, Error> { + cleanup_stale_daemons()?; + list_daemons() +} diff --git a/packages/zpm-switch/src/errors.rs b/packages/zpm-switch/src/errors.rs index 0a4a3d03..9c32064a 100644 --- a/packages/zpm-switch/src/errors.rs +++ b/packages/zpm-switch/src/errors.rs @@ -83,6 +83,36 @@ pub enum Error { #[error("Yarn cannot be used on project configured for use with {0}")] UnsupportedProject(&'static str), + + #[error("No project found in current directory or any parent")] + NoProjectFound, + + #[error("Daemons are not supported for local Yarn versions")] + DaemonNotSupportedForLocalVersions, + + #[error("Failed to start daemon: {0}")] + FailedToStartDaemon(Arc), + + #[error("No daemon is running for this project")] + DaemonNotRunning, + + #[error("Daemon failed to start within timeout")] + DaemonStartTimeout, + + #[error("Failed to connect to daemon: {0}")] + DaemonConnectionFailed(Arc), + + #[error("Invalid daemon message: {0}")] + InvalidDaemonMessage(String), + + #[error("Failed to bind socket: {0}")] + FailedToBindSocket(Arc), + + #[error("Failed to read from socket: {0}")] + SocketReadError(Arc), + + #[error("Failed to write to socket: {0}")] + SocketWriteError(Arc), } impl From for Error { diff --git a/packages/zpm-switch/src/ipc.rs b/packages/zpm-switch/src/ipc.rs new file mode 100644 index 00000000..d82c84b7 --- /dev/null +++ b/packages/zpm-switch/src/ipc.rs @@ -0,0 +1 @@ +pub const YARN_SWITCH_PATH_ENV: &str = "YARN_SWITCH_PATH"; diff --git a/packages/zpm-switch/src/lib.rs b/packages/zpm-switch/src/lib.rs index b1ac2c14..ae4e95bb 100644 --- a/packages/zpm-switch/src/lib.rs +++ b/packages/zpm-switch/src/lib.rs @@ -1,5 +1,7 @@ +pub mod daemons; mod errors; mod http; +mod ipc; mod manifest; mod yarn_enums; mod yarn; @@ -8,6 +10,8 @@ pub use errors::{ Error, }; +pub use ipc::YARN_SWITCH_PATH_ENV; + pub use manifest::{ PackageManagerField, PackageManagerReference, diff --git a/packages/zpm-switch/src/main.rs b/packages/zpm-switch/src/main.rs index f5859fc6..11caf954 100644 --- a/packages/zpm-switch/src/main.rs +++ b/packages/zpm-switch/src/main.rs @@ -5,9 +5,11 @@ use std::process::ExitCode; mod cache; mod commands; mod cwd; +mod daemons; mod errors; mod http; mod install; +mod ipc; mod links; mod manifest; mod yarn_enums; diff --git a/packages/zpm-tasks/src/ast.rs b/packages/zpm-tasks/src/ast.rs index e17ba5a7..4f64f469 100644 --- a/packages/zpm-tasks/src/ast.rs +++ b/packages/zpm-tasks/src/ast.rs @@ -14,6 +14,14 @@ pub enum TaskNameError { SyntaxError(String), } +#[derive(thiserror::Error, Clone, Debug)] +pub enum TaskIdError { + #[error("Invalid task id format (expected 'workspace:task'): {0}")] + SyntaxError(String), + #[error("Invalid task name in task id: {0}")] + InvalidTaskName(#[from] TaskNameError), +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TaskName(String); @@ -68,7 +76,7 @@ impl ToFileString for TaskName { impl ToHumanString for TaskName { fn to_print_string(&self) -> String { - DataType::Ident.colorize(&self.0) + DataType::Task.colorize(&self.0) } } @@ -81,21 +89,39 @@ pub struct TaskId { pub task_name: TaskName, } -impl std::fmt::Display for TaskId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}:{}", self.workspace.as_str(), self.task_name.as_str()) +impl FromFileString for TaskId { + type Error = TaskIdError; + + fn from_file_string(s: &str) -> Result { + let (workspace_str, task_name_str) + = s.rsplit_once(':') + .ok_or_else(|| TaskIdError::SyntaxError(s.to_string()))?; + + let workspace + = Ident::new(workspace_str); + + let task_name + = TaskName::new(task_name_str)?; + + Ok(TaskId { workspace, task_name }) } } -impl Serialize for TaskId { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_string()) +impl ToFileString for TaskId { + fn to_file_string(&self) -> String { + format!("{}:{}", self.workspace.to_file_string(), self.task_name.to_file_string()) } } +impl ToHumanString for TaskId { + fn to_print_string(&self) -> String { + format!("{}{}{}", self.workspace.to_print_string(), DataType::Task.colorize(":"), self.task_name.to_print_string()) + } +} + +impl_file_string_from_str!(TaskId); +impl_file_string_serialization!(TaskId); + #[derive(Debug, Clone, Serialize)] pub struct TaskFile { pub includes: Vec, diff --git a/packages/zpm-tasks/src/error.rs b/packages/zpm-tasks/src/error.rs index 5171117c..25dcc110 100644 --- a/packages/zpm-tasks/src/error.rs +++ b/packages/zpm-tasks/src/error.rs @@ -1,4 +1,5 @@ use zpm_primitives::Ident; +use zpm_utils::ToFileString; use crate::ast::{TaskId, TaskName}; @@ -46,10 +47,10 @@ pub enum Error { fn format_cycle(cycle: &[TaskId]) -> String { let mut parts: Vec - = cycle.iter().map(|t| t.to_string()).collect(); + = cycle.iter().map(|t| t.to_file_string()).collect(); if let Some(first) = cycle.first() { - parts.push(first.to_string()); + parts.push(first.to_file_string()); } parts.join(" -> ") diff --git a/packages/zpm-utils/Cargo.toml b/packages/zpm-utils/Cargo.toml index d22fe621..5e8373f2 100644 --- a/packages/zpm-utils/Cargo.toml +++ b/packages/zpm-utils/Cargo.toml @@ -27,3 +27,6 @@ indexmap = { workspace = true, features = ["serde"]} timeago = { workspace = true } fundu = { workspace = true } zpm-macro-enum = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/packages/zpm-utils/src/colors.rs b/packages/zpm-utils/src/colors.rs index 8e3546df..1b1fc3ee 100644 --- a/packages/zpm-utils/src/colors.rs +++ b/packages/zpm-utils/src/colors.rs @@ -51,6 +51,12 @@ const RANGE_COLOR: Color const REFERENCE_COLOR: Color = Color::TrueColor { r: 135, g: 175, b: 255 }; +const TASK_COLOR: Color + = Color::TrueColor { r: 135, g: 175, b: 255 }; + +const TIMESTAMP_COLOR: Color + = Color::TrueColor { r: 144, g: 144, b: 144 }; + #[derive(Debug, Clone, Copy)] pub enum DataType { Info, @@ -70,6 +76,8 @@ pub enum DataType { Ident, Range, Reference, + Timestamp, + Task, Custom(u8, u8, u8), } @@ -93,6 +101,8 @@ impl DataType { DataType::Ident => IDENT_COLOR, DataType::Range => RANGE_COLOR, DataType::Reference => REFERENCE_COLOR, + DataType::Timestamp => TIMESTAMP_COLOR, + DataType::Task => TASK_COLOR, DataType::Custom(r, g, b) => Color::TrueColor {r: *r, g: *g, b: *b}, } } diff --git a/packages/zpm-utils/src/process.rs b/packages/zpm-utils/src/process.rs index b735ac7c..a4e65c5b 100644 --- a/packages/zpm-utils/src/process.rs +++ b/packages/zpm-utils/src/process.rs @@ -1,6 +1,52 @@ use std::process::Command; use shlex::{try_quote, QuoteError}; +/// RAII guard to ignore SIGINT while waiting for a child process. +/// +/// When a terminal user presses Ctrl-C, SIGINT is sent to the entire +/// foreground process group. If a parent process is waiting for a child, +/// both receive the signal. By ignoring SIGINT in the parent, we ensure +/// the child can handle the signal and exit gracefully, and the parent +/// can properly propagate the child's exit code. +/// +/// On drop, restores the previous signal handler. +#[cfg(unix)] +pub struct IgnoreSigint { + prev_handler: libc::sighandler_t, +} + +#[cfg(unix)] +impl IgnoreSigint { + /// Creates a new guard that ignores SIGINT until dropped. + pub fn new() -> Self { + // SAFETY: We're setting SIG_IGN which is always safe + let prev_handler = unsafe { libc::signal(libc::SIGINT, libc::SIG_IGN) }; + // If signal() returns SIG_ERR, use SIG_DFL as safe fallback so Drop + // restores a known-valid handler rather than attempting to set SIG_ERR. + let prev_handler = if prev_handler == libc::SIG_ERR { + libc::SIG_DFL + } else { + prev_handler + }; + Self { prev_handler } + } +} + +#[cfg(unix)] +impl Default for IgnoreSigint { + fn default() -> Self { + Self::new() + } +} + +#[cfg(unix)] +impl Drop for IgnoreSigint { + fn drop(&mut self) { + // SAFETY: We're restoring the previous handler + unsafe { libc::signal(libc::SIGINT, self.prev_handler) }; + } +} + pub fn to_shell_line(cmd: &Command) -> Result { let mut parts: Vec = Vec::new(); diff --git a/packages/zpm/Cargo.toml b/packages/zpm/Cargo.toml index 566085af..2a44918b 100644 --- a/packages/zpm/Cargo.toml +++ b/packages/zpm/Cargo.toml @@ -57,8 +57,14 @@ hex = { workspace = true } p256 = { workspace = true } spki = { workspace = true } ring = { workspace = true } +tokio-tungstenite = { workspace = true } url = { workspace = true } rand = { workspace = true } +uuid = { version = "1.21.0", features = ["v4"] } +async-trait = "0.1.89" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" [dev-dependencies] divan = { workspace = true, package = "codspeed-divan-compat" } diff --git a/packages/zpm/src/commands/debug/daemon.rs b/packages/zpm/src/commands/debug/daemon.rs new file mode 100644 index 00000000..baa71223 --- /dev/null +++ b/packages/zpm/src/commands/debug/daemon.rs @@ -0,0 +1,24 @@ +use std::sync::Arc; + +use clipanion::cli; + +use crate::daemon::run_daemon; +use crate::error::Error; +use crate::project::Project; + +/// Start a background daemon process. +/// +/// This command starts an idle daemon that runs indefinitely until terminated. +/// It listens on a WebSocket server for IPC messages. +/// +#[cli::command] +#[cli::path("debug", "daemon")] +#[cli::category("Debug commands")] +pub struct Daemon {} + +impl Daemon { + pub async fn execute(&self) -> Result<(), Error> { + let project = Arc::new(Project::new(None).await?); + run_daemon(project).await + } +} diff --git a/packages/zpm/src/commands/debug/mod.rs b/packages/zpm/src/commands/debug/mod.rs index a5c88f7c..0260b463 100644 --- a/packages/zpm/src/commands/debug/mod.rs +++ b/packages/zpm/src/commands/debug/mod.rs @@ -6,6 +6,7 @@ pub mod check_range; pub mod check_reference; pub mod check_requirements; pub mod check_semver_version; +pub mod daemon; pub mod flamegraph; pub mod http; pub mod iter_zip; diff --git a/packages/zpm/src/commands/mod.rs b/packages/zpm/src/commands/mod.rs index 5317f0ba..a1cefb02 100644 --- a/packages/zpm/src/commands/mod.rs +++ b/packages/zpm/src/commands/mod.rs @@ -85,6 +85,7 @@ pub enum YarnCli { CacheClear2(cache_clear::CacheClear2), Config(config::Config), ConfigGet(config_get::ConfigGet), + Daemon(debug::daemon::Daemon), ConfigSet(config_set::ConfigSet), Constraints(constraints::Constraints), Dedupe(dedupe::Dedupe), @@ -106,8 +107,13 @@ pub enum YarnCli { Rebuild(rebuild::Rebuild), Remove(remove::Remove), Run(run::Run), + TaskList(tasks::list::TaskList), TaskPush(tasks::push::TaskPush), - TaskRun(tasks::run::TaskRun), + TaskRunInterlaced(tasks::run_interlaced::TaskRunInterlaced), + TaskRunBuffered(tasks::run_buffered::TaskRunBuffered), + TaskRunSilentDependencies(tasks::run_silent_dependencies::TaskRunSilentDependencies), + TaskStats(tasks::stats::TaskStats), + TaskStop(tasks::stop::TaskStop), Unlink(unlink::Unlink), Unplug(unplug::Unplug), Up(up::Up), diff --git a/packages/zpm/src/commands/run.rs b/packages/zpm/src/commands/run.rs index 13b28d46..abce218f 100644 --- a/packages/zpm/src/commands/run.rs +++ b/packages/zpm/src/commands/run.rs @@ -3,8 +3,8 @@ use std::{os::unix::process::ExitStatusExt, process::ExitStatus}; use zpm_utils::Path; use clipanion::cli; -use crate::{error::Error, project, script::ScriptEnvironment}; -use super::tasks::run as task_run; +use crate::{commands::tasks::run_silent_dependencies::TaskRunSilentDependencies, error::Error, project, script::ScriptEnvironment}; +use super::tasks as task_run; /// Run a dependency binary or local script /// @@ -161,20 +161,13 @@ impl Run { }, Err(Error::ScriptNotFound(_)) | Err(Error::GlobalScriptNotFound(_)) => { - // Try task files as a fallback before looking for binaries if task_run::task_exists(&project, &self.name) { - return task_run::run_task( - &project, - &self.name, - &self.args, - 0, // verbose_level - true, // silent_dependencies - true, // interlaced - project.config.settings.enable_timers.value, - ).await; + let task_run_silent_dependencies + = TaskRunSilentDependencies::new(&self.cli_environment, self.name.clone(), self.args.clone()); + + return task_run_silent_dependencies.execute().await; } - // Fall back to binary lookup execute_binary(true).await } diff --git a/packages/zpm/src/commands/tasks/helpers.rs b/packages/zpm/src/commands/tasks/helpers.rs new file mode 100644 index 00000000..8e759a36 --- /dev/null +++ b/packages/zpm/src/commands/tasks/helpers.rs @@ -0,0 +1,54 @@ +use chrono::{Local, TimeZone, Utc}; +use colored::Colorize; +use zpm_tasks::TaskId; +use zpm_utils::{DataType, FromFileString, ToHumanString}; + +use crate::daemon::{AttachedLongLivedTask, LONG_LIVED_CONTEXT_ID}; + +pub fn format_task_id(task_id: &str) -> String { + let base + = task_id.rsplit_once('@').map(|(b, _)| b).unwrap_or(task_id); + + TaskId::from_file_string(base) + .map(|t| t.to_print_string()) + .unwrap_or_else(|_| base.to_string()) +} + +pub fn format_timestamp() -> String { + DataType::Timestamp.colorize(&Local::now().format("%Y-%m-%dT%H:%M:%S%.3f").to_string()) +} + +pub fn is_long_lived_task(task_id: &str) -> bool { + task_id.ends_with(&format!("@{}", LONG_LIVED_CONTEXT_ID)) +} + +pub fn format_start_time(started_at_ms: u64) -> String { + let secs + = (started_at_ms / 1000) as i64; + + let nanos + = ((started_at_ms % 1000) * 1_000_000) as u32; + + Utc.timestamp_opt(secs, nanos) + .single() + .map(|dt| dt.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +pub fn print_attach_header(attached: &AttachedLongLivedTask) { + println!( + "{}", + format!( + "Attaching to long-lived task {} (started at {})", + format_task_id(&attached.task_id), + format_start_time(attached.started_at_ms) + ).bold() + ); +} + +pub fn print_detach_footer(task_name: &str) { + println!("{}", "The long-lived task is still running in the background.".bold()); + println!(); + println!("{}", format!(" To re-attach: yarn tasks run {}", task_name).dimmed()); + println!("{}", format!(" To stop: yarn tasks stop {}", task_name).dimmed()); +} diff --git a/packages/zpm/src/commands/tasks/list.rs b/packages/zpm/src/commands/tasks/list.rs new file mode 100644 index 00000000..788a7eae --- /dev/null +++ b/packages/zpm/src/commands/tasks/list.rs @@ -0,0 +1,99 @@ +use std::{os::unix::process::ExitStatusExt, process::ExitStatus}; + +use clipanion::cli; +use colored::Colorize; + +use crate::daemon::{DaemonClient, LongLivedTaskInfo, LongLivedTaskStatus}; +use crate::error::Error; +use crate::project::Project; + +use super::helpers::format_start_time; + +/// List all long-lived tasks +/// +/// This command lists all long-lived tasks currently registered in the project. +/// Long-lived tasks are background processes that continue running after the +/// initial command completes, such as development servers or watch processes. +/// +/// The output shows each task's name, current status (running or stopped), +/// and when it was started (for running tasks). +#[cli::command] +#[cli::path("tasks")] +#[cli::category("Task management commands")] +pub struct TaskList { + #[cli::option("--json", default = false)] + pub json: bool, +} + +impl TaskList { + pub async fn execute(&self) -> Result { + let project + = Project::new(None).await?; + + let mut client + = DaemonClient::connect(&project.project_cwd).await?; + + let tasks + = client.list_long_lived_tasks().await?; + + client.close(); + + if self.json { + self.print_json(&tasks); + } else { + self.print_human(&tasks); + } + + Ok(ExitStatus::from_raw(0)) + } + + fn print_json(&self, tasks: &[LongLivedTaskInfo]) { + for task in tasks { + println!("{}", serde_json::to_string(task).unwrap()); + } + } + + fn print_human(&self, tasks: &[LongLivedTaskInfo]) { + if tasks.is_empty() { + println!("No long-lived tasks found in this project."); + return; + } + + println!("{}", "Long-lived tasks:".bold()); + println!(); + + for task in tasks { + let task_display + = format!("{}:{}", task.workspace, task.task_name); + + match &task.status { + LongLivedTaskStatus::Stopped => { + println!( + " {} {}", + task_display.bold(), + "(stopped)".dimmed() + ); + } + LongLivedTaskStatus::Running { started_at_ms, process_id } => { + let started_str + = format_start_time(*started_at_ms); + + let pid_str + = process_id + .map(|pid| format!(" (pid: {})", pid)) + .unwrap_or_default(); + + println!( + " {} {} {}{}", + task_display.bold(), + "running".green(), + format!("since {}", started_str).dimmed(), + pid_str.dimmed() + ); + } + } + } + + println!(); + } +} diff --git a/packages/zpm/src/commands/tasks/mod.rs b/packages/zpm/src/commands/tasks/mod.rs index 16cc25dd..63abf6f8 100644 --- a/packages/zpm/src/commands/tasks/mod.rs +++ b/packages/zpm/src/commands/tasks/mod.rs @@ -1,2 +1,40 @@ +mod helpers; +mod runner; + +pub mod list; pub mod push; -pub mod run; +pub mod run_buffered; +pub mod run_interlaced; +pub mod run_silent_dependencies; +pub mod stats; +pub mod stop; + +use zpm_tasks::{parse, TaskName}; + +use crate::project::Project; + +pub fn task_exists(project: &Project, task_name: &str) -> bool { + let Ok(task_name) = TaskName::new(task_name) else { + return false; + }; + + let Ok(workspace) = project.active_workspace() else { + return false; + }; + + let task_file_path = workspace.taskfile_path(); + + if !task_file_path.fs_exists() { + return false; + } + + let Ok(task_file_content) = task_file_path.fs_read_text() else { + return false; + }; + + let Ok(task_file) = parse(&task_file_content) else { + return false; + }; + + task_file.tasks.contains_key(task_name.as_str()) +} diff --git a/packages/zpm/src/commands/tasks/push.rs b/packages/zpm/src/commands/tasks/push.rs index 045cccf0..15a9c16e 100644 --- a/packages/zpm/src/commands/tasks/push.rs +++ b/packages/zpm/src/commands/tasks/push.rs @@ -2,14 +2,22 @@ use std::os::unix::process::ExitStatusExt; use std::process::ExitStatus; use clipanion::cli; - +use crate::daemon::{DaemonClient, TaskSubscription, DAEMON_SERVER_ENV, TASK_CURRENT_ENV}; use crate::error::Error; -use crate::ipc::{TaskIpcClient, IPC_CURRENT_TASK_ENV}; +/// Push tasks to be executed from within a running task +/// +/// This command allows a running task to schedule additional tasks to be +/// executed by the daemon. It can only be called from within a task context +/// (i.e., when running inside a task that was started by the daemon). +/// +/// This is useful for dynamically spawning subtasks based on runtime conditions, +/// such as triggering build steps after certain conditions are met. #[cli::command] #[cli::path("tasks", "push")] -#[cli::category("Scripting commands")] +#[cli::category("Task management commands")] pub struct TaskPush { + /// Names of the tasks to push for execution #[cli::positional] tasks: Vec, } @@ -20,15 +28,40 @@ impl TaskPush { return Err(Error::TaskPushFailed("No tasks specified".to_string())); } + let parent_task_id + = match std::env::var(TASK_CURRENT_ENV) { + Ok(id) => Some(id), + Err(_) => { + return Err(Error::TaskPushFailed( + format!("Not running inside a task context ({} not set)", TASK_CURRENT_ENV), + )); + } + }; + + let daemon_url + = match std::env::var(DAEMON_SERVER_ENV) { + Ok(url) => url, + Err(_) => { + return Err(Error::TaskPushFailed( + format!("Not running inside a daemon context ({} not set)", DAEMON_SERVER_ENV), + )); + } + }; + let mut client - = TaskIpcClient::connect().await?; + = DaemonClient::connect_to_url(&daemon_url).await?; - let parent_task_id - = std::env::var(IPC_CURRENT_TASK_ENV).ok(); + let task_subscriptions: Vec + = self + .tasks + .iter() + .map(|name| TaskSubscription { + name: name.clone(), + args: vec![], + }) + .collect(); - for task in &self.tasks { - client.push_task(task, parent_task_id.as_deref()).await?; - } + client.push_tasks(task_subscriptions, parent_task_id, None, None).await?; Ok(ExitStatus::from_raw(0)) } diff --git a/packages/zpm/src/commands/tasks/run.rs b/packages/zpm/src/commands/tasks/run.rs deleted file mode 100644 index d7cb0193..00000000 --- a/packages/zpm/src/commands/tasks/run.rs +++ /dev/null @@ -1,1193 +0,0 @@ -use std::{collections::{BTreeMap, BTreeSet, HashMap, HashSet}, io::Write, os::unix::process::ExitStatusExt, process::ExitStatus, sync::{atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, Mutex, RwLock}, time::Instant}; - -use clipanion::cli; -use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::sync::mpsc; -use zpm_tasks::{parse, TaskId, TaskName}; -use zpm_utils::{is_terminal, shell_escape, start_progress, DataType, Path, ProgressHandle, ToFileString, ToHumanString, Unit}; - -use crate::{error::Error, ipc::{IPC_SOCKET_ENV, IPC_CURRENT_TASK_ENV, PushRequest, PushResponse, TaskIpcServer}, project::Project, script::ScriptEnvironment}; - -#[derive(Clone)] -struct SpawnedTaskOptions { - verbose_level: u8, - interlaced: bool, - enable_timers: bool, - silent_dependencies: bool, -} - -struct ProgressState { - total: AtomicUsize, - completed: AtomicUsize, - running_tasks: Mutex>, - gradient_frames: Vec, -} - -fn interpolate_gradient(keyframes: &[(u8, u8, u8)], steps_between: usize) -> Vec<(u8, u8, u8)> { - let mut colors - = Vec::with_capacity(keyframes.len() * steps_between); - - for i in 0..keyframes.len() { - let (r1, g1, b1) = keyframes[i]; - let (r2, g2, b2) = keyframes[(i + 1) % keyframes.len()]; - - for step in 0..steps_between { - let t - = step as f32 / steps_between as f32; - - let r = (r1 as f32 + (r2 as f32 - r1 as f32) * t) as u8; - let g = (g1 as f32 + (g2 as f32 - g1 as f32) * t) as u8; - let b = (b1 as f32 + (b2 as f32 - b1 as f32) * t) as u8; - - colors.push((r, g, b)); - } - } - - colors -} - -fn generate_gradient_frames(text: &str) -> Vec { - let keyframes: [(u8, u8, u8); 4] = [ - (100, 149, 237), - (65, 105, 225), - (30, 144, 255), - (0, 191, 255), - ]; - - let gradient_colors - = interpolate_gradient(&keyframes, 8); - - let chars: Vec - = text.chars().collect(); - - (0..gradient_colors.len()) - .map(|frame| { - let mut result - = String::with_capacity(text.len() * 20); - - for (i, ch) in chars.iter().enumerate() { - let color_idx - = (i * 2 + gradient_colors.len() - frame) % gradient_colors.len(); - - let (r, g, b) - = gradient_colors[color_idx]; - - result.push_str(&format!("\x1b[38;2;{};{};{}m{}", r, g, b, ch)); - } - - result.push_str("\x1b[0m"); - result - }) - .collect() -} - -impl ProgressState { - fn new(total: usize) -> Self { - let gradient_frames - = generate_gradient_frames("Running dependencies"); - - Self { - total: AtomicUsize::new(total), - completed: AtomicUsize::new(0), - running_tasks: Mutex::new(BTreeSet::new()), - gradient_frames, - } - } - - fn add_to_total(&self, count: usize) { - self.total.fetch_add(count, Ordering::Relaxed); - } - - fn add_task(&self, task_name: &str) { - self.running_tasks.lock().unwrap().insert(task_name.to_string()); - } - - fn remove_task(&self, task_name: &str) { - self.running_tasks.lock().unwrap().remove(task_name); - self.completed.fetch_add(1, Ordering::Relaxed); - } - - fn format_progress(&self, frame_idx: usize) -> String { - let total - = self.total.load(Ordering::Relaxed); - - let completed - = self.completed.load(Ordering::Relaxed); - - let running - = self.running_tasks.lock().unwrap().len(); - - let scheduled - = total.saturating_sub(running).saturating_sub(completed); - - let label - = &self.gradient_frames[frame_idx % self.gradient_frames.len()]; - - format!( - "{} {}", - label, - DataType::Custom(128, 128, 128).colorize(&format!( - "· running {} · scheduled {} · completed {}", - running, - scheduled, - completed - )) - ) - } -} - -fn prefix_colors() -> impl Iterator { - static COLORS: [DataType; 5] = [ - DataType::Custom(46, 134, 171), - DataType::Custom(162, 59, 114), - DataType::Custom(241, 143, 1), - DataType::Custom(199, 62, 29), - DataType::Custom(204, 226, 163), - ]; - - COLORS.iter().cycle() -} - -#[derive(Clone)] -struct PreparedTask { - script: String, - cwd: Path, - env: BTreeMap, - prefix: String, -} - -#[cli::command(proxy)] -#[cli::path("tasks", "run")] -#[cli::category("Scripting commands")] -pub struct TaskRun { - #[cli::option("-i,--interlaced", default = true)] - interlaced: bool, - - #[cli::option("-v,--verbose", default = if zpm_utils::is_terminal() {2} else {0}, counter)] - verbose_level: u8, - - #[cli::option("--silent-dependencies", default = false)] - silent_dependencies: bool, - - name: String, - args: Vec, -} - -impl TaskRun { - pub async fn execute(&self) -> Result { - let mut project - = Project::new(None).await?; - - project - .lazy_install().await?; - - let options - = SpawnedTaskOptions { - verbose_level: self.verbose_level, - interlaced: self.interlaced, - enable_timers: project.config.settings.enable_timers.value, - silent_dependencies: self.silent_dependencies, - }; - - run_task_impl(&project, &self.name, &self.args, &options).await - } -} - -pub async fn run_task( - project: &Project, - name: &str, - args: &[String], - verbose_level: u8, - silent_dependencies: bool, - interlaced: bool, - enable_timers: bool, -) -> Result { - let options - = SpawnedTaskOptions { - verbose_level, - interlaced, - enable_timers, - silent_dependencies, - }; - - run_task_impl(project, name, args, &options).await -} - -async fn run_task_impl( - project: &Project, - name: &str, - args: &[String], - options: &SpawnedTaskOptions, -) -> Result { - let task_name - = TaskName::new(name) - .map_err(|_| Error::TaskNameParseError(name.to_string()))?; - - let workspace - = project.active_workspace()?; - - let task_file_path - = workspace.taskfile_path(); - - if !task_file_path.fs_exists() { - return Err(Error::TaskFileNotFound(workspace.path.clone())); - } - - let task_file_content - = task_file_path.fs_read_text()?; - - let task_file - = parse(&task_file_content).map_err(Error::TaskParseError)?; - - if !task_file.tasks.contains_key(task_name.as_str()) { - return Err(Error::TaskNotFound { - workspace: workspace.name.clone(), - task_name: name.to_string(), - }); - } - - let root_task - = TaskId { - workspace: workspace.name.clone(), - task_name, - }; - - let resolved - = project.resolve_task(&root_task)?; - - let ipc_server - = TaskIpcServer::new().await?; - - let socket_name - = ipc_server.socket_name().to_string(); - - let (push_tx, push_rx) - = mpsc::channel::(32); - - let ipc_handle - = tokio::spawn(async move { - ipc_server.run(push_tx).await; - }); - - let result - = execute_resolved_tasks(project, resolved, &root_task, args, options, &socket_name, push_rx).await; - - ipc_handle.abort(); - - result -} - -pub fn task_exists(project: &Project, task_name: &str) -> bool { - let Ok(task_name) - = TaskName::new(task_name) - else { - return false; - }; - - let Ok(workspace) - = project.active_workspace() - else { - return false; - }; - - let task_file_path - = workspace.taskfile_path(); - - if !task_file_path.fs_exists() { - return false; - } - - let Ok(task_file_content) - = task_file_path.fs_read_text() - else { - return false; - }; - - let Ok(task_file) - = parse(&task_file_content) - else { - return false; - }; - - task_file.tasks.contains_key(task_name.as_str()) -} - -struct DynamicExecutionState { - resolved: RwLock, - target_tasks: RwLock>, - original_targets: RwLock>, - completed: RwLock>, - script_finished: RwLock>, - subtasks: RwLock>>, - prepared_tasks: RwLock>, - color_index: RwLock, -} - -impl DynamicExecutionState { - fn new(resolved: zpm_tasks::ResolvedTasks, root_task: TaskId) -> Self { - let mut target_tasks - = HashSet::new(); - - target_tasks.insert(root_task.clone()); - - let mut original_targets - = HashSet::new(); - - original_targets.insert(root_task); - - Self { - resolved: RwLock::new(resolved), - target_tasks: RwLock::new(target_tasks), - original_targets: RwLock::new(original_targets), - completed: RwLock::new(HashSet::new()), - script_finished: RwLock::new(HashSet::new()), - subtasks: RwLock::new(HashMap::new()), - prepared_tasks: RwLock::new(BTreeMap::new()), - color_index: RwLock::new(0), - } - } - - fn all_targets_completed(&self) -> bool { - let targets - = self.target_tasks.read().unwrap(); - - let completed - = self.completed.read().unwrap(); - - targets.iter().all(|t| completed.contains(t)) - } - - fn is_task_fully_completed(&self, task_id: &TaskId) -> bool { - let script_finished - = self.script_finished.read().unwrap(); - - if !script_finished.contains(task_id) { - return false; - } - - let subtasks - = self.subtasks.read().unwrap(); - - let completed - = self.completed.read().unwrap(); - - if let Some(task_subtasks) = subtasks.get(task_id) { - task_subtasks.iter().all(|s| completed.contains(s)) - } else { - true - } - } - - fn try_complete_task(&self, task_id: &TaskId) -> bool { - if self.is_task_fully_completed(task_id) { - let mut completed - = self.completed.write().unwrap(); - - completed.insert(task_id.clone()); - true - } else { - false - } - } - - fn add_pushed_task(&self, project: &Project, task_name: &str, parent_task_id: Option<&str>) -> Result<(TaskId, usize), Error> { - let task_name - = TaskName::new(task_name) - .map_err(|_| Error::TaskNameParseError(task_name.to_string()))?; - - let workspace - = project.active_workspace()?; - - let task_id - = TaskId { - workspace: workspace.name.clone(), - task_name, - }; - - if let Some(parent_str) = parent_task_id { - if let Some(parent_id) = self.parse_task_id(project, parent_str) { - let mut subtasks - = self.subtasks.write().unwrap(); - - subtasks - .entry(parent_id) - .or_default() - .insert(task_id.clone()); - } - } - - { - let completed - = self.completed.read().unwrap(); - - let targets - = self.target_tasks.read().unwrap(); - - if completed.contains(&task_id) || targets.contains(&task_id) { - return Ok((task_id, 0)); - } - } - - let new_resolved - = project.resolve_task(&task_id)?; - - { - let mut resolved - = self.resolved.write().unwrap(); - - for (tid, prereqs) in new_resolved.tasks { - resolved.tasks.entry(tid).or_insert(prereqs); - } - - for (ident, tf) in new_resolved.task_files { - resolved.task_files.entry(ident).or_insert(tf); - } - } - - { - let mut targets - = self.target_tasks.write().unwrap(); - - targets.insert(task_id.clone()); - } - - let new_task_count - = self.prepare_new_tasks(project)?; - - Ok((task_id, new_task_count)) - } - - fn prepare_new_tasks(&self, project: &Project) -> Result { - let resolved - = self.resolved.read().unwrap(); - - let mut prepared - = self.prepared_tasks.write().unwrap(); - - let mut color_index - = self.color_index.write().unwrap(); - - let colors: Vec<&DataType> - = prefix_colors().take(5).collect(); - - let mut new_count - = 0; - - for task_id in resolved.tasks.keys() { - if prepared.contains_key(task_id) { - continue; - } - - let Some(task_file) - = resolved.task_files.get(&task_id.workspace) - else { - continue; - }; - - let Some(task) - = task_file.tasks.get(task_id.task_name.as_str()) - else { - continue; - }; - - if task.script.is_empty() { - continue; - } - - let Ok(workspace) - = project.workspace_by_ident(&task_id.workspace) - else { - continue; - }; - - let script - = task.script.join("\n"); - - let mut env - = BTreeMap::new(); - - env.insert( - "npm_lifecycle_event".to_string(), - task_id.task_name.as_str().to_string(), - ); - - let color - = colors[*color_index % colors.len()]; - - *color_index += 1; - - let prefix - = color.colorize(&format!( - "[{}:{}]: ", - task_id.workspace.to_file_string(), - task_id.task_name.as_str() - )); - - prepared.insert( - task_id.clone(), - PreparedTask { - script, - cwd: workspace.path.clone(), - env, - prefix, - }, - ); - - new_count += 1; - } - - Ok(new_count) - } - - fn parse_task_id(&self, project: &Project, task_id_str: &str) -> Option { - let (workspace_str, task_name_str) - = task_id_str.split_once(':')?; - - let task_name - = TaskName::new(task_name_str).ok()?; - - let ident - = zpm_primitives::Ident::new(workspace_str); - - let workspace - = project.workspace_by_ident(&ident).ok()?; - - Some(TaskId { - workspace: workspace.name.clone(), - task_name, - }) - } -} - -async fn execute_resolved_tasks( - project: &Project, - resolved: zpm_tasks::ResolvedTasks, - target_task: &TaskId, - args: &[String], - options: &SpawnedTaskOptions, - socket_name: &str, - push_rx: mpsc::Receiver, -) -> Result { - if resolved.tasks.is_empty() { - return Ok(ExitStatus::from_raw(0)); - } - - let state - = Arc::new(DynamicExecutionState::new(resolved, target_task.clone())); - - state.prepare_new_tasks(project)?; - - let dependency_count - = { - let resolved - = state.resolved.read().unwrap(); - - let prepared - = state.prepared_tasks.read().unwrap(); - - resolved.tasks.keys() - .filter(|t| *t != target_task && prepared.contains_key(*t)) - .count() - }; - - let show_progress - = options.silent_dependencies && is_terminal() && dependency_count > 0; - - let mut progress_handle - = if show_progress { - let progress_state - = Arc::new(ProgressState::new(dependency_count)); - - let progress_state_clone - = progress_state.clone(); - - Some(( - start_progress(move |frame_idx| progress_state_clone.format_progress(frame_idx)), - progress_state, - )) - } else { - None - }; - - execute_tasks_impl( - project, - state, - target_task, - args, - options, - socket_name, - push_rx, - &mut progress_handle, - ).await -} - -async fn execute_tasks_impl( - project: &Project, - state: Arc, - root_task: &TaskId, - args: &[String], - options: &SpawnedTaskOptions, - socket_name: &str, - mut push_rx: mpsc::Receiver, - progress: &mut Option<(ProgressHandle, Arc)>, -) -> Result { - use std::collections::HashMap; - use tokio::task::JoinHandle; - - let is_first_printed - = Arc::new(AtomicBool::new(true)); - - let mut running_handles: HashMap>> - = HashMap::new(); - - loop { - if state.all_targets_completed() { - break; - } - - while let Ok(request) = push_rx.try_recv() { - let response - = match state.add_pushed_task(project, &request.task_name, request.parent_task_id.as_deref()) { - Ok((_, new_count)) => { - if let Some((_, progress_state)) = progress.as_ref() { - progress_state.add_to_total(new_count); - } - PushResponse::Ok - } - Err(e) => PushResponse::Error(e.to_string()), - }; - - let _ = request.response_tx.send(response); - } - - let ready_tasks: Vec - = { - let resolved - = state.resolved.read().unwrap(); - - let completed - = state.completed.read().unwrap(); - - let script_finished - = state.script_finished.read().unwrap(); - - let running: HashSet - = running_handles.keys().cloned().collect(); - - resolved - .tasks - .iter() - .filter(|(task_id, prerequisites)| { - !completed.contains(*task_id) - && !script_finished.contains(*task_id) - && !running.contains(*task_id) - && prerequisites.iter().all(|p| completed.contains(p)) - }) - .map(|(task_id, _)| task_id.clone()) - .collect() - }; - - for task_id in ready_tasks { - let original_targets - = state.original_targets.read().unwrap(); - - let is_target - = original_targets.contains(&task_id); - - drop(original_targets); - - let task_args: Vec - = if &task_id == root_task { args.to_vec() } else { vec![] }; - - let prepared_opt - = { - let prepared_tasks - = state.prepared_tasks.read().unwrap(); - - prepared_tasks.get(&task_id).cloned() - }; - - if let Some(prepared) = prepared_opt { - if is_target { - if let Some((handle, _)) = progress { - handle.stop(); - } - } - - let task_display_name - = format!( - "{}:{}", - task_id.workspace.to_file_string(), - task_id.task_name.as_str() - ); - - if !is_target { - if let Some((_, state)) = progress.as_ref() { - state.add_task(&task_display_name); - } - } - - let is_first - = is_first_printed.clone(); - - let opts - = options.clone(); - - let progress_state - = progress.as_ref().map(|(_, state)| state.clone()); - - let socket - = socket_name.to_string(); - - let task_id_str - = task_display_name.clone(); - - let handle - = tokio::spawn(async move { - let result - = execute_prepared_task_with_ipc(&prepared, &task_args, is_first, &opts, is_target, &socket, &task_id_str).await; - - if !is_target { - if let Some(state) = progress_state { - state.remove_task(&task_display_name); - } - } - - result - }); - - running_handles.insert(task_id, handle); - } else { - let mut completed - = state.completed.write().unwrap(); - - completed.insert(task_id); - } - } - - if running_handles.is_empty() { - if state.all_targets_completed() { - break; - } - - tokio::select! { - Some(request) = push_rx.recv() => { - let response - = match state.add_pushed_task(project, &request.task_name, request.parent_task_id.as_deref()) { - Ok((_, new_count)) => { - if let Some((_, progress_state)) = progress.as_ref() { - progress_state.add_to_total(new_count); - } - PushResponse::Ok - } - Err(e) => PushResponse::Error(e.to_string()), - }; - - let _ = request.response_tx.send(response); - } - } - - continue; - } - - let completed_task: (TaskId, Result); - - tokio::select! { - Some(request) = push_rx.recv() => { - let response - = match state.add_pushed_task(project, &request.task_name, request.parent_task_id.as_deref()) { - Ok((_, new_count)) => { - if let Some((_, progress_state)) = progress.as_ref() { - progress_state.add_to_total(new_count); - } - PushResponse::Ok - } - Err(e) => PushResponse::Error(e.to_string()), - }; - - let _ = request.response_tx.send(response); - continue; - } - - result = async { - use futures::future::select_all; - let handles: Vec<_> = running_handles.iter_mut().collect(); - let task_ids: Vec<_> = handles.iter().map(|(id, _)| (*id).clone()).collect(); - let futures: Vec<_> = handles.into_iter().map(|(_, h)| Box::pin(async move { h.await })).collect(); - let (result, idx, _) = select_all(futures).await; - (task_ids[idx].clone(), result) - } => { - let (task_id, join_result) = result; - running_handles.remove(&task_id); - - match join_result { - Ok(task_result) => { - completed_task = (task_id, task_result); - } - Err(e) => { - if let Some((handle, _)) = progress { - handle.stop(); - } - return Err(Error::TaskJoinError(e.to_string())); - } - } - } - } - - { - let (task_id, task_result) = completed_task; - match task_result { - Ok(status) if status.success() => { - { - let mut script_finished - = state.script_finished.write().unwrap(); - - script_finished.insert(task_id.clone()); - } - - state.try_complete_task(&task_id); - - let parents_to_check: Vec - = { - let subtasks - = state.subtasks.read().unwrap(); - - subtasks - .iter() - .filter(|(_, children)| children.contains(&task_id)) - .map(|(parent, _)| parent.clone()) - .collect() - }; - - for parent in parents_to_check { - state.try_complete_task(&parent); - } - } - Ok(status) => { - if let Some((handle, _)) = progress { - handle.stop(); - } - return Ok(status); - } - Err(e) => { - if let Some((handle, _)) = progress { - handle.stop(); - } - return Err(e); - } - } - } - } - - Ok(ExitStatus::from_raw(0)) -} - -async fn execute_prepared_task_with_ipc( - prepared: &PreparedTask, - args: &[String], - is_first_printed: Arc, - options: &SpawnedTaskOptions, - is_target: bool, - socket_name: &str, - task_id_str: &str, -) -> Result { - let mut prepared_with_ipc - = prepared.clone(); - - prepared_with_ipc.env.insert( - IPC_SOCKET_ENV.to_string(), - socket_name.to_string(), - ); - - prepared_with_ipc.env.insert( - IPC_CURRENT_TASK_ENV.to_string(), - task_id_str.to_string(), - ); - - execute_prepared_task(&prepared_with_ipc, args, is_first_printed, options, is_target).await -} - -fn build_task_script(script: &str, args: &[String]) -> String { - if args.is_empty() { - script.to_string() - } else { - let escaped_args: Vec - = args.iter() - .map(|a| shell_escape(a)) - .collect(); - - format!("set -- {}; {}", escaped_args.join(" "), script) - } -} - -fn write_line(writer: &mut std::io::StdoutLock<'_>, prefix: &str, line: &str, verbose_level: u8) { - if verbose_level >= 1 { - writeln!(writer, "{}{}", prefix, line).ok(); - } else { - writeln!(writer, "{}", line).ok(); - } -} - -async fn execute_prepared_task( - prepared: &PreparedTask, - args: &[String], - is_first_printed: Arc, - options: &SpawnedTaskOptions, - is_target: bool, -) -> Result { - let start - = Instant::now(); - - let mut env - = ScriptEnvironment::new()?; - - for (key, value) in &prepared.env { - env = env.with_env_variable(key, value); - } - - let show_output - = !options.silent_dependencies || is_target; - - let use_inherited_stdio - = options.silent_dependencies && options.verbose_level == 0 && is_target; - - if use_inherited_stdio { - execute_inherited(prepared, args, env).await - } else if options.interlaced && show_output { - execute_interlaced(prepared, args, env, start, is_first_printed, options).await - } else { - execute_buffered(prepared, args, env, start, is_first_printed, options, show_output).await - } -} - -async fn execute_inherited( - prepared: &PreparedTask, - args: &[String], - env: ScriptEnvironment, -) -> Result { - let script - = build_task_script(&prepared.script, args); - - let empty_args: [String; 0] - = []; - - let status - = env - .with_cwd(prepared.cwd.clone()) - .run_script_inherited(&script, empty_args) - .await?; - - Ok(status) -} - -async fn execute_interlaced( - prepared: &PreparedTask, - args: &[String], - env: ScriptEnvironment, - start: Instant, - _is_first_printed: Arc, - options: &SpawnedTaskOptions, -) -> Result { - let script - = build_task_script(&prepared.script, args); - - let empty_args: [String; 0] - = []; - - let mut running - = env - .with_cwd(prepared.cwd.clone()) - .spawn_script(&script, empty_args) - .await?; - - let child_stdout - = running.child.stdout.take().expect("Failed to capture stdout"); - - let child_stderr - = running.child.stderr.take().expect("Failed to capture stderr"); - - let mut stdout_reader - = BufReader::new(child_stdout).lines(); - - let mut stderr_reader - = BufReader::new(child_stderr).lines(); - - if options.verbose_level >= 2 { - let mut writer - = std::io::stdout().lock(); - - write_line(&mut writer, &prepared.prefix, "Process started", options.verbose_level); - } - - let prefix - = prepared.prefix.clone(); - - let verbose - = options.verbose_level; - - loop { - tokio::select! { - line = stdout_reader.next_line() => { - match line { - Ok(Some(line)) => { - let mut writer - = std::io::stdout().lock(); - - write_line(&mut writer, &prefix, &line, verbose); - } - Ok(None) => break, - Err(_) => break, - } - } - line = stderr_reader.next_line() => { - match line { - Ok(Some(line)) => { - let mut writer - = std::io::stdout().lock(); - - write_line(&mut writer, &prefix, &line, verbose); - } - Ok(None) => {} - Err(_) => {} - } - } - } - } - - while let Ok(Some(line)) = stderr_reader.next_line().await { - let mut writer - = std::io::stdout().lock(); - - write_line(&mut writer, &prefix, &line, verbose); - } - - let status - = running.child.wait().await?; - - let duration - = start.elapsed(); - - if options.verbose_level >= 2 { - let mut writer - = std::io::stdout().lock(); - - let status_string - = match status.code() { - Some(code) => format!("exit code {}", DataType::Number.colorize(&format!("{}", code))), - None => "exit code unknown".to_string(), - }; - - if options.enable_timers { - write_line( - &mut writer, - &prepared.prefix, - &format!( - "Process exited ({}), completed in {}", - status_string, - Unit::duration(duration.as_secs_f64()).to_print_string() - ), - options.verbose_level, - ); - } else { - write_line( - &mut writer, - &prepared.prefix, - &format!("Process exited ({})", status_string), - options.verbose_level, - ); - } - } - - Ok(status) -} - -async fn execute_buffered( - prepared: &PreparedTask, - args: &[String], - env: ScriptEnvironment, - start: Instant, - is_first_printed: Arc, - options: &SpawnedTaskOptions, - show_output: bool, -) -> Result { - let script - = build_task_script(&prepared.script, args); - - let empty_args: [String; 0] - = []; - - let result - = env - .with_cwd(prepared.cwd.clone()) - .run_script(&script, empty_args) - .await?; - - let output - = result.output(); - - let duration - = start.elapsed(); - - let is_failure_output - = !show_output && !output.status.success(); - - if show_output || is_failure_output { - let verbose_level - = if is_failure_output { 2 } else { options.verbose_level }; - - let stdout - = String::from_utf8_lossy(&output.stdout); - - let stderr - = String::from_utf8_lossy(&output.stderr); - - let mut writer - = std::io::stdout().lock(); - - if verbose_level >= 2 && !is_first_printed.swap(false, Ordering::Relaxed) { - writeln!(writer).ok(); - } - - if verbose_level >= 2 { - write_line(&mut writer, &prepared.prefix, "Process started", verbose_level); - } - - for line in stdout.lines() { - write_line(&mut writer, &prepared.prefix, line, verbose_level); - } - - for line in stderr.lines() { - write_line(&mut writer, &prepared.prefix, line, verbose_level); - } - - if verbose_level >= 2 { - let status_string - = match output.status.code() { - Some(code) => format!("exit code {}", DataType::Number.colorize(&format!("{}", code))), - None => "exit code unknown".to_string(), - }; - - if options.enable_timers { - write_line( - &mut writer, - &prepared.prefix, - &format!( - "Process exited ({}), completed in {}", - status_string, - Unit::duration(duration.as_secs_f64()).to_print_string() - ), - verbose_level, - ); - } else { - write_line( - &mut writer, - &prepared.prefix, - &format!("Process exited ({})", status_string), - verbose_level, - ); - } - } - } - - Ok(output.status) -} diff --git a/packages/zpm/src/commands/tasks/run_buffered.rs b/packages/zpm/src/commands/tasks/run_buffered.rs new file mode 100644 index 00000000..1de5a154 --- /dev/null +++ b/packages/zpm/src/commands/tasks/run_buffered.rs @@ -0,0 +1,120 @@ +use std::io::Write; +use std::process::ExitStatus; + +use async_trait::async_trait; +use clipanion::cli; + +use super::helpers::format_task_id; +use super::runner::{run_task, TaskRunConfig, TaskRunContext, TaskRunHandler}; +use crate::daemon::SubscriptionScope; +use crate::error::Error; + +struct BufferedHandler; + +#[async_trait] +impl TaskRunHandler for BufferedHandler { + fn config(&self) -> TaskRunConfig { + TaskRunConfig { + output_subscription: SubscriptionScope::None, + status_subscription: SubscriptionScope::FullTree, + } + } + + async fn on_output_line(&mut self, _ctx: &mut TaskRunContext, _task_id: &str, _line: &str, _stream: &str) {} + + async fn on_task_started(&mut self, ctx: &mut TaskRunContext, task_id: &str, _is_target: bool) { + if ctx.verbose_level >= 2 { + let mut stdout + = std::io::stdout().lock(); + + writeln!(stdout, "[{}]: Process started", format_task_id(task_id)).ok(); + } + } + + async fn on_task_completed( + &mut self, + ctx: &mut TaskRunContext, + task_id: &str, + exit_code: i32, + _is_target: bool, + ) { + if let Ok(lines) = ctx.client.get_task_output(task_id).await { + let mut stdout + = std::io::stdout().lock(); + + if !lines.is_empty() { + if ctx.is_first_line { + if ctx.has_attached() { + writeln!(stdout, "").ok(); + } + + ctx.is_first_line = false; + } + + for output_line in lines { + if ctx.verbose_level >= 1 { + writeln!(stdout, "[{}]: {}", format_task_id(task_id), output_line.line).ok(); + } else { + writeln!(stdout, "{}", output_line.line).ok(); + } + } + } + } + + if ctx.verbose_level >= 2 { + let mut stdout + = std::io::stdout().lock(); + + writeln!(stdout, "[{}]: Process exited (exit code {})", format_task_id(task_id), exit_code).ok(); + } + } + + fn on_ctrl_c(&mut self) {} +} + +/// Run a task with buffered output +/// +/// This command runs a task with buffered output mode. In this mode, the output +/// from each task (including dependencies) is collected and displayed only after +/// the task completes. This provides cleaner output when running multiple tasks +/// that might produce interleaved output. +/// +/// The buffered mode is useful for CI environments or when you want to see the +/// complete output of each task as a unit rather than interleaved lines. +#[cli::command(proxy)] +#[cli::path("tasks", "run")] +#[cli::category("Task management commands")] +pub struct TaskRunBuffered { + /// Enable buffered output mode + #[cli::option("--buffered")] + _buffered: bool, + + /// Increase the verbosity level (can be repeated) + #[cli::option("-v,--verbose", default = if zpm_utils::is_terminal() {2} else {0}, counter)] + verbose_level: u8, + + /// Run the task without connecting to the daemon + #[cli::option("--standalone", default = false)] + standalone: bool, + + /// Name of the task to run + name: String, + + /// Arguments to pass to the task + args: Vec, +} + +impl TaskRunBuffered { + pub async fn execute(&self) -> Result { + let mut handler + = BufferedHandler; + + run_task( + &mut handler, + &self.name, + &self.args, + self.standalone, + self.verbose_level, + ).await + } +} diff --git a/packages/zpm/src/commands/tasks/run_interlaced.rs b/packages/zpm/src/commands/tasks/run_interlaced.rs new file mode 100644 index 00000000..ea2ea382 --- /dev/null +++ b/packages/zpm/src/commands/tasks/run_interlaced.rs @@ -0,0 +1,188 @@ +use std::io::Write; +use std::process::ExitStatus; + +use async_trait::async_trait; +use clipanion::cli; +use serde_json::json; + +use super::helpers::{format_task_id, format_timestamp}; +use super::runner::{run_task, TaskRunConfig, TaskRunContext, TaskRunHandler}; +use crate::daemon::SubscriptionScope; +use crate::error::Error; + +struct InterlacedHandler { + timestamps: bool, + json: bool, +} + +#[async_trait] +impl TaskRunHandler for InterlacedHandler { + fn config(&self) -> TaskRunConfig { + TaskRunConfig { + output_subscription: SubscriptionScope::FullTree, + status_subscription: SubscriptionScope::FullTree, + } + } + + async fn on_output_line(&mut self, ctx: &mut TaskRunContext, task_id: &str, line: &str, stream: &str) { + let mut stdout + = std::io::stdout().lock(); + + if self.json { + writeln!(stdout, "{}", json!({ + "type": "output", + "taskId": format_task_id(task_id), + "stream": stream, + "line": line, + })).ok(); + return; + } + + if ctx.is_first_line { + if ctx.has_attached() { + writeln!(stdout, "").ok(); + } + + ctx.is_first_line = false; + } + + if self.timestamps { + if ctx.verbose_level >= 1 { + writeln!(stdout, "[{}] [{}]: {}", format_timestamp(), format_task_id(task_id), line).ok(); + } else { + writeln!(stdout, "[{}] {}", format_timestamp(), line).ok(); + } + } else if ctx.verbose_level >= 1 { + writeln!(stdout, "[{}]: {}", format_task_id(task_id), line).ok(); + } else { + writeln!(stdout, "{}", line).ok(); + } + } + + async fn on_task_started(&mut self, ctx: &mut TaskRunContext, task_id: &str, _is_target: bool) { + if self.json { + let mut stdout + = std::io::stdout().lock(); + + writeln!(stdout, "{}", json!({ + "type": "task-started", + "taskId": format_task_id(task_id), + })).ok(); + return; + } + + if ctx.verbose_level >= 2 { + let mut stdout + = std::io::stdout().lock(); + + if self.timestamps { + writeln!(stdout, "[{}] [{}]: Process started", format_timestamp(), format_task_id(task_id)).ok(); + } else { + writeln!(stdout, "[{}]: Process started", format_task_id(task_id)).ok(); + } + } + } + + async fn on_task_completed( + &mut self, + ctx: &mut TaskRunContext, + task_id: &str, + exit_code: i32, + _is_target: bool, + ) { + if self.json { + let mut stdout + = std::io::stdout().lock(); + + writeln!(stdout, "{}", json!({ + "type": "task-completed", + "taskId": format_task_id(task_id), + "exitCode": exit_code, + })).ok(); + return; + } + + if ctx.verbose_level >= 2 { + let mut stdout + = std::io::stdout().lock(); + + if self.timestamps { + writeln!(stdout, "[{}] [{}]: Process exited (exit code {})", format_timestamp(), format_task_id(task_id), exit_code).ok(); + } else { + writeln!(stdout, "[{}]: Process exited (exit code {})", format_task_id(task_id), exit_code).ok(); + } + } + } + + async fn on_task_cancelled( + &mut self, + _ctx: &mut TaskRunContext, + task_id: &str, + _is_target: bool, + ) { + if self.json { + let mut stdout + = std::io::stdout().lock(); + + writeln!(stdout, "{}", json!({ + "type": "task-cancelled", + "taskId": format_task_id(task_id), + })).ok(); + } + } + + fn on_ctrl_c(&mut self) {} +} + +/// Run a task with interlaced output (default) +/// +/// This command runs a task with interlaced output mode. In this mode, output +/// from the task and its dependencies is displayed in real-time as it is +/// produced. Lines from different tasks may be interleaved. +/// +/// This is the default mode for running tasks and provides the most responsive +/// feedback during execution. +#[cli::command(proxy)] +#[cli::path("tasks", "run")] +#[cli::category("Task management commands")] +pub struct TaskRunInterlaced { + /// Increase the verbosity level (can be repeated) + #[cli::option("-v,--verbose", default = if zpm_utils::is_terminal() {2} else {0}, counter)] + verbose_level: u8, + + /// Prefix each output line with a timestamp + #[cli::option("--timestamps", default = false)] + timestamps: bool, + + /// Output JSON objects (one per line) for each task event + #[cli::option("--json", default = false)] + json: bool, + + /// Run the task without connecting to the daemon + #[cli::option("--standalone", default = false)] + standalone: bool, + + /// Name of the task to run + name: String, + + /// Arguments to pass to the task + args: Vec, +} + +impl TaskRunInterlaced { + pub async fn execute(&self) -> Result { + let mut handler + = InterlacedHandler { + timestamps: self.timestamps, + json: self.json, + }; + + run_task( + &mut handler, + &self.name, + &self.args, + self.standalone, + self.verbose_level, + ).await + } +} diff --git a/packages/zpm/src/commands/tasks/run_silent_dependencies.rs b/packages/zpm/src/commands/tasks/run_silent_dependencies.rs new file mode 100644 index 00000000..ce2c38f8 --- /dev/null +++ b/packages/zpm/src/commands/tasks/run_silent_dependencies.rs @@ -0,0 +1,194 @@ +use std::io::Write; +use std::process::ExitStatus; +use std::sync::Arc; + +use async_trait::async_trait; +use clipanion::{Environment, cli}; +use zpm_utils::{is_terminal, start_progress, ProgressHandle}; + +use super::helpers::format_task_id; +use super::runner::{run_task, TaskRunConfig, TaskRunContext, TaskRunHandler}; +use crate::daemon::{ProgressState, SubscriptionScope}; +use crate::error::Error; + +struct SilentDependenciesHandler { + progress_handle: Option<(ProgressHandle, Arc)>, +} + +impl SilentDependenciesHandler { + fn stop_progress(&mut self) { + if let Some((ref mut handle, _)) = self.progress_handle { + handle.stop(); + } + } +} + +#[async_trait] +impl TaskRunHandler for SilentDependenciesHandler { + fn config(&self) -> TaskRunConfig { + TaskRunConfig { + output_subscription: SubscriptionScope::TargetOnly, + status_subscription: SubscriptionScope::FullTree, + } + } + + fn on_tasks_pushed(&mut self, ctx: &TaskRunContext) { + let show_progress + = is_terminal() && ctx.result.dependency_count > 0; + + if show_progress { + let progress_state + = Arc::new(ProgressState::new(ctx.result.dependency_count)); + + let progress_state_clone + = progress_state.clone(); + + self.progress_handle = Some(( + start_progress(move |frame_idx| progress_state_clone.format_progress(frame_idx)), + progress_state, + )); + } + } + + async fn on_output_line(&mut self, ctx: &mut TaskRunContext, _task_id: &str, line: &str, _stream: &str) { + let mut stdout + = std::io::stdout().lock(); + + if ctx.is_first_line { + if ctx.has_attached() { + writeln!(stdout, "").ok(); + } + + ctx.is_first_line = false; + } + + writeln!(stdout, "{}", line).ok(); + } + + async fn on_task_started(&mut self, _ctx: &mut TaskRunContext, task_id: &str, is_target: bool) { + if is_target { + self.stop_progress(); + } else { + if let Some((_, ref progress_state)) = self.progress_handle { + progress_state.add_task(&format_task_id(task_id)); + } + } + } + + async fn on_task_completed( + &mut self, + ctx: &mut TaskRunContext, + task_id: &str, + exit_code: i32, + is_target: bool, + ) { + if !is_target { + if let Some((_, ref progress_state)) = self.progress_handle { + progress_state.remove_task(&format_task_id(task_id)); + } + + if exit_code != 0 { + self.stop_progress(); + + let lines = ctx.client.get_task_output(task_id).await.ok(); + + if lines.as_ref().map_or(false, |l| !l.is_empty()) { + let mut stdout = std::io::stdout().lock(); + + writeln!(stdout, "[{}]: Process started", format_task_id(task_id)).ok(); + + for output_line in lines.unwrap() { + writeln!(stdout, "[{}]: {}", format_task_id(task_id), output_line.line).ok(); + } + + writeln!(stdout, "[{}]: Process exited (exit code {})", format_task_id(task_id), exit_code).ok(); + } + } + } else if exit_code != 0 { + // Target task failed. + // Output was already printed live via on_output_line (TargetOnly subscription), + // so do NOT replay it from the buffer — that would duplicate every line. + self.stop_progress(); + } + } + + async fn on_task_cancelled( + &mut self, + _ctx: &mut TaskRunContext, + task_id: &str, + is_target: bool, + ) { + if let Some((_, ref progress_state)) = self.progress_handle { + progress_state.remove_task(&format_task_id(task_id)); + } + + if is_target { + self.stop_progress(); + } + } + + fn on_ctrl_c(&mut self) { + self.stop_progress(); + } +} + +/// Run a task with silent dependency output +/// +/// This command runs a task while suppressing output from dependency tasks. +/// Only the output from the target task itself is shown, with a progress +/// indicator displayed while dependencies are running. +/// +/// If a dependency task fails, its output will be displayed to help diagnose +/// the failure. This mode is useful when you're primarily interested in the +/// output of the main task and dependencies are expected to succeed silently. +#[cli::command(proxy)] +#[cli::path("tasks", "run")] +#[cli::category("Task management commands")] +pub struct TaskRunSilentDependencies { + /// Enable silent dependencies mode + #[cli::option("--silent-dependencies")] + _silent_dependencies: bool, + + /// Increase the verbosity level (can be repeated) + #[cli::option("-v,--verbose", default = if zpm_utils::is_terminal() {2} else {0}, counter)] + verbose_level: u8, + + /// Run the task without connecting to the daemon + #[cli::option("--standalone", default = false)] + standalone: bool, + + /// Name of the task to run + name: String, + + /// Arguments to pass to the task + args: Vec, +} + +impl TaskRunSilentDependencies { + pub fn new(cli_environment: &Environment, name: String, args: Vec) -> Self { + Self { + cli_environment: cli_environment.clone(), + cli_path: vec!["tasks".to_string(), "run".to_string()], + _silent_dependencies: true, + verbose_level: 0, + standalone: false, + name, + args, + } + } + + pub async fn execute(&self) -> Result { + let mut handler + = SilentDependenciesHandler { + progress_handle: None, + }; + + run_task( + &mut handler, + &self.name, + &self.args, + self.standalone, + self.verbose_level, + ).await + } +} diff --git a/packages/zpm/src/commands/tasks/runner.rs b/packages/zpm/src/commands/tasks/runner.rs new file mode 100644 index 00000000..b9def3a0 --- /dev/null +++ b/packages/zpm/src/commands/tasks/runner.rs @@ -0,0 +1,280 @@ +use std::collections::HashSet; +use std::os::unix::process::ExitStatusExt; +use std::process::ExitStatus; +use std::sync::Arc; + +/// Create an ExitStatus from a logical exit code. +/// On Unix, `from_raw` expects a wait status where the exit code is in bits 8-15. +fn exit_status_from_code(code: i32) -> ExitStatus { + ExitStatus::from_raw(code << 8) +} + +use async_trait::async_trait; +use uuid::Uuid; +use zpm_utils::ToFileString; + +use super::helpers::{is_long_lived_task, print_attach_header, print_detach_footer}; +use crate::daemon::{ + DaemonClient, DaemonNotification, PushTasksResult, StandaloneDaemonHandle, SubscriptionScope, + TaskSubscription, +}; +use crate::error::Error; +use crate::project::Project; + +pub struct TaskRunConfig { + pub output_subscription: SubscriptionScope, + pub status_subscription: SubscriptionScope, +} + +pub struct TaskRunContext { + pub client: DaemonClient, + pub result: PushTasksResult, + pub target_task_ids: HashSet, + pub completed_tasks: HashSet, + pub exit_code: i32, + pub is_first_line: bool, + pub verbose_level: u8, +} + +impl TaskRunContext { + pub fn has_attached(&self) -> bool { + !self.result.attached_long_lived.is_empty() + } + + pub fn has_long_lived_target(&self) -> bool { + self.target_task_ids.iter().any(|id| is_long_lived_task(id)) + } + + pub fn is_target(&self, task_id: &str) -> bool { + self.target_task_ids.contains(task_id) + } + + pub fn mark_completed(&mut self, task_id: String, code: i32) { + if self.target_task_ids.contains(&task_id) { + self.completed_tasks.insert(task_id); + if code != 0 { + self.exit_code = code; + } + } + } + + pub fn all_completed(&self) -> bool { + self.completed_tasks.len() >= self.target_task_ids.len() + } +} + +#[async_trait] +pub trait TaskRunHandler: Send { + fn config(&self) -> TaskRunConfig; + + fn on_tasks_pushed(&mut self, ctx: &TaskRunContext) { + let _ = ctx; + } + + async fn on_output_line(&mut self, ctx: &mut TaskRunContext, task_id: &str, line: &str, stream: &str); + + async fn on_task_started(&mut self, ctx: &mut TaskRunContext, task_id: &str, is_target: bool); + + async fn on_task_completed( + &mut self, + ctx: &mut TaskRunContext, + task_id: &str, + exit_code: i32, + is_target: bool, + ); + + async fn on_task_cancelled( + &mut self, + ctx: &mut TaskRunContext, + task_id: &str, + is_target: bool, + ) { + let _ = (ctx, task_id, is_target); + } + + fn on_ctrl_c(&mut self); +} + +pub async fn run_task( + handler: &mut impl TaskRunHandler, + name: &str, + args: &[String], + standalone: bool, + verbose_level: u8, +) -> Result { + let mut project + = Project::new(None).await?; + + project.lazy_install().await?; + + let workspace + = project.active_workspace()?; + + let workspace_name + = workspace.name.to_file_string(); + + let project_cwd + = project.project_cwd.clone(); + + let _daemon_handle: Option; + + let mut client + = if standalone { + let project + = Arc::new(project); + + let (c, handle) + = DaemonClient::connect_standalone(project).await?; + + _daemon_handle = Some(handle); + c + } else { + _daemon_handle = None; + DaemonClient::connect(&project_cwd).await? + }; + + let context_id + = Uuid::new_v4().to_string(); + + let context_id_for_cancel + = context_id.clone(); + + let task_subscriptions + = vec![TaskSubscription { + name: name.to_string(), + args: args.to_vec(), + }]; + + let config + = handler.config(); + + let mut ctx + = TaskRunContext { + result: client + .push_tasks_with_subscriptions( + task_subscriptions, + None, + Some(workspace_name), + config.output_subscription, + config.status_subscription, + Some(context_id), + ) + .await?, + client, + target_task_ids: HashSet::new(), + completed_tasks: HashSet::new(), + exit_code: 0, + is_first_line: true, + verbose_level, + }; + + if ctx.result.task_ids.is_empty() { + return Err(Error::TaskPushFailed("No tasks enqueued".to_string())); + } + + for attached in &ctx.result.attached_long_lived { + print_attach_header(attached); + } + + ctx.target_task_ids + = ctx.result.task_ids.clone().into_iter().collect(); + + handler.on_tasks_pushed(&ctx); + + loop { + let notification + = tokio::select! { + biased; + + _ = tokio::signal::ctrl_c() => { + if ctx.has_long_lived_target() { + handler.on_ctrl_c(); + + println!(); + + if !ctx.is_first_line { + println!(); + } + + print_detach_footer(name); + + ctx.client.close(); + + if standalone { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + return Ok(exit_status_from_code(0)); + } else { + // Cancel all tasks in this context + handler.on_ctrl_c(); + + let _ = ctx.client.cancel_context(&context_id_for_cancel).await; + + ctx.client.close(); + + if standalone { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + // Exit with SIGINT code (130 = 128 + 2) + return Ok(exit_status_from_code(130)); + } + } + n = ctx.client.recv_notification() => n?, + }; + + match notification { + DaemonNotification::TaskOutputLine { task_id, line, stream } => { + handler.on_output_line(&mut ctx, &task_id, &line, &stream).await; + } + + DaemonNotification::TaskStarted { task_id } => { + let is_target + = ctx.is_target(&task_id); + + handler.on_task_started(&mut ctx, &task_id, is_target).await; + } + + DaemonNotification::TaskCompleted { task_id, exit_code } => { + let is_target + = ctx.is_target(&task_id); + + handler + .on_task_completed(&mut ctx, &task_id, exit_code, is_target) + .await; + + ctx.mark_completed(task_id, exit_code); + + if ctx.all_completed() { + break; + } + } + + DaemonNotification::TaskCancelled { task_id } => { + let is_target + = ctx.is_target(&task_id); + + handler + .on_task_cancelled(&mut ctx, &task_id, is_target) + .await; + + ctx.mark_completed(task_id, 1); + + if ctx.all_completed() { + break; + } + } + + DaemonNotification::TaskWarmUpComplete { .. } => {} + } + } + + ctx.client.close(); + + if standalone { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + Ok(exit_status_from_code(ctx.exit_code)) +} diff --git a/packages/zpm/src/commands/tasks/stats.rs b/packages/zpm/src/commands/tasks/stats.rs new file mode 100644 index 00000000..a04088cb --- /dev/null +++ b/packages/zpm/src/commands/tasks/stats.rs @@ -0,0 +1,54 @@ +use std::{os::unix::process::ExitStatusExt, process::ExitStatus}; + +use clipanion::cli; + +use crate::daemon::DaemonClient; +use crate::error::Error; +use crate::project::Project; + +/// Get internal state statistics from the daemon +/// +/// This command returns statistics about the daemon's internal state, +/// useful for debugging and testing memory management. +#[cli::command] +#[cli::path("tasks", "stats")] +#[cli::category("Task management commands")] +pub struct TaskStats { + /// Output as JSON + #[cli::option("--json", default = false)] + json: bool, +} + +impl TaskStats { + pub async fn execute(&self) -> Result { + let project = Project::new(None).await?; + + let mut client = DaemonClient::connect(&project.project_cwd).await?; + + let stats = client.get_stats().await?; + + client.close(); + + if self.json { + println!( + "{}", + serde_json::json!({ + "tasksCount": stats.tasks_count, + "preparedCount": stats.prepared_count, + "subtasksCount": stats.subtasks_count, + "outputBufferCount": stats.output_buffer_count, + "closedTasksCount": stats.closed_tasks_count, + }) + ); + } else { + println!("Daemon State Statistics:"); + println!(" tasks: {}", stats.tasks_count); + println!(" prepared: {}", stats.prepared_count); + println!(" subtasks: {}", stats.subtasks_count); + println!(" output_buffer: {}", stats.output_buffer_count); + println!(" closed_tasks: {}", stats.closed_tasks_count); + } + + Ok(ExitStatus::from_raw(0)) + } +} diff --git a/packages/zpm/src/commands/tasks/stop.rs b/packages/zpm/src/commands/tasks/stop.rs new file mode 100644 index 00000000..0df59f4f --- /dev/null +++ b/packages/zpm/src/commands/tasks/stop.rs @@ -0,0 +1,55 @@ +use std::{os::unix::process::ExitStatusExt, process::ExitStatus}; + +use clipanion::cli; +use zpm_utils::ToFileString; + +use crate::daemon::DaemonClient; +use crate::error::Error; +use crate::project::Project; + +/// Stop a running long-lived task +/// +/// This command stops a long-lived task that is currently running in the +/// background. The task will be terminated and its status will be set to +/// "stopped". +/// +/// Use `yarn tasks list` to see the names of running tasks that can be stopped. +#[cli::command] +#[cli::path("tasks", "stop")] +#[cli::category("Task management commands")] +pub struct TaskStop { + /// Name of the task to stop + name: String, +} + +impl TaskStop { + pub async fn execute(&self) -> Result { + let project + = Project::new(None).await?; + + let workspace + = project.active_workspace()?; + + let workspace_name + = workspace.name.to_file_string(); + + let mut client + = DaemonClient::connect(&project.project_cwd).await?; + + let (success, error) + = client.stop_task(&self.name, Some(workspace_name)).await?; + + client.close(); + + if success { + println!("Task {} stopped successfully", self.name); + Ok(ExitStatus::from_raw(0)) + } else { + let err_msg + = error.unwrap_or_else(|| "Unknown error".to_string()); + + eprintln!("Failed to stop task {}: {}", self.name, err_msg); + Ok(ExitStatus::from_raw(1 << 8)) + } + } +} diff --git a/packages/zpm/src/daemon/client.rs b/packages/zpm/src/daemon/client.rs new file mode 100644 index 00000000..d1f83844 --- /dev/null +++ b/packages/zpm/src/daemon/client.rs @@ -0,0 +1,478 @@ +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use futures::stream::StreamExt; +use futures::SinkExt; +use tokio::io::AsyncBufReadExt; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::task::AbortHandle; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; +use zpm_switch::YARN_SWITCH_PATH_ENV; + +use super::coordinator::start_daemon_inline; +use super::ipc::{ + AttachedLongLivedTask, BufferedOutputLine, DaemonMessage, DaemonNotification, DaemonRequest, + DaemonRequestEnvelope, DaemonResponse, LongLivedTaskInfo, SubscriptionScope, TaskSubscription, + DAEMON_SERVER_ENV, daemon_url, +}; +use zpm_utils::Path; + +use crate::error::Error; +use crate::project::Project; + +type PendingRequests = Arc>>>; + +/// Result of pushing tasks to the daemon +pub struct PushTasksResult { + /// The directly requested task IDs + pub task_ids: Vec, + /// Total number of dependency tasks (excluding target tasks) + pub dependency_count: usize, + /// Long-lived tasks that we attached to (already running) + pub attached_long_lived: Vec, +} + +/// Handle to a standalone daemon running in-process that can be aborted when no longer needed +pub struct StandaloneDaemonHandle { + abort_handle: AbortHandle, +} + +impl StandaloneDaemonHandle { + pub fn abort(&self) { + self.abort_handle.abort(); + } +} + +impl Drop for StandaloneDaemonHandle { + fn drop(&mut self) { + self.abort(); + } +} + +pub struct DaemonClient { + /// Channel to send outgoing messages to the writer task + outgoing_tx: mpsc::UnboundedSender, + /// Channel to receive notifications from the reader task + notification_rx: mpsc::UnboundedReceiver, + /// Map of pending request IDs to their response channels + pending_requests: PendingRequests, + /// Counter for generating unique request IDs + next_request_id: Arc, + /// Flag to indicate that close() was called (suppresses error messages) + closing: Arc, +} + +impl DaemonClient { + pub async fn connect(project_root: &Path) -> Result { + let url + = match std::env::var(DAEMON_SERVER_ENV) { + Ok(url) => url, + Err(_) => start_daemon(project_root).await?, + }; + + Self::connect_to_url(&url).await + } + + /// Start a new standalone daemon in-process that will be aborted when the handle is dropped + pub async fn connect_standalone(project: Arc) -> Result<(Self, StandaloneDaemonHandle), Error> { + let (port_tx, port_rx) + = oneshot::channel::(); + + let project_clone + = project.clone(); + + let join_handle + = tokio::spawn(async move { + if let Err(e) = start_daemon_inline(project_clone, port_tx).await { + eprintln!("Standalone daemon error: {}", e); + } + }); + + let abort_handle + = join_handle.abort_handle(); + + let port + = port_rx + .await + .map_err(|_| Error::IpcError("Daemon failed to start".to_string()))?; + + let url + = daemon_url(port); + + // Poll until daemon is ready + let max_attempts + = 100; + + let poll_interval + = Duration::from_millis(50); + + for _ in 0..max_attempts { + match tokio_tungstenite::connect_async(&url).await { + Ok((ws_stream, _)) => { + let client + = Self::connect_with_stream(ws_stream); + + return Ok((client, StandaloneDaemonHandle { abort_handle })); + } + Err(_) => tokio::time::sleep(poll_interval).await, + } + } + + Err(Error::IpcError("Timeout waiting for daemon to be ready".to_string())) + } + + pub async fn connect_to_url(url: &str) -> Result { + let (ws_stream, _) + = tokio_tungstenite::connect_async(url) + .await + .map_err(|e| Error::IpcConnectionFailed(e.to_string()))?; + + Ok(Self::connect_with_stream(ws_stream)) + } + + pub fn connect_with_stream(ws_stream: WebSocketStream>) -> Self { + let (write, read) + = ws_stream.split(); + + let (outgoing_tx, outgoing_rx) + = mpsc::unbounded_channel::(); + + let (notification_tx, notification_rx) + = mpsc::unbounded_channel::(); + + let pending_requests: PendingRequests + = Arc::new(Mutex::new(HashMap::new())); + + let next_request_id + = Arc::new(AtomicU64::new(1)); + + let closing + = Arc::new(AtomicBool::new(false)); + + let write + = Arc::new(Mutex::new(write)); + + let write_clone + = write.clone(); + + tokio::spawn(async move { + let mut outgoing_rx + = outgoing_rx; + + while let Some(msg) = outgoing_rx.recv().await { + let mut writer + = write_clone.lock().await; + + if writer.send(msg).await.is_err() { + break; + } + } + }); + + let pending_for_reader + = pending_requests.clone(); + + let write_for_reader + = write; + + let closing_for_reader + = closing.clone(); + + tokio::spawn(async move { + let mut read + = read; + + while let Some(msg_result) = read.next().await { + match msg_result { + Ok(Message::Text(text)) => { + match serde_json::from_str::(&text) { + Ok(DaemonMessage::Response { request_id, response }) => { + let mut pending + = pending_for_reader.lock().await; + + if let Some(sender) = pending.remove(&request_id) { + let _ = sender.send(response); + } + } + Ok(DaemonMessage::Notification { notification }) => { + let _ = notification_tx.send(notification); + } + Err(e) => { + eprintln!("Failed to parse daemon message: {} - raw: {}", e, text); + } + } + } + Ok(Message::Ping(data)) => { + let mut writer + = write_for_reader.lock().await; + + let _ = writer.send(Message::Pong(data)).await; + } + Ok(Message::Close(_)) => break, + Ok(_) => {} + Err(e) => { + if !closing_for_reader.load(Ordering::Relaxed) { + eprintln!("WebSocket read error: {}", e); + } + break; + } + } + } + }); + + Self { + outgoing_tx, + notification_rx, + pending_requests, + next_request_id, + closing, + } + } + + pub async fn send_request(&mut self, request: DaemonRequest) -> Result { + let request_id + = self.next_request_id.fetch_add(1, Ordering::Relaxed); + + let envelope + = DaemonRequestEnvelope { + request_id, + request, + }; + + let json + = serde_json::to_string(&envelope) + .map_err(|e| Error::IpcError(e.to_string()))?; + + let (response_tx, response_rx) + = oneshot::channel(); + + { + let mut pending + = self.pending_requests.lock().await; + + pending.insert(request_id, response_tx); + } + + self.outgoing_tx + .send(Message::Text(json.into())) + .map_err(|e| Error::IpcError(e.to_string()))?; + + const REQUEST_TIMEOUT_SECS: u64 = 30; + + let result = tokio::time::timeout( + Duration::from_secs(REQUEST_TIMEOUT_SECS), + response_rx, + ) + .await; + + match result { + Err(_) => { + // Timeout: clean up the stale pending request to prevent unbounded growth + self.pending_requests.lock().await.remove(&request_id); + Err(Error::IpcError("Request timed out".to_string())) + } + Ok(Err(_)) => Err(Error::IpcError("Connection closed while waiting for response".to_string())), + Ok(Ok(resp)) => Ok(resp), + } + } + + pub async fn recv_notification(&mut self) -> Result { + self.notification_rx + .recv() + .await + .ok_or_else(|| Error::IpcError("Connection closed".to_string())) + } + + pub fn close(&self) { + self.closing.store(true, Ordering::Relaxed); + let _ = self.outgoing_tx.send(Message::Close(None)); + } + + pub async fn push_tasks( + &mut self, + tasks: Vec, + parent_task_id: Option, + workspace: Option, + context_id: Option, + ) -> Result { + self.push_tasks_with_subscriptions( + tasks, + parent_task_id, + workspace, + SubscriptionScope::None, + SubscriptionScope::None, + context_id, + ) + .await + } + + pub async fn push_tasks_with_subscriptions( + &mut self, + tasks: Vec, + parent_task_id: Option, + workspace: Option, + output_subscription: SubscriptionScope, + status_subscription: SubscriptionScope, + context_id: Option, + ) -> Result { + let request + = DaemonRequest::PushTasks { + tasks, + parent_task_id, + workspace, + output_subscription, + status_subscription, + context_id, + }; + + match self.send_request(request).await? { + DaemonResponse::TasksEnqueued { task_ids, dependency_count, attached_long_lived } => Ok(PushTasksResult { + task_ids, + dependency_count, + attached_long_lived, + }), + DaemonResponse::Error { message } => Err(Error::TaskPushFailed(message)), + _ => Err(Error::IpcError("Unexpected response".to_string())), + } + } + + pub async fn get_task_output( + &mut self, + task_id: &str, + ) -> Result, Error> { + let request + = DaemonRequest::GetTaskOutput { + task_id: task_id.to_string(), + }; + + match self.send_request(request).await? { + DaemonResponse::TaskOutput { lines, .. } => Ok(lines), + DaemonResponse::Error { message } => Err(Error::IpcError(message)), + _ => Err(Error::IpcError("Unexpected response".to_string())), + } + } + + pub async fn stop_task( + &mut self, + task_name: &str, + workspace: Option, + ) -> Result<(bool, Option), Error> { + let request + = DaemonRequest::StopTask { + task_name: task_name.to_string(), + workspace, + }; + + match self.send_request(request).await? { + DaemonResponse::TaskStopped { success, error } => Ok((success, error)), + DaemonResponse::Error { message } => Err(Error::IpcError(message)), + _ => Err(Error::IpcError("Unexpected response".to_string())), + } + } + + pub async fn list_long_lived_tasks(&mut self) -> Result, Error> { + let request + = DaemonRequest::ListLongLivedTasks; + + match self.send_request(request).await? { + DaemonResponse::LongLivedTaskList { tasks } => Ok(tasks), + DaemonResponse::Error { message } => Err(Error::IpcError(message)), + _ => Err(Error::IpcError("Unexpected response".to_string())), + } + } + + pub async fn cancel_context(&mut self, context_id: &str) -> Result { + let request + = DaemonRequest::CancelContext { + context_id: context_id.to_string(), + }; + + match self.send_request(request).await? { + DaemonResponse::ContextCancelled { cancelled_count } => Ok(cancelled_count), + DaemonResponse::Error { message } => Err(Error::IpcError(message)), + _ => Err(Error::IpcError("Unexpected response".to_string())), + } + } + + /// Get internal state statistics from the daemon (for debugging/testing) + pub async fn get_stats(&mut self) -> Result { + let request = DaemonRequest::GetStats; + + match self.send_request(request).await? { + DaemonResponse::Stats { + tasks_count, + prepared_count, + subtasks_count, + output_buffer_count, + closed_tasks_count, + } => Ok(DaemonStatsResult { + tasks_count, + prepared_count, + subtasks_count, + output_buffer_count, + closed_tasks_count, + }), + DaemonResponse::Error { message } => Err(Error::IpcError(message)), + _ => Err(Error::IpcError("Unexpected response".to_string())), + } + } +} + +/// Result of getting daemon statistics +pub struct DaemonStatsResult { + pub tasks_count: usize, + pub prepared_count: usize, + pub subtasks_count: usize, + pub output_buffer_count: usize, + pub closed_tasks_count: usize, +} + +async fn start_daemon(project_root: &Path) -> Result { + let switch_path + = std::env::var(YARN_SWITCH_PATH_ENV).map_err(|_| { + Error::IpcError( + "This command can only be called within a Yarn Switch context. \ + Please run this command through `yarn` instead of calling the binary directly." + .to_string(), + ) + })?; + + let mut cmd + = tokio::process::Command::new(&switch_path); + + cmd.args(["switch", "daemon", "--open"]) + .current_dir(project_root.to_path_buf()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .stdin(Stdio::null()); + + let mut child + = cmd + .spawn() + .map_err(|e| Error::IpcError(format!("Failed to start daemon: {}", e)))?; + + let stdout + = child + .stdout + .take() + .ok_or_else(|| Error::IpcError("Failed to capture daemon stdout".to_string()))?; + + let mut reader + = tokio::io::BufReader::new(stdout).lines(); + + let url + = tokio::time::timeout(Duration::from_secs(10), reader.next_line()) + .await + .map_err(|_| Error::IpcError("Timeout waiting for daemon URL".to_string()))? + .map_err(|e| Error::IpcError(e.to_string()))? + .ok_or_else(|| Error::IpcError("Daemon closed without printing URL".to_string()))?; + + let _ = child.wait().await; + + Ok(url.trim().to_string()) +} + diff --git a/packages/zpm/src/daemon/coordinator.rs b/packages/zpm/src/daemon/coordinator.rs new file mode 100644 index 00000000..fe94409f --- /dev/null +++ b/packages/zpm/src/daemon/coordinator.rs @@ -0,0 +1,719 @@ +// ============================================================================ +// Race-Free Coordinator (v3) +// +// This coordinator owns ALL mutable state directly (no Arc). +// All state mutations happen in a single async task via command processing. +// Race conditions are structurally impossible. +// +// Lifecycle transitions are handled by CoordinatorState methods that return +// TransitionEffects. The coordinator loop is a thin dispatcher that applies +// effects (broadcasts notifications, kills PIDs). +// ============================================================================ + +use std::collections::HashSet; +use std::io::Write; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use tokio::sync::{mpsc, oneshot}; +use zpm_primitives::Ident; +use zpm_tasks::{TaskId, TaskName}; +use zpm_utils::{Path, ToFileString}; + +use super::coordinator_commands::{ + CancelContextResult, CommandSender, CoordinatorCommand, LongLivedTaskInfo, + PushTasksResult, StatsResult, StopTaskResult, TaskCompletionResult, +}; +use super::coordinator_state::{ + format_contextual_task_id, + CoordinatorState, TaskGraph, TransitionEffects, +}; +use super::executor::ExecutorPool; +use super::ipc::{daemon_url, AttachedLongLivedTask, BufferedOutputLine, DaemonNotification, TaskSubscription, LONG_LIVED_CONTEXT_ID}; +use super::platform; +use super::scheduler::dependencies; +use super::server::{bind_to_available_port, connection::{run_accept_loop, ConnectionContext}}; +use crate::error::Error; +use crate::project::Project; + +const LONG_LIVED_WARMUP_MS: u64 = 500; + +// ============================================================================ +// Main Entry Points +// ============================================================================ + +pub async fn start_daemon_inline(project: Arc, port_tx: oneshot::Sender) -> Result<(), Error> { + run_daemon_internal(project, Some(port_tx)).await +} + +pub async fn run_daemon(project: Arc) -> Result<(), Error> { + run_daemon_internal(project, None).await +} + +async fn run_daemon_internal( + project: Arc, + port_tx: Option>, +) -> Result<(), Error> { + let (listener, port) = bind_to_available_port().await?; + let daemon_url_str = daemon_url(port); + + // Send port through channel or print to stdout + if let Some(tx) = port_tx { + let _ = tx.send(port); + } else { + println!("{}", port); + let _ = std::io::stdout().flush(); + } + + // Get configuration + let output_buffer_max_lines = project.config.settings.daemon_output_buffer_max_lines.value; + let max_closed_tasks = project.config.settings.daemon_max_closed_tasks.value; + + // Create the command channel + let (command_tx, command_rx) = mpsc::unbounded_channel::(); + + // Spawn the coordinator loop + let project_for_loop = project.clone(); + let command_tx_for_executor = command_tx.clone(); + + tokio::spawn(async move { + run_coordinator_loop( + project_for_loop, + command_rx, + command_tx_for_executor, + daemon_url_str, + output_buffer_max_lines, + max_closed_tasks, + ).await; + }); + + // Project root watcher + let project_root = project.project_cwd.clone(); + let command_tx_for_watcher = command_tx.clone(); + + tokio::spawn(async move { + watch_project_root(project_root, command_tx_for_watcher).await; + }); + + // Signal handler + let command_tx_for_signal = command_tx.clone(); + tokio::spawn(async move { + wait_for_shutdown_signal(command_tx_for_signal).await; + }); + + // Create simplified connection context (no Arc for mutable state) + let ctx = Arc::new(ConnectionContext { + project, + command_tx, + }); + + // Run accept loop with simplified context + run_accept_loop(listener, ctx).await; + + Ok(()) +} + +// ============================================================================ +// Coordinator Loop +// ============================================================================ + +async fn run_coordinator_loop( + project: Arc, + mut command_rx: mpsc::UnboundedReceiver, + command_tx: CommandSender, + daemon_url: String, + output_buffer_max_lines: usize, + max_closed_tasks: usize, +) { + // Create unified state - owned by this task, no locks + let mut state = CoordinatorState::new(output_buffer_max_lines, max_closed_tasks); + + // Create executor pool + let mut executor_pool = ExecutorPool::new(daemon_url, command_tx.clone()); + + while let Some(cmd) = command_rx.recv().await { + let should_shutdown = handle_command( + cmd, + &mut state, + &mut executor_pool, + &project, + &command_tx, + ).await; + + if should_shutdown { + break; + } + + process_ready_tasks(&mut state, &mut executor_pool); + } +} + +// ============================================================================ +// Apply Effects (thin I/O layer) +// ============================================================================ + +/// Broadcast notifications and kill PIDs from transition effects. +fn apply_effects(effects: TransitionEffects, subscriptions: &super::coordinator_state::SubscriptionManager) { + for notification in effects.notifications { + subscriptions.broadcast(notification); + } + for pid in effects.pids_to_kill { + platform::kill_process_group(pid); + } +} + +// ============================================================================ +// Command Handler +// ============================================================================ + +/// Handle a single command. Returns true if coordinator should shut down. +async fn handle_command( + cmd: CoordinatorCommand, + state: &mut CoordinatorState, + executor_pool: &mut ExecutorPool, + project: &Project, + command_tx: &CommandSender, +) -> bool { + match cmd { + // ==================================================================== + // Task Management + // ==================================================================== + + CoordinatorCommand::PushTasks { + tasks, + parent_task_id, + workspace, + context_id, + subscription_id, + response_tx, + } => { + let result = execute_push_tasks( + &tasks, + parent_task_id.as_deref(), + workspace.as_deref(), + context_id.as_deref(), + state, + project, + ); + + // Add tasks to subscription BEFORE sending the response to avoid race + // where TaskStarted is processed before the subscription filter is set + if let Some(sub_id) = subscription_id { + state.subscriptions.add_tasks( + sub_id, + result.task_ids.clone(), + result.dependency_ids.clone(), + ); + } + + let _ = response_tx.send(result); + } + + CoordinatorCommand::CancelContext { context_id, response_tx } => { + let effects = state.cancel_context(&context_id); + let cancelled_count = effects.notifications.len(); + apply_effects(effects, &state.subscriptions); + + // Mark spawning tasks for deferred kill (already done inside cancel_context) + let spawning_ids = state.processes.get_spawning_for_context(&context_id); + + let _ = response_tx.send(CancelContextResult { + cancelled_count: cancelled_count + spawning_ids.len(), + }); + } + + CoordinatorCommand::StopTask { task_name, workspace, response_tx } => { + let result = handle_stop_task(&task_name, workspace.as_deref(), state, project); + let _ = response_tx.send(result); + } + + // ==================================================================== + // Process Management + // ==================================================================== + + CoordinatorCommand::RegisterPid { task_id, pid } => { + // Check if cancelled while spawning + if let Some(pending_cancel) = state.processes.take_spawning(&task_id) { + if pending_cancel { + platform::kill_process_group(pid); + return false; + } + } + state.processes.register_pid(pid, task_id); + } + + CoordinatorCommand::UnregisterPid { task_id, pid } => { + state.processes.take_spawning(&task_id); + state.processes.unregister_pid(pid, &task_id); + } + + // ==================================================================== + // Executor Events + // ==================================================================== + + CoordinatorCommand::TaskStarted { task_id } => { + // Broadcast notification + let task_id_str = format_contextual_task_id(&task_id); + state.subscriptions.broadcast(DaemonNotification::TaskStarted { + task_id: task_id_str, + }); + + // Spawn delayed warm-up command for long-lived tasks + if state.graph.is_long_lived(&task_id) { + let base_task_id = task_id.task_id.clone(); + let tx = command_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(LONG_LIVED_WARMUP_MS)).await; + let _ = tx.send(CoordinatorCommand::WarmUpComplete { + task_id, + base_task_id, + }); + }); + } + } + + CoordinatorCommand::TaskOutput { task_id, line, stream } => { + let task_id_str = format_contextual_task_id(&task_id); + + // Buffer output + state.output.append(task_id_str.clone(), BufferedOutputLine { + line: line.clone(), + stream: stream.as_str().to_string(), + }); + + // Broadcast notification + state.subscriptions.broadcast(DaemonNotification::TaskOutputLine { + task_id: task_id_str, + line, + stream: stream.as_str().to_string(), + }); + } + + CoordinatorCommand::TaskCompleted { task_id, result } => { + // Remove from executor's running set BEFORE updating state + executor_pool.mark_completed(&task_id); + + let exit_code = match result { + TaskCompletionResult::Exited(status) => status.code().unwrap_or(-1), + TaskCompletionResult::Error(e) => { + eprintln!("Task execution error: {}", e); + 1 + } + }; + + let effects = state.task_script_finished(&task_id, exit_code); + apply_effects(effects, &state.subscriptions); + } + + CoordinatorCommand::WarmUpComplete { task_id, base_task_id } => { + let effects = state.warm_up_complete(&task_id, &base_task_id); + apply_effects(effects, &state.subscriptions); + } + + // ==================================================================== + // Query Commands + // ==================================================================== + + CoordinatorCommand::GetTaskOutput { task_id, response_tx } => { + let lines = state.output.get(&task_id); + let _ = response_tx.send(lines); + } + + CoordinatorCommand::ListLongLivedTasks { response_tx } => { + let entries = state.long_lived.list(); + let infos: Vec = entries + .into_iter() + .map(|e| { + let process_id = state.processes.get_pid_for_task(&e.contextual_task_id); + LongLivedTaskInfo { + task_id: format!("{}:{}", e.task_id.workspace.to_file_string(), e.task_id.task_name.as_str()), + contextual_task_id: format_contextual_task_id(&e.contextual_task_id), + warm_up_complete: e.warm_up_complete, + started_at_ms: e.started_at + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + process_id, + } + }) + .collect(); + let _ = response_tx.send(infos); + } + + CoordinatorCommand::GetStats { response_tx } => { + let _ = response_tx.send(StatsResult { + tasks_count: state.graph.tasks_count(), + prepared_count: state.graph.prepared_count(), + subtasks_count: state.graph.subtasks_count(), + output_buffer_count: state.output.buffer_count(), + closed_tasks_count: state.output.closed_tasks_count(), + }); + } + + // ==================================================================== + // Subscription Commands + // ==================================================================== + + CoordinatorCommand::CreateSubscription { + output_scope, + status_scope, + context_id, + response_tx, + } => { + let (id, rx) = state.subscriptions.create(output_scope, status_scope, context_id); + let _ = response_tx.send((id, rx)); + } + + CoordinatorCommand::AddTasksToSubscription { + subscription_id, + target_task_ids, + dependency_task_ids, + } => { + state.subscriptions.add_tasks(subscription_id, target_task_ids, dependency_task_ids); + } + + CoordinatorCommand::RemoveSubscription { subscription_id } => { + state.subscriptions.remove(subscription_id); + } + + // ==================================================================== + // Shutdown + // ==================================================================== + + CoordinatorCommand::Shutdown { response_tx } => { + let pids = state.processes.get_all_pids(); + let _ = response_tx.send(pids); + return true; // Signal shutdown + } + } + + false +} + +// ============================================================================ +// Ready Task Processing +// ============================================================================ + +fn process_ready_tasks(state: &mut CoordinatorState, executor_pool: &mut ExecutorPool) { + let running: HashSet<_> = executor_pool.running_tasks().cloned().collect(); + + // Find tasks to cancel (dependencies failed) + let tasks_to_cancel = dependencies::find_tasks_to_fail(&state.graph, &running); + for task_id in tasks_to_cancel { + let effects = state.cancel_task(&task_id); + apply_effects(effects, &state.subscriptions); + } + + // Find ready tasks + let ready_ids = dependencies::find_ready_tasks(&state.graph, &running); + let ready_tasks: Vec<_> = ready_ids + .into_iter() + .map(|ctx_task_id| { + let prepared = state.graph.prepared.get(&ctx_task_id).cloned(); + (ctx_task_id, prepared) + }) + .collect(); + + for (task_id, prepared_opt) in ready_tasks { + // Atomic check - no race possible, we own the state + if !state.graph.should_spawn_task(&task_id) { + continue; + } + + if let Some(prepared) = prepared_opt { + // Mark as spawning BEFORE spawn + state.processes.mark_spawning(task_id.clone()); + + // Spawn task + executor_pool.spawn(task_id, prepared); + } else { + // No script - complete immediately + let effects = state.complete_no_script(&task_id); + apply_effects(effects, &state.subscriptions); + } + } +} + +// ============================================================================ +// Push Tasks Handler +// ============================================================================ + +fn execute_push_tasks( + tasks: &[TaskSubscription], + parent_task_id: Option<&str>, + workspace: Option<&str>, + context_id: Option<&str>, + state: &mut CoordinatorState, + project: &Project, +) -> PushTasksResult { + let mut task_ids = Vec::new(); + let mut dependency_ids = Vec::new(); + let mut attached_long_lived = Vec::new(); + + for task_sub in tasks { + let task_id = build_task_id(&task_sub.name, workspace, project); + + // Check if this is a long-lived task. Use filesystem fallback on first + // push when the graph cache hasn't been populated yet. + let is_long_lived = task_id + .as_ref() + .map(|tid| resolve_is_long_lived(&state.graph, project, tid)) + .unwrap_or(false); + + // For long-lived tasks, check if already running + if is_long_lived { + if let Some(ref tid) = task_id { + if let Some(existing) = state.long_lived.get(tid) { + let existing_id_str = format_contextual_task_id(&existing.contextual_task_id); + task_ids.push(existing_id_str.clone()); + + let started_at_ms = existing + .started_at + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + attached_long_lived.push(AttachedLongLivedTask { + task_id: existing_id_str, + started_at_ms, + }); + + continue; + } + } + } + + let effective_context_id = if is_long_lived { + Some(LONG_LIVED_CONTEXT_ID) + } else { + context_id + }; + + match state.graph.add_task( + project, + &task_sub.name, + parent_task_id, + task_sub.args.clone(), + workspace, + effective_context_id, + ) { + Ok((ctx_task_id, resolved_ctx_task_ids)) => { + let target_id_str = format_contextual_task_id(&ctx_task_id); + + // After add_task, check is_long_lived from the prepared task + // (which was populated by prepare_specific_tasks without extra I/O) + let is_long_lived = state.graph.is_long_lived(&ctx_task_id) || is_long_lived; + + if is_long_lived { + // Register in long-lived registry + if let Some(ref tid) = task_id { + state.long_lived.register(tid.clone(), ctx_task_id.clone()); + } + } + + task_ids.push(target_id_str.clone()); + + for resolved_id in &resolved_ctx_task_ids { + let resolved_str = format_contextual_task_id(resolved_id); + if resolved_str != target_id_str { + dependency_ids.push(resolved_str); + } + } + } + Err(e) => { + return PushTasksResult { + task_ids: vec![], + dependency_ids: vec![], + attached_long_lived: vec![], + error: Some(e.to_string()), + }; + } + } + } + + PushTasksResult { + task_ids, + dependency_ids, + attached_long_lived, + error: None, + } +} + +// ============================================================================ +// Stop Task Handler +// ============================================================================ + +fn handle_stop_task( + task_name: &str, + workspace: Option<&str>, + state: &mut CoordinatorState, + project: &Project, +) -> StopTaskResult { + let task_id = match build_task_id(task_name, workspace, project) { + Some(tid) => tid, + None => { + return StopTaskResult { + success: false, + error: Some(format!("Could not resolve task: {}", task_name)), + }; + } + }; + + let contextual_task_id = match state.long_lived.get(&task_id) { + Some(e) => e.contextual_task_id.clone(), + None => { + return StopTaskResult { + success: false, + error: Some(format!("No running long-lived task found: {}", task_name)), + }; + } + }; + + let effects = state.stop_long_lived(&task_id, &contextual_task_id); + apply_effects(effects, &state.subscriptions); + + StopTaskResult { + success: true, + error: None, + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +fn build_task_id(task_name: &str, workspace: Option<&str>, project: &Project) -> Option { + let task_name = TaskName::new(task_name).ok()?; + + let workspace = if let Some(ws_name) = workspace { + let ident = Ident::new(ws_name); + project.workspace_by_ident(&ident).ok()?.name.clone() + } else { + project.active_workspace().ok()?.name.clone() + }; + + Some(TaskId { workspace, task_name }) +} + +/// Check if a task is long-lived, with filesystem fallback for first push. +fn resolve_is_long_lived(graph: &TaskGraph, project: &Project, task_id: &TaskId) -> bool { + // Fast path: check graph cache (populated after first add_task) + if check_if_long_lived_from_graph(graph, task_id) { + return true; + } + + // Slow path: resolve from disk (only needed on first push per task) + project.resolve_task(task_id) + .ok() + .and_then(|resolved| { + resolved.task_files.get(&task_id.workspace) + .and_then(|tf| tf.tasks.get(task_id.task_name.as_str())) + .map(|task| task.attributes.iter().any(|a| a.name == "long-lived")) + }) + .unwrap_or(false) +} + +/// Check if a task is long-lived by examining already-resolved task files in the graph. +fn check_if_long_lived_from_graph(graph: &TaskGraph, task_id: &TaskId) -> bool { + if let Some(task_file) = graph.resolved.task_files.get(&task_id.workspace) { + if let Some(task) = task_file.tasks.get(task_id.task_name.as_str()) { + return task.attributes.iter().any(|attr| attr.name == "long-lived"); + } + } + false +} + +// ============================================================================ +// Watchers and Signal Handlers +// ============================================================================ + +async fn watch_project_root(project_root: Path, command_tx: CommandSender) { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let initial_inode = match project_root.fs_metadata().map(|m| m.ino()) { + Ok(ino) => ino, + Err(_) => return, + }; + + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + + let current_inode = project_root.fs_metadata().map(|m| m.ino()).ok(); + + if current_inode != Some(initial_inode) { + graceful_shutdown(command_tx).await; + return; + } + } + } + + #[cfg(not(unix))] + { + // On non-Unix platforms, just keep the watcher alive without inode checking + let _ = command_tx; + let _ = project_root; + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + } + } +} + +#[cfg(unix)] +async fn wait_for_shutdown_signal(command_tx: CommandSender) { + use tokio::signal::unix::{signal, SignalKind}; + + let sigterm = signal(SignalKind::terminate()).ok(); + let sigint = signal(SignalKind::interrupt()).ok(); + + match (sigterm, sigint) { + (Some(mut term), Some(mut int)) => { + tokio::select! { + _ = term.recv() => {} + _ = int.recv() => {} + } + } + _ => { + tokio::signal::ctrl_c().await.ok(); + } + } + + graceful_shutdown(command_tx).await; +} + +#[cfg(not(unix))] +async fn wait_for_shutdown_signal(command_tx: CommandSender) { + let _ = tokio::signal::ctrl_c().await; + graceful_shutdown(command_tx).await; +} + +async fn graceful_shutdown(command_tx: CommandSender) { + let (response_tx, response_rx) = oneshot::channel(); + + if command_tx.send(CoordinatorCommand::Shutdown { response_tx }).is_err() { + std::process::exit(0); + } + + let pids = response_rx.await.unwrap_or_default(); + + if pids.is_empty() { + std::process::exit(0); + } + + // Send SIGTERM + for &pid in &pids { + platform::kill_process_group(pid); + } + + // Wait 5 seconds + tokio::time::sleep(Duration::from_secs(5)).await; + + // Force kill remaining + for &pid in &pids { + if platform::is_process_alive(pid) { + platform::kill_process(pid); + } + } + + std::process::exit(0); +} diff --git a/packages/zpm/src/daemon/coordinator_commands.rs b/packages/zpm/src/daemon/coordinator_commands.rs new file mode 100644 index 00000000..a7941b68 --- /dev/null +++ b/packages/zpm/src/daemon/coordinator_commands.rs @@ -0,0 +1,214 @@ +// ============================================================================ +// Coordinator Commands +// +// All operations that modify state go through these commands. +// This ensures serialized access - no races possible. +// ============================================================================ + +use tokio::sync::{mpsc, oneshot}; + +use super::coordinator_state::SubscriptionId; +use super::events::Stream; +use super::ipc::{AttachedLongLivedTask, BufferedOutputLine, DaemonNotification, SubscriptionScope, TaskSubscription}; +use super::scheduler::ContextualTaskId; + +// ============================================================================ +// Command Types +// ============================================================================ + +/// Commands sent to the coordinator for serialized execution. +/// ALL state mutations go through here - no direct access to state. +#[derive(Debug)] +pub enum CoordinatorCommand { + // ======================================================================== + // Task Management Commands (from handlers) + // ======================================================================== + + /// Add new tasks to the scheduler. + PushTasks { + tasks: Vec, + parent_task_id: Option, + workspace: Option, + context_id: Option, + subscription_id: Option, + response_tx: oneshot::Sender, + }, + + /// Cancel all tasks in a context and kill running processes. + CancelContext { + context_id: String, + response_tx: oneshot::Sender, + }, + + /// Stop a specific long-lived task by name. + StopTask { + task_name: String, + workspace: Option, + response_tx: oneshot::Sender, + }, + + // ======================================================================== + // Process Management Commands (from executor) + // ======================================================================== + + /// Register a PID for a task that has just spawned. + RegisterPid { + task_id: ContextualTaskId, + pid: u32, + }, + + /// Unregister a PID when a task exits. + UnregisterPid { + task_id: ContextualTaskId, + pid: u32, + }, + + // ======================================================================== + // Executor Event Commands (from executor - replaces spawned event task) + // ======================================================================== + + /// Task has started executing. + TaskStarted { + task_id: ContextualTaskId, + }, + + /// Task produced output. + TaskOutput { + task_id: ContextualTaskId, + line: String, + stream: Stream, + }, + + /// Task completed (success or failure). + /// Sent AFTER all output has been streamed, ensuring proper ordering. + TaskCompleted { + task_id: ContextualTaskId, + result: TaskCompletionResult, + }, + + /// Long-lived task warm-up period elapsed. + /// Sent by a spawned timer after LONG_LIVED_WARMUP_MS. + WarmUpComplete { + task_id: ContextualTaskId, + base_task_id: zpm_tasks::TaskId, + }, + + // ======================================================================== + // Query Commands (from handlers) + // ======================================================================== + + /// Get buffered output for a task. + GetTaskOutput { + task_id: String, + response_tx: oneshot::Sender>, + }, + + /// List all long-lived tasks. + ListLongLivedTasks { + response_tx: oneshot::Sender>, + }, + + /// Get internal state statistics. + GetStats { + response_tx: oneshot::Sender, + }, + + // ======================================================================== + // Subscription Commands (from connection handlers) + // ======================================================================== + + /// Create a new subscription. + CreateSubscription { + output_scope: SubscriptionScope, + status_scope: SubscriptionScope, + context_id: Option, + response_tx: oneshot::Sender<(SubscriptionId, mpsc::UnboundedReceiver)>, + }, + + /// Add tasks to an existing subscription. + AddTasksToSubscription { + subscription_id: SubscriptionId, + target_task_ids: Vec, + dependency_task_ids: Vec, + }, + + /// Remove a subscription. + RemoveSubscription { + subscription_id: SubscriptionId, + }, + + // ======================================================================== + // Shutdown Command + // ======================================================================== + + /// Request graceful shutdown, returns all PIDs. + Shutdown { + response_tx: oneshot::Sender>, + }, +} + +// ============================================================================ +// Response Types +// ============================================================================ + +/// Result of a task completion from the executor. +#[derive(Debug)] +pub enum TaskCompletionResult { + /// Task exited with a status code + Exited(std::process::ExitStatus), + /// Task failed to execute + Error(String), +} + +/// Result of pushing tasks to the scheduler. +#[derive(Debug)] +pub struct PushTasksResult { + /// The directly requested task IDs + pub task_ids: Vec, + /// Dependency task IDs (excluding target tasks) + pub dependency_ids: Vec, + /// Long-lived tasks that we attached to (already running) + pub attached_long_lived: Vec, + /// Error message if the operation failed + pub error: Option, +} + +/// Result of cancelling a context. +#[derive(Debug)] +pub struct CancelContextResult { + /// Number of tasks cancelled + pub cancelled_count: usize, +} + +/// Result of stopping a task. +#[derive(Debug)] +pub struct StopTaskResult { + pub success: bool, + pub error: Option, +} + +/// Information about a long-lived task. +#[derive(Debug, Clone)] +pub struct LongLivedTaskInfo { + pub task_id: String, + pub contextual_task_id: String, + pub warm_up_complete: bool, + pub started_at_ms: u64, + pub process_id: Option, +} + +/// Internal state statistics for debugging/testing. +#[derive(Debug, Clone)] +pub struct StatsResult { + pub tasks_count: usize, + pub prepared_count: usize, + pub subtasks_count: usize, + pub output_buffer_count: usize, + pub closed_tasks_count: usize, +} + +// ============================================================================ +// Command Sender Type +// ============================================================================ + +pub type CommandSender = mpsc::UnboundedSender; diff --git a/packages/zpm/src/daemon/coordinator_state/long_lived_registry.rs b/packages/zpm/src/daemon/coordinator_state/long_lived_registry.rs new file mode 100644 index 00000000..8b77e870 --- /dev/null +++ b/packages/zpm/src/daemon/coordinator_state/long_lived_registry.rs @@ -0,0 +1,69 @@ +use std::collections::HashMap; +use std::time::SystemTime; + +use zpm_tasks::TaskId; + +use super::super::scheduler::ContextualTaskId; + +// ============================================================================ +// Long-Lived Task State +// ============================================================================ + +#[derive(Debug, Clone)] +pub struct LongLivedEntry { + pub task_id: TaskId, + pub contextual_task_id: ContextualTaskId, + pub warm_up_complete: bool, + pub started_at: SystemTime, +} + +// ============================================================================ +// Long-Lived Registry +// ============================================================================ + +/// Owns long-lived task entries. +/// Only modified by the coordinator event loop — no locks needed. +pub struct LongLivedRegistry { + entries: HashMap, +} + +impl LongLivedRegistry { + pub fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + pub fn get(&self, task_id: &TaskId) -> Option<&LongLivedEntry> { + self.entries.get(task_id) + } + + pub fn register(&mut self, task_id: TaskId, contextual_task_id: ContextualTaskId) { + self.entries.insert( + task_id.clone(), + LongLivedEntry { + task_id, + contextual_task_id, + warm_up_complete: false, + started_at: SystemTime::now(), + }, + ); + } + + pub fn remove(&mut self, task_id: &TaskId) -> Option { + self.entries.remove(task_id) + } + + pub fn mark_warm_up_complete(&mut self, task_id: &TaskId) -> bool { + if let Some(entry) = self.entries.get_mut(task_id) { + entry.warm_up_complete = true; + true + } else { + false + } + } + + pub fn list(&self) -> Vec { + self.entries.values().cloned().collect() + } +} diff --git a/packages/zpm/src/daemon/coordinator_state/mod.rs b/packages/zpm/src/daemon/coordinator_state/mod.rs new file mode 100644 index 00000000..57c9c019 --- /dev/null +++ b/packages/zpm/src/daemon/coordinator_state/mod.rs @@ -0,0 +1,367 @@ +// ============================================================================ +// Coordinator State (Composed) +// +// This module consolidates all mutable daemon state into sub-structs +// that are owned exclusively by the coordinator loop. No Arc wrappers — +// the coordinator is the single owner, making race conditions structurally +// impossible. +// +// Every terminal transition goes through `close_task`, which updates all +// relevant registries atomically. The coordinator loop calls transition +// methods that return `TransitionEffects` describing what I/O to perform, +// keeping this module free of channels, Tokio types, and process management. +// ============================================================================ + +mod long_lived_registry; +mod output_buffer; +mod process_registry; +mod subscription_manager; +mod task_graph; + +pub use long_lived_registry::LongLivedRegistry; +pub use output_buffer::OutputBuffer; +pub use process_registry::ProcessRegistry; +pub use subscription_manager::{SubscriptionId, SubscriptionManager}; +pub use task_graph::{format_contextual_task_id, TaskGraph}; + +// Re-export scheduler types that are used across the coordinator +pub use super::scheduler::{ContextualTaskId, PreparedTask}; + +use super::ipc::DaemonNotification; + +// ============================================================================ +// Transition Effects +// ============================================================================ + +/// Describes the I/O the coordinator loop should perform after a transition. +/// Keeps CoordinatorState free of channels, Tokio types, and process management. +#[derive(Default)] +pub struct TransitionEffects { + pub notifications: Vec, + pub pids_to_kill: Vec, +} + +// ============================================================================ +// Close Task Effect (internal) +// ============================================================================ + +struct CloseTaskEffect { + task_id_str: String, + pid: Option, +} + +// ============================================================================ +// Coordinator State +// ============================================================================ + +/// All mutable daemon state, composed from focused sub-structs. +/// Only modified by the coordinator event loop — no locks needed. +pub struct CoordinatorState { + pub graph: TaskGraph, + pub processes: ProcessRegistry, + pub long_lived: LongLivedRegistry, + pub subscriptions: SubscriptionManager, + pub output: OutputBuffer, +} + +impl CoordinatorState { + pub fn new(output_buffer_max_lines: usize, max_closed_tasks: usize) -> Self { + Self { + graph: TaskGraph::new(), + processes: ProcessRegistry::new(), + long_lived: LongLivedRegistry::new(), + subscriptions: SubscriptionManager::new(), + output: OutputBuffer::new(output_buffer_max_lines, max_closed_tasks), + } + } + + // ======================================================================== + // Core: close_task (single cleanup codepath for all terminal transitions) + // ======================================================================== + + /// Clean up all registries for a task that has reached a terminal state. + /// Called by every transition that ends a task (complete, fail, cancel). + fn close_task(&mut self, task_id: &ContextualTaskId) -> CloseTaskEffect { + let task_id_str = format_contextual_task_id(task_id); + + // 1. Output buffer: mark closed, may trigger eviction of old closed tasks + let evicted = self.output.mark_closed(task_id_str.clone()); + for id in &evicted { + self.graph.evict_closed_task(id); + } + + // 2. Long-lived registry (no-op if not long-lived) + self.long_lived.remove(&task_id.task_id); + + // 3. Process registry: take PID if still registered + let pid = self.processes.take_pid_for_task(task_id); + + CloseTaskEffect { task_id_str, pid } + } + + // ======================================================================== + // Transition: task_script_finished + // ======================================================================== + + /// Called when a task's script exits. Routes to complete_task or fail_task + /// based on exit code and subtask state. + /// + /// Guards against already-terminal tasks: if the task was stopped and + /// then re-added before the old process's TaskCompleted arrived, the + /// task_id now refers to the new run. Ignore the stale completion. + pub fn task_script_finished( + &mut self, + task_id: &ContextualTaskId, + exit_code: i32, + ) -> TransitionEffects { + // Guard: ignore completions for tasks that are already terminal + // (e.g. a stopped task whose process finally exited after re-add) + if self.graph.is_terminal(task_id) { + return TransitionEffects::default(); + } + + self.graph.mark_script_finished_with_code(task_id, exit_code); + + if exit_code != 0 { + self.fail_task(task_id, exit_code) + } else { + self.try_complete_or_wait(task_id, exit_code) + } + } + + /// Called when a task's script exits with success. Checks subtask state + /// to determine whether to complete or wait. + fn try_complete_or_wait( + &mut self, + task_id: &ContextualTaskId, + exit_code: i32, + ) -> TransitionEffects { + if self.graph.try_complete_task(task_id) { + self.on_task_completed(task_id, exit_code) + } else if self.graph.has_failed_subtask(task_id) { + // Subtask already failed - fail the parent + self.fail_task(task_id, 1) + } else { + // Task stays in WaitingForSubtasks until all subtasks complete + TransitionEffects::default() + } + } + + /// Common completion path: broadcast + close + try to complete parents. + fn on_task_completed( + &mut self, + task_id: &ContextualTaskId, + exit_code: i32, + ) -> TransitionEffects { + let mut effects = TransitionEffects::default(); + + let close = self.close_task(task_id); + effects.notifications.push(DaemonNotification::TaskCompleted { + task_id: close.task_id_str, + exit_code, + }); + if let Some(pid) = close.pid { + effects.pids_to_kill.push(pid); + } + + // Try to complete parents that are waiting for subtasks + let parents = self.graph.find_parents(task_id); + for parent in parents { + if let Some(parent_exit_code) = self.graph.get_waiting_exit_code(&parent) { + if self.graph.try_complete_task(&parent) { + let parent_effects = self.on_task_completed(&parent, parent_exit_code); + effects.notifications.extend(parent_effects.notifications); + effects.pids_to_kill.extend(parent_effects.pids_to_kill); + } + } + } + + effects + } + + // ======================================================================== + // Transition: fail_task + // ======================================================================== + + /// Mark a task as failed, close it, and propagate failure to waiting parents. + pub fn fail_task( + &mut self, + task_id: &ContextualTaskId, + exit_code: i32, + ) -> TransitionEffects { + let mut effects = TransitionEffects::default(); + + self.graph.mark_failed(task_id); + + let close = self.close_task(task_id); + effects.notifications.push(DaemonNotification::TaskCompleted { + task_id: close.task_id_str, + exit_code, + }); + if let Some(pid) = close.pid { + effects.pids_to_kill.push(pid); + } + + // Propagate failure to parents that are waiting for subtasks + let parents = self.graph.find_parents(task_id); + for parent in parents { + if self.graph.get_waiting_exit_code(&parent).is_some() { + let parent_effects = self.fail_task(&parent, exit_code); + effects.notifications.extend(parent_effects.notifications); + effects.pids_to_kill.extend(parent_effects.pids_to_kill); + } + } + + effects + } + + // ======================================================================== + // Transition: cancel_task + // ======================================================================== + + /// Mark a single task as cancelled, close it, and return effects. + pub fn cancel_task( + &mut self, + task_id: &ContextualTaskId, + ) -> TransitionEffects { + let mut effects = TransitionEffects::default(); + + self.graph.mark_cancelled(task_id); + + let close = self.close_task(task_id); + effects.notifications.push(DaemonNotification::TaskCancelled { + task_id: close.task_id_str, + }); + if let Some(pid) = close.pid { + effects.pids_to_kill.push(pid); + } + + effects + } + + // ======================================================================== + // Transition: cancel_context + // ======================================================================== + + /// Cancel all non-terminal tasks in a context. Collects PIDs to kill + /// and marks spawning tasks for deferred kill. + pub fn cancel_context(&mut self, context_id: &str) -> TransitionEffects { + let mut effects = TransitionEffects::default(); + + // 1. Cancel all non-terminal tasks in graph + let tasks_to_cancel: Vec = self.graph + .prepared + .keys() + .filter(|ctx_task_id| { + ctx_task_id.context_id == context_id && !self.graph.is_terminal(ctx_task_id) + }) + .cloned() + .collect(); + + for task_id in &tasks_to_cancel { + let task_effects = self.cancel_task(task_id); + effects.notifications.extend(task_effects.notifications); + effects.pids_to_kill.extend(task_effects.pids_to_kill); + } + + // 2. Get and collect registered PIDs for running tasks in this context + let pids = self.processes.take_pids_for_context(context_id); + effects.pids_to_kill.extend(pids); + + // 3. Mark spawning tasks for deferred kill + let spawning_ids = self.processes.get_spawning_for_context(context_id); + for task_id in &spawning_ids { + self.processes.mark_spawning_pending_cancel(task_id); + } + + effects + } + + // ======================================================================== + // Transition: warm_up_complete + // ======================================================================== + + /// Handle warm-up completion for a long-lived task. + /// Returns empty effects if the task is already terminal (guards against + /// the timer firing after task failure). + pub fn warm_up_complete( + &mut self, + task_id: &ContextualTaskId, + base_task_id: &zpm_tasks::TaskId, + ) -> TransitionEffects { + let mut effects = TransitionEffects::default(); + + // Guard: if task already died, ignore the warm-up timer + if self.graph.is_terminal(task_id) { + return effects; + } + + self.graph.mark_warm_up_complete(task_id); + self.long_lived.mark_warm_up_complete(base_task_id); + + let task_id_str = format_contextual_task_id(task_id); + effects.notifications.push(DaemonNotification::TaskWarmUpComplete { + task_id: task_id_str, + }); + + effects + } + + // ======================================================================== + // Transition: stop_long_lived + // ======================================================================== + + /// Stop a long-lived task by task ID. Removes the long-lived registry + /// entry and returns the PID to kill. + /// + /// Does NOT call close_task — the process is still alive after we send + /// SIGTERM. When it actually exits, TaskCompleted will fire and the + /// normal task_script_finished → fail_task → close_task path handles + /// the full cleanup. This avoids double-eviction of output buffers + /// and lets add_task re-register the task cleanly on restart. + pub fn stop_long_lived( + &mut self, + task_id: &zpm_tasks::TaskId, + contextual_task_id: &ContextualTaskId, + ) -> TransitionEffects { + let mut effects = TransitionEffects::default(); + + // Remove from long-lived registry first so that a subsequent + // PushTasks for the same task doesn't attach to the dying instance. + self.long_lived.remove(task_id); + + // Check if spawning (between spawn and PID registration) + if self.processes.mark_spawning_pending_cancel(contextual_task_id) { + return effects; + } + + // Take the PID so we can kill it, but leave graph/output state intact + // for the TaskCompleted path to clean up properly. + if let Some(pid) = self.processes.take_pid_for_task(contextual_task_id) { + effects.pids_to_kill.push(pid); + } + + effects + } + + // ======================================================================== + // Transition: complete_no_script (task with no script completes immediately) + // ======================================================================== + + /// Complete a task that has no script (dependency aggregator). + pub fn complete_no_script( + &mut self, + task_id: &ContextualTaskId, + ) -> TransitionEffects { + self.graph.mark_completed(task_id); + let mut effects = TransitionEffects::default(); + + let close = self.close_task(task_id); + effects.notifications.push(DaemonNotification::TaskCompleted { + task_id: close.task_id_str, + exit_code: 0, + }); + + effects + } +} diff --git a/packages/zpm/src/daemon/coordinator_state/output_buffer.rs b/packages/zpm/src/daemon/coordinator_state/output_buffer.rs new file mode 100644 index 00000000..ab868ac4 --- /dev/null +++ b/packages/zpm/src/daemon/coordinator_state/output_buffer.rs @@ -0,0 +1,75 @@ +use std::collections::{HashMap, VecDeque}; + +use super::super::ipc::BufferedOutputLine; + +// ============================================================================ +// Output Buffer +// ============================================================================ + +/// Owns output storage and closed-task eviction. +/// Only modified by the coordinator event loop — no locks needed. +pub struct OutputBuffer { + /// Output lines per task + buffer: HashMap>, + /// Closed tasks in order (for LRU cleanup) + closed_tasks: VecDeque, + /// Max lines per task + max_lines: usize, + /// Max closed tasks to keep + max_closed_tasks: usize, +} + +impl OutputBuffer { + pub fn new(max_lines: usize, max_closed_tasks: usize) -> Self { + Self { + buffer: HashMap::new(), + closed_tasks: VecDeque::new(), + max_lines, + max_closed_tasks, + } + } + + pub fn append(&mut self, task_id: String, line: BufferedOutputLine) { + let lines = self.buffer.entry(task_id).or_default(); + lines.push(line); + + if lines.len() > self.max_lines { + let excess = lines.len() - self.max_lines; + lines.drain(0..excess); + } + } + + pub fn get(&self, task_id: &str) -> Vec { + self.buffer.get(task_id).cloned().unwrap_or_default() + } + + /// Mark a task as closed. Returns task ID strings that were evicted + /// (oldest closed tasks beyond the limit) so the caller can clean up + /// related state in other sub-structs. + pub fn mark_closed(&mut self, task_id: String) -> Vec { + self.closed_tasks.push_back(task_id); + + let mut evicted = Vec::new(); + + while self.closed_tasks.len() > self.max_closed_tasks { + if let Some(oldest_task_id) = self.closed_tasks.pop_front() { + self.buffer.remove(&oldest_task_id); + evicted.push(oldest_task_id); + } + } + + evicted + } + + // ======================================================================== + // Statistics + // ======================================================================== + + pub fn buffer_count(&self) -> usize { + self.buffer.len() + } + + pub fn closed_tasks_count(&self) -> usize { + self.closed_tasks.len() + } +} diff --git a/packages/zpm/src/daemon/coordinator_state/process_registry.rs b/packages/zpm/src/daemon/coordinator_state/process_registry.rs new file mode 100644 index 00000000..d84c2c06 --- /dev/null +++ b/packages/zpm/src/daemon/coordinator_state/process_registry.rs @@ -0,0 +1,119 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +use super::super::scheduler::ContextualTaskId; + +// ============================================================================ +// Spawning Task State +// ============================================================================ + +#[derive(Debug)] +struct SpawningEntry { + #[allow(dead_code)] + spawned_at: Instant, + pending_cancel: bool, +} + +// ============================================================================ +// Process Registry +// ============================================================================ + +/// Owns PID tracking and the spawning state machine. +/// Only modified by the coordinator event loop — no locks needed. +pub struct ProcessRegistry { + /// All registered PIDs + pids: HashSet, + /// Mapping from task to PID + task_to_pid: HashMap, + /// Tasks currently spawning (between spawn() and PID registration) + spawning: HashMap, +} + +impl ProcessRegistry { + pub fn new() -> Self { + Self { + pids: HashSet::new(), + task_to_pid: HashMap::new(), + spawning: HashMap::new(), + } + } + + // ======================================================================== + // PID Operations + // ======================================================================== + + pub fn register_pid(&mut self, pid: u32, task_id: ContextualTaskId) { + self.pids.insert(pid); + self.task_to_pid.insert(task_id, pid); + } + + pub fn unregister_pid(&mut self, pid: u32, task_id: &ContextualTaskId) { + self.pids.remove(&pid); + self.task_to_pid.remove(task_id); + } + + pub fn get_all_pids(&self) -> Vec { + self.pids.iter().cloned().collect() + } + + pub fn get_pid_for_task(&self, task_id: &ContextualTaskId) -> Option { + self.task_to_pid.get(task_id).copied() + } + + pub fn take_pid_for_task(&mut self, task_id: &ContextualTaskId) -> Option { + let pid = self.task_to_pid.remove(task_id)?; + self.pids.remove(&pid); + Some(pid) + } + + pub fn take_pids_for_context(&mut self, context_id: &str) -> Vec { + let task_ids_to_remove: Vec = self + .task_to_pid + .keys() + .filter(|ctx| ctx.context_id == context_id) + .cloned() + .collect(); + + let mut pids = Vec::with_capacity(task_ids_to_remove.len()); + for task_id in task_ids_to_remove { + if let Some(pid) = self.task_to_pid.remove(&task_id) { + self.pids.remove(&pid); + pids.push(pid); + } + } + + pids + } + + // ======================================================================== + // Spawning Operations + // ======================================================================== + + pub fn mark_spawning(&mut self, task_id: ContextualTaskId) { + self.spawning.insert(task_id, SpawningEntry { + spawned_at: Instant::now(), + pending_cancel: false, + }); + } + + pub fn mark_spawning_pending_cancel(&mut self, task_id: &ContextualTaskId) -> bool { + if let Some(entry) = self.spawning.get_mut(task_id) { + entry.pending_cancel = true; + true + } else { + false + } + } + + pub fn take_spawning(&mut self, task_id: &ContextualTaskId) -> Option { + self.spawning.remove(task_id).map(|e| e.pending_cancel) + } + + pub fn get_spawning_for_context(&self, context_id: &str) -> Vec { + self.spawning + .keys() + .filter(|ctx| ctx.context_id == context_id) + .cloned() + .collect() + } +} diff --git a/packages/zpm/src/daemon/coordinator_state/subscription_manager.rs b/packages/zpm/src/daemon/coordinator_state/subscription_manager.rs new file mode 100644 index 00000000..cd8019d1 --- /dev/null +++ b/packages/zpm/src/daemon/coordinator_state/subscription_manager.rs @@ -0,0 +1,144 @@ +use std::collections::{HashMap, HashSet}; + +use tokio::sync::mpsc; + +use super::super::ipc::{DaemonNotification, SubscriptionScope}; + +// ============================================================================ +// Subscription State +// ============================================================================ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SubscriptionId(pub u64); + +#[derive(Debug, Clone)] +pub struct SubscriptionFilter { + pub output_scope: SubscriptionScope, + pub status_scope: SubscriptionScope, + pub target_task_ids: HashSet, + pub all_task_ids: HashSet, + pub context_id: Option, +} + +impl SubscriptionFilter { + pub fn new(output_scope: SubscriptionScope, status_scope: SubscriptionScope, context_id: Option) -> Self { + Self { + output_scope, + status_scope, + target_task_ids: HashSet::new(), + all_task_ids: HashSet::new(), + context_id, + } + } + + pub fn matches(&self, notification: &DaemonNotification) -> bool { + let (task_id, scope) = match notification { + DaemonNotification::TaskOutputLine { task_id, .. } => (task_id, self.output_scope), + DaemonNotification::TaskStarted { task_id } => (task_id, self.status_scope), + DaemonNotification::TaskCompleted { task_id, .. } => (task_id, self.status_scope), + DaemonNotification::TaskCancelled { task_id } => (task_id, self.status_scope), + DaemonNotification::TaskWarmUpComplete { task_id } => (task_id, self.status_scope), + }; + + let is_explicit_target = self.target_task_ids.contains(task_id); + + if let Some(ref ctx) = self.context_id { + if !is_explicit_target && !task_id.ends_with(&format!("@{}", ctx)) { + return false; + } + } + + match scope { + SubscriptionScope::None => false, + SubscriptionScope::TargetOnly => is_explicit_target, + SubscriptionScope::FullTree => { + if is_explicit_target { + return true; + } + match &self.context_id { + Some(ctx) => task_id.ends_with(&format!("@{}", ctx)), + None => true, + } + } + } + } + + pub fn add_target_task(&mut self, task_id: String) { + self.target_task_ids.insert(task_id.clone()); + self.all_task_ids.insert(task_id); + } + + pub fn add_dependency_task(&mut self, task_id: String) { + self.all_task_ids.insert(task_id); + } +} + +struct Subscription { + filter: SubscriptionFilter, + sender: mpsc::UnboundedSender, +} + +// ============================================================================ +// Subscription Manager +// ============================================================================ + +/// Owns notification subscriptions. +/// Only modified by the coordinator event loop — no locks needed. +pub struct SubscriptionManager { + subscriptions: HashMap, + next_subscription_id: u64, +} + +impl SubscriptionManager { + pub fn new() -> Self { + Self { + subscriptions: HashMap::new(), + next_subscription_id: 1, + } + } + + pub fn create( + &mut self, + output_scope: SubscriptionScope, + status_scope: SubscriptionScope, + context_id: Option, + ) -> (SubscriptionId, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + let filter = SubscriptionFilter::new(output_scope, status_scope, context_id); + + let id = SubscriptionId(self.next_subscription_id); + self.next_subscription_id += 1; + + self.subscriptions.insert(id, Subscription { filter, sender: tx }); + + (id, rx) + } + + pub fn add_tasks( + &mut self, + subscription_id: SubscriptionId, + target_task_ids: Vec, + dependency_task_ids: Vec, + ) { + if let Some(sub) = self.subscriptions.get_mut(&subscription_id) { + for task_id in target_task_ids { + sub.filter.add_target_task(task_id); + } + for task_id in dependency_task_ids { + sub.filter.add_dependency_task(task_id); + } + } + } + + pub fn remove(&mut self, subscription_id: SubscriptionId) { + self.subscriptions.remove(&subscription_id); + } + + pub fn broadcast(&self, notification: DaemonNotification) { + for sub in self.subscriptions.values() { + if sub.filter.matches(¬ification) { + let _ = sub.sender.send(notification.clone()); + } + } + } +} diff --git a/packages/zpm/src/daemon/coordinator_state/task_graph.rs b/packages/zpm/src/daemon/coordinator_state/task_graph.rs new file mode 100644 index 00000000..758dc647 --- /dev/null +++ b/packages/zpm/src/daemon/coordinator_state/task_graph.rs @@ -0,0 +1,472 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use zpm_primitives::Ident; +use zpm_tasks::{ResolvedTasks, TaskId, TaskName}; +use zpm_utils::{DataType, ToFileString}; + +use super::super::presentation::prefix_colors; +use super::super::scheduler::{ContextualTaskId, PreparedTask}; +use crate::error::Error; +use crate::project::Project; + +// ============================================================================ +// Task State (Unified State Machine) +// ============================================================================ + +/// Core task execution state +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskState { + /// Task is prepared but not yet running + Pending, + /// Script has exited, waiting for subtasks to complete + WaitingForSubtasks { exit_code: i32 }, + /// Task completed successfully + Completed, + /// Task failed (script failure or process-level failure) + Failed, + /// Task was cancelled because a dependency failed + Cancelled, +} + +impl TaskState { + pub fn is_terminal(&self) -> bool { + matches!(self, TaskState::Completed | TaskState::Failed | TaskState::Cancelled) + } + + pub fn is_script_finished(&self) -> bool { + matches!( + self, + TaskState::WaitingForSubtasks { .. } | TaskState::Completed | TaskState::Failed | TaskState::Cancelled + ) + } +} + +/// Task information including state and metadata +#[derive(Debug, Clone)] +pub struct TaskInfo { + /// Current execution state + pub state: TaskState, + /// Whether this task was explicitly requested (vs. being a dependency) + pub is_target: bool, + /// For long-lived tasks: has the warm-up period completed? + pub warm_up_complete: bool, +} + +impl Default for TaskInfo { + fn default() -> Self { + Self { + state: TaskState::Pending, + is_target: false, + warm_up_complete: false, + } + } +} + +// ============================================================================ +// Task Graph +// ============================================================================ + +/// Owns the dependency/scheduling data and the task state machine. +/// Only modified by the coordinator event loop — no locks needed. +pub struct TaskGraph { + /// Shared task definitions (keyed by TaskId, not context-specific) + pub resolved: ResolvedTasks, + /// Unified task state tracking + pub tasks: HashMap, + /// Parent-child subtask relationships + pub subtasks: HashMap>, + /// Prepared task execution info + pub prepared: BTreeMap, +} + +impl TaskGraph { + pub fn new() -> Self { + Self { + resolved: ResolvedTasks { + tasks: BTreeMap::new(), + task_files: BTreeMap::new(), + }, + tasks: HashMap::new(), + subtasks: HashMap::new(), + prepared: BTreeMap::new(), + } + } + + // ======================================================================== + // Task Registration + // ======================================================================== + + pub fn add_task( + &mut self, + project: &Project, + task_name: &str, + parent_task_id: Option<&str>, + args: Vec, + workspace_override: Option<&str>, + context_id: Option<&str>, + ) -> Result<(ContextualTaskId, Vec), Error> { + let task_name = TaskName::new(task_name) + .map_err(|_| Error::TaskNameParseError(task_name.to_string()))?; + + let workspace = if let Some(ws_name) = workspace_override { + let ident = Ident::new(ws_name); + project.workspace_by_ident(&ident)? + } else { + project.active_workspace()? + }; + + let task_id = TaskId { + workspace: workspace.name.clone(), + task_name, + }; + + let ctx_id = if let Some(ctx) = context_id { + ctx.to_string() + } else if let Some(parent_str) = parent_task_id { + self.parse_context_id(parent_str) + .ok_or_else(|| Error::MissingContextId)? + } else { + return Err(Error::MissingContextId); + }; + + let ctx_task_id = ContextualTaskId::new(task_id.clone(), ctx_id.clone()); + + if let Some(parent_str) = parent_task_id { + if let Some(parent_ctx_id) = self.parse_contextual_task_id(project, parent_str) { + self.subtasks + .entry(parent_ctx_id) + .or_default() + .insert(ctx_task_id.clone()); + } + } + + // If task is already an active target for this context, don't re-add. + // But if the task has reached a terminal state (e.g. a long-lived task + // that was stopped and then restarted), allow re-adding by falling + // through to clear_task_state which resets the old entry. + if self.is_target(&ctx_task_id) && !self.is_terminal(&ctx_task_id) { + return Ok((ctx_task_id, vec![])); + } + + self.clear_task_state(&ctx_task_id); + + let new_resolved = project.resolve_task(&task_id)?; + + let mut resolved_ctx_task_ids: Vec = Vec::new(); + + for (tid, prereqs) in new_resolved.tasks { + let ctx_tid = ContextualTaskId::new(tid.clone(), ctx_id.clone()); + self.clear_task_state(&ctx_tid); + resolved_ctx_task_ids.push(ctx_tid); + self.resolved.tasks.entry(tid).or_insert(prereqs); + } + + for (ident, tf) in new_resolved.task_files { + self.resolved.task_files.entry(ident).or_insert(tf); + } + + self.set_as_target(&ctx_task_id); + self.prepare_specific_tasks(project, &resolved_ctx_task_ids)?; + + if !args.is_empty() { + if let Some(task) = self.prepared.get_mut(&ctx_task_id) { + task.args = args; + } + } + + Ok((ctx_task_id, resolved_ctx_task_ids)) + } + + /// Prepare only the specific tasks that were resolved for this context. + fn prepare_specific_tasks( + &mut self, + project: &Project, + tasks_to_prepare: &[ContextualTaskId], + ) -> Result { + let colors: Vec<&DataType> = prefix_colors().take(5).collect(); + let mut color_index = self.prepared.len(); + let mut new_count = 0; + + for ctx_task_id in tasks_to_prepare { + if self.prepared.contains_key(ctx_task_id) { + continue; + } + + let task_id = &ctx_task_id.task_id; + + let Some(task_file) = self.resolved.task_files.get(&task_id.workspace) else { + continue; + }; + + let Some(task) = task_file.tasks.get(task_id.task_name.as_str()) else { + continue; + }; + + if task.script.is_empty() { + continue; + } + + let Ok(workspace) = project.workspace_by_ident(&task_id.workspace) else { + continue; + }; + + let script = task.script.join("\n"); + let mut env = BTreeMap::new(); + + env.insert( + "npm_lifecycle_event".to_string(), + task_id.task_name.as_str().to_string(), + ); + + let color = colors[color_index % colors.len()]; + color_index += 1; + + let prefix = color.colorize(&format!( + "[{}:{}]: ", + task_id.workspace.to_file_string(), + task_id.task_name.as_str() + )); + + let is_long_lived = task.attributes.iter().any(|attr| attr.name == "long-lived"); + + self.prepared.insert( + ctx_task_id.clone(), + PreparedTask { + script, + cwd: workspace.path.clone(), + env, + prefix, + args: vec![], + is_long_lived, + }, + ); + + new_count += 1; + } + + Ok(new_count) + } + + fn clear_task_state(&mut self, task_id: &ContextualTaskId) { + self.tasks.remove(task_id); + self.subtasks.remove(task_id); + } + + // ======================================================================== + // Task State Queries + // ======================================================================== + + pub fn get_state(&self, task_id: &ContextualTaskId) -> TaskState { + self.tasks + .get(task_id) + .map(|info| info.state) + .unwrap_or(TaskState::Pending) + } + + pub fn is_completed(&self, task_id: &ContextualTaskId) -> bool { + matches!(self.get_state(task_id), TaskState::Completed) + } + + #[allow(dead_code)] + pub fn is_failed(&self, task_id: &ContextualTaskId) -> bool { + matches!(self.get_state(task_id), TaskState::Failed) + } + + #[allow(dead_code)] + pub fn is_cancelled(&self, task_id: &ContextualTaskId) -> bool { + matches!(self.get_state(task_id), TaskState::Cancelled) + } + + pub fn is_failed_or_cancelled(&self, task_id: &ContextualTaskId) -> bool { + matches!( + self.get_state(task_id), + TaskState::Failed | TaskState::Cancelled + ) + } + + pub fn is_terminal(&self, task_id: &ContextualTaskId) -> bool { + self.get_state(task_id).is_terminal() + } + + pub fn is_script_finished(&self, task_id: &ContextualTaskId) -> bool { + self.get_state(task_id).is_script_finished() + } + + pub fn is_warm_up_complete(&self, task_id: &ContextualTaskId) -> bool { + self.tasks + .get(task_id) + .map(|info| info.warm_up_complete) + .unwrap_or(false) + } + + pub fn is_target(&self, task_id: &ContextualTaskId) -> bool { + self.tasks + .get(task_id) + .map(|info| info.is_target) + .unwrap_or(false) + } + + pub fn get_waiting_exit_code(&self, task_id: &ContextualTaskId) -> Option { + match self.tasks.get(task_id)?.state { + TaskState::WaitingForSubtasks { exit_code } => Some(exit_code), + _ => None, + } + } + + pub fn is_task_fully_completed(&self, task_id: &ContextualTaskId) -> bool { + if !self.is_script_finished(task_id) { + return false; + } + + if let Some(task_subtasks) = self.subtasks.get(task_id) { + task_subtasks.iter().all(|s| self.is_completed(s) && !self.is_failed_or_cancelled(s)) + } else { + true + } + } + + pub fn is_long_lived(&self, task_id: &ContextualTaskId) -> bool { + self.prepared + .get(task_id) + .map(|p| p.is_long_lived) + .unwrap_or(false) + } + + pub fn has_failed_subtask(&self, task_id: &ContextualTaskId) -> bool { + if let Some(subtasks) = self.subtasks.get(task_id) { + subtasks.iter().any(|s| self.is_failed_or_cancelled(s)) + } else { + false + } + } + + pub fn find_parents(&self, task_id: &ContextualTaskId) -> Vec { + self.subtasks + .iter() + .filter(|(_, children)| children.contains(task_id)) + .map(|(parent, _)| parent.clone()) + .collect() + } + + pub fn should_spawn_task(&self, task_id: &ContextualTaskId) -> bool { + !self.is_terminal(task_id) + } + + // ======================================================================== + // Task State Mutations + // ======================================================================== + + fn ensure_task_info(&mut self, task_id: &ContextualTaskId) -> &mut TaskInfo { + self.tasks.entry(task_id.clone()).or_default() + } + + pub fn set_as_target(&mut self, task_id: &ContextualTaskId) { + self.ensure_task_info(task_id).is_target = true; + } + + pub fn mark_script_finished_with_code(&mut self, task_id: &ContextualTaskId, exit_code: i32) { + self.ensure_task_info(task_id).state = TaskState::WaitingForSubtasks { exit_code }; + } + + pub fn try_complete_task(&mut self, task_id: &ContextualTaskId) -> bool { + if self.is_task_fully_completed(task_id) { + self.ensure_task_info(task_id).state = TaskState::Completed; + true + } else { + false + } + } + + pub fn mark_completed(&mut self, task_id: &ContextualTaskId) { + self.ensure_task_info(task_id).state = TaskState::Completed; + } + + pub fn mark_failed(&mut self, task_id: &ContextualTaskId) { + self.ensure_task_info(task_id).state = TaskState::Failed; + } + + pub fn mark_cancelled(&mut self, task_id: &ContextualTaskId) { + self.ensure_task_info(task_id).state = TaskState::Cancelled; + } + + pub fn mark_warm_up_complete(&mut self, task_id: &ContextualTaskId) { + self.ensure_task_info(task_id).warm_up_complete = true; + } + + /// Remove all state for a closed task (used by output buffer eviction). + pub fn evict_closed_task(&mut self, task_id_str: &str) { + if let Some(ctx_id) = parse_contextual_task_id_simple(task_id_str) { + self.tasks.remove(&ctx_id); + self.prepared.remove(&ctx_id); + self.subtasks.remove(&ctx_id); + } + } + + // ======================================================================== + // ID Parsing Helpers + // ======================================================================== + + fn parse_contextual_task_id(&self, project: &Project, task_id_str: &str) -> Option { + let (task_part, context_id) = task_id_str.rsplit_once('@')?; + let (workspace_str, task_name_str) = task_part.split_once(':')?; + + let task_name = TaskName::new(task_name_str).ok()?; + let ident = Ident::new(workspace_str); + let workspace = project.workspace_by_ident(&ident).ok()?; + + Some(ContextualTaskId::new( + TaskId { + workspace: workspace.name.clone(), + task_name, + }, + context_id.to_string(), + )) + } + + fn parse_context_id(&self, task_id_str: &str) -> Option { + let (_, context_id) = task_id_str.rsplit_once('@')?; + Some(context_id.to_string()) + } + + // ======================================================================== + // Statistics + // ======================================================================== + + pub fn tasks_count(&self) -> usize { + self.tasks.len() + } + + pub fn prepared_count(&self) -> usize { + self.prepared.len() + } + + pub fn subtasks_count(&self) -> usize { + self.subtasks.len() + } +} + +// ============================================================================ +// Formatting Helpers (free functions) +// ============================================================================ + +pub fn format_contextual_task_id(ctx_task_id: &ContextualTaskId) -> String { + format!( + "{}:{}@{}", + ctx_task_id.task_id.workspace.to_file_string(), + ctx_task_id.task_id.task_name.as_str(), + ctx_task_id.context_id + ) +} + +fn parse_contextual_task_id_simple(task_id_str: &str) -> Option { + let (task_part, context_id) = task_id_str.rsplit_once('@')?; + let (workspace_str, task_name_str) = task_part.split_once(':')?; + + let task_name = TaskName::new(task_name_str).ok()?; + let workspace = Ident::new(workspace_str); + + Some(ContextualTaskId::new( + TaskId { workspace, task_name }, + context_id.to_string(), + )) +} diff --git a/packages/zpm/src/daemon/events.rs b/packages/zpm/src/daemon/events.rs new file mode 100644 index 00000000..f47742bc --- /dev/null +++ b/packages/zpm/src/daemon/events.rs @@ -0,0 +1,15 @@ +/// Stream type for task output (stdout or stderr). +#[derive(Debug, Clone)] +pub enum Stream { + Stdout, + Stderr, +} + +impl Stream { + pub fn as_str(&self) -> &'static str { + match self { + Stream::Stdout => "stdout", + Stream::Stderr => "stderr", + } + } +} diff --git a/packages/zpm/src/daemon/executor/mod.rs b/packages/zpm/src/daemon/executor/mod.rs new file mode 100644 index 00000000..a27e8b1f --- /dev/null +++ b/packages/zpm/src/daemon/executor/mod.rs @@ -0,0 +1,5 @@ +mod output; +mod pool; +mod runner; + +pub use pool::ExecutorPool; diff --git a/packages/zpm/src/daemon/executor/output.rs b/packages/zpm/src/daemon/executor/output.rs new file mode 100644 index 00000000..b9c08ae0 --- /dev/null +++ b/packages/zpm/src/daemon/executor/output.rs @@ -0,0 +1,56 @@ +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::ChildStderr; +use tokio::process::ChildStdout; + +use super::super::coordinator_commands::{CommandSender, CoordinatorCommand}; +use super::super::coordinator_state::ContextualTaskId; +use super::super::events::Stream; + +pub async fn stream_output( + stdout: ChildStdout, + stderr: ChildStderr, + task_id: ContextualTaskId, + command_tx: CommandSender, +) { + let mut stdout_reader = BufReader::new(stdout).lines(); + let mut stderr_reader = BufReader::new(stderr).lines(); + let mut stdout_done = false; + let mut stderr_done = false; + + loop { + tokio::select! { + line = stdout_reader.next_line(), if !stdout_done => { + match line { + Ok(Some(line)) => { + if command_tx.send(CoordinatorCommand::TaskOutput { + task_id: task_id.clone(), + line, + stream: Stream::Stdout, + }).is_err() { + return; + } + } + Ok(None) | Err(_) => { stdout_done = true; } + } + } + line = stderr_reader.next_line(), if !stderr_done => { + match line { + Ok(Some(line)) => { + if command_tx.send(CoordinatorCommand::TaskOutput { + task_id: task_id.clone(), + line, + stream: Stream::Stderr, + }).is_err() { + return; + } + } + Ok(None) | Err(_) => { stderr_done = true; } + } + } + } + + if stdout_done && stderr_done { + break; + } + } +} diff --git a/packages/zpm/src/daemon/executor/pool.rs b/packages/zpm/src/daemon/executor/pool.rs new file mode 100644 index 00000000..a942e937 --- /dev/null +++ b/packages/zpm/src/daemon/executor/pool.rs @@ -0,0 +1,66 @@ +// ============================================================================ +// ExecutorPool - Command-Based +// +// Sends ALL events as commands to the coordinator, including task completion. +// This ensures proper ordering: TaskOutput commands are always processed +// before TaskCompleted, since they all go through the same FIFO channel. +// ============================================================================ + +use std::collections::HashSet; + +use super::super::coordinator_commands::{CommandSender, CoordinatorCommand, TaskCompletionResult}; +use super::super::coordinator_state::{ContextualTaskId, PreparedTask}; +use super::runner::TaskRunner; + +/// ExecutorPool that communicates exclusively via commands. +/// All events including completion go through the command channel. +pub struct ExecutorPool { + running: HashSet, + daemon_url: String, + command_tx: CommandSender, +} + +impl ExecutorPool { + pub fn new(daemon_url: String, command_tx: CommandSender) -> Self { + Self { + running: HashSet::new(), + daemon_url, + command_tx, + } + } + + pub fn spawn(&mut self, task_id: ContextualTaskId, prepared: PreparedTask) { + let daemon_url = self.daemon_url.clone(); + let command_tx = self.command_tx.clone(); + + self.running.insert(task_id.clone()); + + tokio::spawn(async move { + let runner = TaskRunner::new(prepared, task_id.clone(), daemon_url, command_tx.clone()); + let result = runner.run().await; + + // Send TaskCompleted through the command channel. + // This happens AFTER stream_output() completes, so all TaskOutput + // commands are already in the channel ahead of this one. + let completion_result = match result { + Ok(status) => TaskCompletionResult::Exited(status), + Err(e) => TaskCompletionResult::Error(e.to_string()), + }; + + let _ = command_tx.send(CoordinatorCommand::TaskCompleted { + task_id, + result: completion_result, + }); + }); + } + + pub fn running_tasks(&self) -> impl Iterator { + self.running.iter() + } + + /// Mark a task as no longer running. + /// Called when TaskCompleted is processed by the coordinator. + pub fn mark_completed(&mut self, task_id: &ContextualTaskId) { + self.running.remove(task_id); + } +} diff --git a/packages/zpm/src/daemon/executor/runner.rs b/packages/zpm/src/daemon/executor/runner.rs new file mode 100644 index 00000000..483462ca --- /dev/null +++ b/packages/zpm/src/daemon/executor/runner.rs @@ -0,0 +1,99 @@ +// ============================================================================ +// TaskRunner - Command-Based +// +// Sends PID registration and unregistration via commands. +// Output is sent directly through the command channel. +// ============================================================================ + +use std::process::ExitStatus; + +use super::super::coordinator_commands::{CommandSender, CoordinatorCommand}; +use super::super::coordinator_state::{format_contextual_task_id, ContextualTaskId, PreparedTask}; +use super::super::ipc::{DAEMON_SERVER_ENV, TASK_CURRENT_ENV}; +use super::output::stream_output; +use crate::error::Error; +use crate::script::ScriptEnvironment; + +pub struct TaskRunner { + prepared: PreparedTask, + task_id: ContextualTaskId, + daemon_url: String, + command_tx: CommandSender, +} + +impl TaskRunner { + pub fn new( + prepared: PreparedTask, + task_id: ContextualTaskId, + daemon_url: String, + command_tx: CommandSender, + ) -> Self { + Self { + prepared, + task_id, + daemon_url, + command_tx, + } + } + + pub async fn run(self) -> Result { + let mut env = ScriptEnvironment::new()?; + + let task_id_str = format_contextual_task_id(&self.task_id); + + for (key, value) in &self.prepared.env { + env = env.with_env_variable(key, value); + } + + env = env.with_env_variable(TASK_CURRENT_ENV, &task_id_str); + env = env.with_env_variable(DAEMON_SERVER_ENV, &self.daemon_url); + + let mut running = env + .with_cwd(self.prepared.cwd.clone()) + .spawn_script( + &self.prepared.script, + self.prepared.args.iter().map(|s| s.as_str()), + ) + .await?; + + // Notify that the task has started (process is now spawned) + let _ = self.command_tx.send(CoordinatorCommand::TaskStarted { + task_id: self.task_id.clone(), + }); + + // Register the process PID via command + let pid = running.child.id(); + if let Some(pid) = pid { + let _ = self.command_tx.send(CoordinatorCommand::RegisterPid { + task_id: self.task_id.clone(), + pid, + }); + } + + let child_stdout = running + .child + .stdout + .take() + .ok_or_else(|| Error::TaskExecutionFailed("Failed to capture stdout".to_string()))?; + + let child_stderr = running + .child + .stderr + .take() + .ok_or_else(|| Error::TaskExecutionFailed("Failed to capture stderr".to_string()))?; + + stream_output(child_stdout, child_stderr, self.task_id.clone(), self.command_tx.clone()).await; + + let status = running.child.wait().await?; + + // Unregister the process PID via command + if let Some(pid) = pid { + let _ = self.command_tx.send(CoordinatorCommand::UnregisterPid { + task_id: self.task_id.clone(), + pid, + }); + } + + Ok(status) + } +} diff --git a/packages/zpm/src/daemon/handlers.rs b/packages/zpm/src/daemon/handlers.rs new file mode 100644 index 00000000..66d9af62 --- /dev/null +++ b/packages/zpm/src/daemon/handlers.rs @@ -0,0 +1,296 @@ +// ============================================================================ +// Updated Handlers (v2) +// +// All handlers communicate exclusively through the command channel. +// No direct access to any mutable state - races are impossible. +// ============================================================================ + +use tokio::sync::oneshot; + +use super::coordinator_commands::{CommandSender, CoordinatorCommand}; +use super::coordinator_state::SubscriptionId; +use super::ipc::{DaemonRequest, DaemonResponse, LongLivedTaskStatus, SubscriptionScope}; +use crate::project::Project; + +// ============================================================================ +// Request Dispatcher +// ============================================================================ + +/// Dispatch a daemon request using only the command channel. +/// NO direct access to scheduler, output_buffer, or any other mutable state. +pub async fn dispatch_request( + request: DaemonRequest, + project: &Project, + subscription_id: Option, + command_tx: &CommandSender, +) -> DaemonResponse { + match request { + DaemonRequest::Ping => DaemonResponse::Pong, + + DaemonRequest::PushTasks { + tasks, + parent_task_id, + workspace, + output_subscription: _, + status_subscription: _, + context_id, + } => { + handle_push_tasks(tasks, parent_task_id, workspace, context_id, subscription_id, command_tx).await + } + + DaemonRequest::GetTaskOutput { task_id } => { + handle_get_task_output(task_id, command_tx).await + } + + DaemonRequest::StopTask { task_name, workspace } => { + handle_stop_task(task_name, workspace, command_tx).await + } + + DaemonRequest::ListLongLivedTasks => { + handle_list_long_lived_tasks(project, command_tx).await + } + + DaemonRequest::CancelContext { context_id } => { + handle_cancel_context(context_id, command_tx).await + } + + DaemonRequest::GetStats => { + handle_get_stats(command_tx).await + } + } +} + +// ============================================================================ +// Individual Handlers +// ============================================================================ + +async fn handle_push_tasks( + tasks: Vec, + parent_task_id: Option, + workspace: Option, + context_id: Option, + subscription_id: Option, + command_tx: &CommandSender, +) -> DaemonResponse { + let (response_tx, response_rx) = oneshot::channel(); + + if command_tx + .send(CoordinatorCommand::PushTasks { + tasks, + parent_task_id, + workspace, + context_id, + subscription_id, + response_tx, + }) + .is_err() + { + return DaemonResponse::Error { + message: "Coordinator channel closed".to_string(), + }; + } + + match response_rx.await { + Ok(result) => { + if let Some(error) = result.error { + DaemonResponse::Error { message: error } + } else { + DaemonResponse::TasksEnqueued { + task_ids: result.task_ids, + dependency_count: result.dependency_ids.len(), + attached_long_lived: result.attached_long_lived, + } + } + } + Err(_) => DaemonResponse::Error { + message: "Coordinator did not respond".to_string(), + }, + } +} + +async fn handle_get_task_output(task_id: String, command_tx: &CommandSender) -> DaemonResponse { + let (response_tx, response_rx) = oneshot::channel(); + + if command_tx + .send(CoordinatorCommand::GetTaskOutput { + task_id: task_id.clone(), + response_tx, + }) + .is_err() + { + return DaemonResponse::Error { + message: "Coordinator channel closed".to_string(), + }; + } + + match response_rx.await { + Ok(lines) => DaemonResponse::TaskOutput { task_id, lines }, + Err(_) => DaemonResponse::Error { + message: "Coordinator did not respond".to_string(), + }, + } +} + +async fn handle_stop_task( + task_name: String, + workspace: Option, + command_tx: &CommandSender, +) -> DaemonResponse { + let (response_tx, response_rx) = oneshot::channel(); + + if command_tx + .send(CoordinatorCommand::StopTask { + task_name, + workspace, + response_tx, + }) + .is_err() + { + return DaemonResponse::Error { + message: "Coordinator channel closed".to_string(), + }; + } + + match response_rx.await { + Ok(result) => DaemonResponse::TaskStopped { + success: result.success, + error: result.error, + }, + Err(_) => DaemonResponse::Error { + message: "Coordinator did not respond".to_string(), + }, + } +} + +async fn handle_list_long_lived_tasks( + _project: &Project, + command_tx: &CommandSender, +) -> DaemonResponse { + let (response_tx, response_rx) = oneshot::channel(); + + if command_tx + .send(CoordinatorCommand::ListLongLivedTasks { response_tx }) + .is_err() + { + return DaemonResponse::Error { + message: "Coordinator channel closed".to_string(), + }; + } + + match response_rx.await { + Ok(entries) => { + let tasks: Vec = entries + .into_iter() + .map(|info| { + // Parse task_id (format: "workspace:taskname") + let (workspace, task_name) = info + .task_id + .split_once(':') + .map(|(w, t)| (w.to_string(), t.to_string())) + .unwrap_or_else(|| ("".to_string(), info.task_id.clone())); + + let status = LongLivedTaskStatus::Running { + started_at_ms: info.started_at_ms, + process_id: info.process_id, + }; + + super::ipc::LongLivedTaskInfo { + workspace, + task_name, + status, + } + }) + .collect(); + + DaemonResponse::LongLivedTaskList { tasks } + } + Err(_) => DaemonResponse::Error { + message: "Coordinator did not respond".to_string(), + }, + } +} + +async fn handle_cancel_context(context_id: String, command_tx: &CommandSender) -> DaemonResponse { + let (response_tx, response_rx) = oneshot::channel(); + + if command_tx + .send(CoordinatorCommand::CancelContext { + context_id, + response_tx, + }) + .is_err() + { + return DaemonResponse::Error { + message: "Coordinator channel closed".to_string(), + }; + } + + match response_rx.await { + Ok(result) => DaemonResponse::ContextCancelled { + cancelled_count: result.cancelled_count, + }, + Err(_) => DaemonResponse::Error { + message: "Coordinator did not respond".to_string(), + }, + } +} + +async fn handle_get_stats(command_tx: &CommandSender) -> DaemonResponse { + let (response_tx, response_rx) = oneshot::channel(); + + if command_tx + .send(CoordinatorCommand::GetStats { response_tx }) + .is_err() + { + return DaemonResponse::Error { + message: "Coordinator channel closed".to_string(), + }; + } + + match response_rx.await { + Ok(result) => DaemonResponse::Stats { + tasks_count: result.tasks_count, + prepared_count: result.prepared_count, + subtasks_count: result.subtasks_count, + output_buffer_count: result.output_buffer_count, + closed_tasks_count: result.closed_tasks_count, + }, + Err(_) => DaemonResponse::Error { + message: "Coordinator did not respond".to_string(), + }, + } +} + +// ============================================================================ +// Subscription Creation Helper +// +// Called from connection handler before dispatching request. +// Returns subscription info via command. +// ============================================================================ + +pub async fn create_subscription_if_needed( + output_scope: SubscriptionScope, + status_scope: SubscriptionScope, + context_id: Option, + command_tx: &CommandSender, +) -> Option<(SubscriptionId, tokio::sync::mpsc::UnboundedReceiver)> { + if output_scope == SubscriptionScope::None && status_scope == SubscriptionScope::None { + return None; + } + + let (response_tx, response_rx) = oneshot::channel(); + + if command_tx + .send(CoordinatorCommand::CreateSubscription { + output_scope, + status_scope, + context_id, + response_tx, + }) + .is_err() + { + return None; + } + + response_rx.await.ok() +} diff --git a/packages/zpm/src/daemon/ipc.rs b/packages/zpm/src/daemon/ipc.rs new file mode 100644 index 00000000..96c2a9b7 --- /dev/null +++ b/packages/zpm/src/daemon/ipc.rs @@ -0,0 +1,195 @@ +use serde::{Deserialize, Serialize}; + +pub const DAEMON_BASE_PORT: u16 = 12197; +pub const TASK_CURRENT_ENV: &str = "ZPM_TASK_CURRENT"; +pub const DAEMON_SERVER_ENV: &str = "YARN_DAEMON_SERVER"; +pub const LONG_LIVED_CONTEXT_ID: &str = "4d84fea4-e0d4-4df6-8190-f312b86968b3"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskSubscription { + pub name: String, + pub args: Vec, +} + +/// Defines the scope of subscription for notifications +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SubscriptionScope { + /// No subscription - don't receive these notifications + None, + /// Subscribe only to target tasks (the ones directly requested) + TargetOnly, + /// Subscribe to all tasks in the dependency tree + FullTree, +} + +/// Envelope for client-to-server requests, includes a correlation ID +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DaemonRequestEnvelope { + pub request_id: u64, + pub request: DaemonRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum DaemonRequest { + Ping, + PushTasks { + tasks: Vec, + parent_task_id: Option, + workspace: Option, + output_subscription: SubscriptionScope, + status_subscription: SubscriptionScope, + /// Context ID for task execution. Required for new tasks, inherited from parent for subtasks. + context_id: Option, + }, + GetTaskOutput { + task_id: String, + }, + StopTask { + task_name: String, + workspace: Option, + }, + ListLongLivedTasks, + /// Cancel all tasks in a given context (used for Ctrl+C handling) + CancelContext { + context_id: String, + }, + /// Get internal state statistics (for debugging/testing memory management) + GetStats, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BufferedOutputLine { + pub line: String, + pub stream: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachedLongLivedTask { + pub task_id: String, + pub started_at_ms: u64, +} + +/// Status of a long-lived task +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LongLivedTaskStatus { + /// Task is not running + Stopped, + /// Task is running + Running { + started_at_ms: u64, + process_id: Option, + }, +} + +/// Information about a long-lived task +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LongLivedTaskInfo { + /// The workspace name + pub workspace: String, + /// The task name + pub task_name: String, + /// Current status + pub status: LongLivedTaskStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum DaemonResponse { + Pong, + TasksEnqueued { + /// The directly requested task IDs + task_ids: Vec, + /// Total number of dependency tasks (excluding target tasks) + dependency_count: usize, + /// Long-lived tasks that we attached to (already running) + attached_long_lived: Vec, + }, + TaskOutput { + task_id: String, + lines: Vec, + }, + TaskStopped { + success: bool, + error: Option, + }, + LongLivedTaskList { + tasks: Vec, + }, + ContextCancelled { + cancelled_count: usize, + }, + Stats { + /// Number of entries in the tasks HashMap + tasks_count: usize, + /// Number of entries in the prepared BTreeMap + prepared_count: usize, + /// Number of entries in the subtasks HashMap + subtasks_count: usize, + /// Number of entries in the output_buffer HashMap + output_buffer_count: usize, + /// Number of entries in the closed_tasks queue + closed_tasks_count: usize, + }, + Error { + message: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum DaemonNotification { + TaskOutputLine { + task_id: String, + line: String, + stream: String, + }, + TaskStarted { + task_id: String, + }, + TaskCompleted { + task_id: String, + exit_code: i32, + }, + TaskCancelled { + task_id: String, + }, + TaskWarmUpComplete { + task_id: String, + }, +} + +/// Unified message type for all server-to-client communication. +/// Uses a `kind` discriminator to distinguish responses from notifications. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum DaemonMessage { + Response { + request_id: u64, + response: DaemonResponse, + }, + Notification { + notification: DaemonNotification, + }, +} + +impl DaemonMessage { + pub fn response(request_id: u64, response: DaemonResponse) -> Self { + Self::Response { request_id, response } + } + + pub fn notification(notification: DaemonNotification) -> Self { + Self::Notification { notification } + } +} + +pub fn daemon_url(port: u16) -> String { + format!("ws://127.0.0.1:{}", port) +} diff --git a/packages/zpm/src/daemon/mod.rs b/packages/zpm/src/daemon/mod.rs new file mode 100644 index 00000000..61c18764 --- /dev/null +++ b/packages/zpm/src/daemon/mod.rs @@ -0,0 +1,27 @@ +mod client; +mod coordinator; +mod coordinator_commands; +mod coordinator_state; +mod events; +mod executor; +mod handlers; +mod ipc; +mod platform; +mod presentation; +mod scheduler; +mod server; + +pub use client::{DaemonClient, PushTasksResult, StandaloneDaemonHandle}; +pub use coordinator::run_daemon; +pub use coordinator_commands::{CommandSender, CoordinatorCommand}; +pub use coordinator_state::SubscriptionId; +pub use events::Stream; +pub use ipc::{ + daemon_url, AttachedLongLivedTask, BufferedOutputLine, DaemonMessage, DaemonNotification, + DaemonRequest, DaemonRequestEnvelope, DaemonResponse, LongLivedTaskInfo, LongLivedTaskStatus, + SubscriptionScope, TaskSubscription, DAEMON_BASE_PORT, DAEMON_SERVER_ENV, LONG_LIVED_CONTEXT_ID, + TASK_CURRENT_ENV, +}; +pub use presentation::{prefix_colors, ProgressState}; +pub use scheduler::{ContextualTaskId, PreparedTask}; +pub use server::bind_to_available_port; diff --git a/packages/zpm/src/daemon/platform.rs b/packages/zpm/src/daemon/platform.rs new file mode 100644 index 00000000..9843fc5a --- /dev/null +++ b/packages/zpm/src/daemon/platform.rs @@ -0,0 +1,41 @@ +// ============================================================================ +// Platform-specific process operations +// +// Consolidates all platform-gated process operations into one module. +// Every other file imports from here instead of writing its own #[cfg] blocks. +// ============================================================================ + +/// Send SIGTERM to a process group (Unix) or terminate the process (Windows). +#[cfg(unix)] +pub fn kill_process_group(pid: u32) { + let result = unsafe { libc::killpg(pid as i32, libc::SIGTERM) }; + if result != 0 { + let _ = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + } +} + +#[cfg(not(unix))] +pub fn kill_process_group(_pid: u32) {} + +/// Send SIGKILL to a process (Unix) or terminate the process (Windows). +#[cfg(unix)] +pub fn kill_process(pid: u32) { + let result = unsafe { libc::killpg(pid as i32, libc::SIGKILL) }; + if result != 0 { + let _ = unsafe { libc::kill(pid as i32, libc::SIGKILL) }; + } +} + +#[cfg(not(unix))] +pub fn kill_process(_pid: u32) {} + +/// Check if a process is still alive. +#[cfg(unix)] +pub fn is_process_alive(pid: u32) -> bool { + unsafe { libc::kill(pid as i32, 0) == 0 } +} + +#[cfg(not(unix))] +pub fn is_process_alive(_pid: u32) -> bool { + false +} diff --git a/packages/zpm/src/daemon/presentation/mod.rs b/packages/zpm/src/daemon/presentation/mod.rs new file mode 100644 index 00000000..c605a418 --- /dev/null +++ b/packages/zpm/src/daemon/presentation/mod.rs @@ -0,0 +1,17 @@ +mod progress; + +pub use progress::ProgressState; + +use zpm_utils::DataType; + +static PREFIX_COLORS: [DataType; 5] = [ + DataType::Custom(46, 134, 171), + DataType::Custom(162, 59, 114), + DataType::Custom(241, 143, 1), + DataType::Custom(199, 62, 29), + DataType::Custom(204, 226, 163), +]; + +pub fn prefix_colors() -> impl Iterator { + PREFIX_COLORS.iter().cycle() +} diff --git a/packages/zpm/src/daemon/presentation/progress.rs b/packages/zpm/src/daemon/presentation/progress.rs new file mode 100644 index 00000000..b68c382c --- /dev/null +++ b/packages/zpm/src/daemon/presentation/progress.rs @@ -0,0 +1,109 @@ +use std::collections::BTreeSet; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + +use zpm_utils::DataType; + +fn interpolate_gradient(keyframes: &[(u8, u8, u8)], steps_between: usize) -> Vec<(u8, u8, u8)> { + let mut colors = Vec::with_capacity(keyframes.len() * steps_between); + + for i in 0..keyframes.len() { + let (r1, g1, b1) = keyframes[i]; + let (r2, g2, b2) = keyframes[(i + 1) % keyframes.len()]; + + for step in 0..steps_between { + let t = step as f32 / steps_between as f32; + + let r = (r1 as f32 + (r2 as f32 - r1 as f32) * t) as u8; + let g = (g1 as f32 + (g2 as f32 - g1 as f32) * t) as u8; + let b = (b1 as f32 + (b2 as f32 - b1 as f32) * t) as u8; + + colors.push((r, g, b)); + } + } + + colors +} + +fn generate_gradient_frames(text: &str) -> Vec { + let keyframes: [(u8, u8, u8); 4] = [ + (100, 149, 237), + (65, 105, 225), + (30, 144, 255), + (0, 191, 255), + ]; + + let gradient_colors = interpolate_gradient(&keyframes, 8); + + let chars: Vec = text.chars().collect(); + + (0..gradient_colors.len()) + .map(|frame| { + let mut result = String::with_capacity(text.len() * 20); + + for (i, ch) in chars.iter().enumerate() { + let color_idx = (i * 2 + gradient_colors.len() - frame) % gradient_colors.len(); + + let (r, g, b) = gradient_colors[color_idx]; + + result.push_str(&format!("\x1b[38;2;{};{};{}m{}", r, g, b, ch)); + } + + result.push_str("\x1b[0m"); + result + }) + .collect() +} + +pub struct ProgressState { + pub total: AtomicUsize, + pub completed: AtomicUsize, + pub running_tasks: Mutex>, + gradient_frames: Vec, +} + +impl ProgressState { + pub fn new(total: usize) -> Self { + let gradient_frames = generate_gradient_frames("Running dependencies"); + + Self { + total: AtomicUsize::new(total), + completed: AtomicUsize::new(0), + running_tasks: Mutex::new(BTreeSet::new()), + gradient_frames, + } + } + + pub fn add_to_total(&self, count: usize) { + self.total.fetch_add(count, Ordering::Relaxed); + } + + pub fn add_task(&self, task_name: &str) { + self.running_tasks.lock().unwrap().insert(task_name.to_string()); + } + + pub fn remove_task(&self, task_name: &str) { + self.running_tasks.lock().unwrap().remove(task_name); + self.completed.fetch_add(1, Ordering::Relaxed); + } + + pub fn format_progress(&self, frame_idx: usize) -> String { + let total = self.total.load(Ordering::Relaxed); + let completed = self.completed.load(Ordering::Relaxed); + let running = self.running_tasks.lock().unwrap().len(); + let scheduled = total.saturating_sub(running).saturating_sub(completed); + + let label = &self.gradient_frames[frame_idx % self.gradient_frames.len()]; + + format!( + "{} {}", + label, + DataType::Custom(128, 128, 128).colorize(&format!( + "· running {} · scheduled {} · completed {}", + running, + scheduled, + completed + )) + ) + } +} diff --git a/packages/zpm/src/daemon/scheduler/dependencies.rs b/packages/zpm/src/daemon/scheduler/dependencies.rs new file mode 100644 index 00000000..89b82cd3 --- /dev/null +++ b/packages/zpm/src/daemon/scheduler/dependencies.rs @@ -0,0 +1,137 @@ +use std::collections::HashSet; + +use super::state::ContextualTaskId; +use crate::daemon::coordinator_state::TaskGraph; + +/// Find tasks that are ready to execute. +/// A task is ready when all its prerequisites are completed (in the same context). +/// For long-lived prerequisites, being "warmed up" counts as ready for dependents. +/// +/// This function returns both tasks with scripts (in `prepared`) and tasks without +/// scripts (dependency aggregators). Tasks without scripts will have None as their +/// PreparedTask in the coordinator's ready task list. +pub fn find_ready_tasks( + graph: &TaskGraph, + running: &HashSet, +) -> Vec { + // Collect all contexts that have pending work + let active_contexts: HashSet<&String> = graph + .tasks + .keys() + .chain(running.iter()) + .map(|ctx_id| &ctx_id.context_id) + .collect(); + + let mut ready = Vec::new(); + + // For each context, check which tasks are ready + for context_id in active_contexts { + for (task_id, prerequisites) in &graph.resolved.tasks { + let ctx_task_id = ContextualTaskId::new(task_id.clone(), context_id.clone()); + + // A task can be ready if it's either: + // 1. In the prepared map (has a script to run), OR + // 2. Is a target task that's not in prepared (no script, just aggregates dependencies) + // + // For non-target tasks without scripts, they are implicitly "completed" since + // there's nothing to run - their prerequisites just propagate to their dependents. + let is_prepared = graph.prepared.contains_key(&ctx_task_id); + let is_target_without_script = !is_prepared && graph.is_target(&ctx_task_id); + + if !is_prepared && !is_target_without_script { + continue; + } + + // Skip if already completed, failed, finished, or running + let task_state = graph.get_state(&ctx_task_id); + if task_state.is_terminal() + || task_state.is_script_finished() + || running.contains(&ctx_task_id) + { + continue; + } + + // Check if all prerequisites are ready (in the same context) + // For regular tasks, "ready" means completed. + // For long-lived tasks, "ready" means warm-up complete. + let all_prereqs_ready = prerequisites.iter().all(|prereq| { + let ctx_prereq = ContextualTaskId::new(prereq.clone(), context_id.clone()); + + if graph.is_failed_or_cancelled(&ctx_prereq) { + return false; + } + + if graph.is_completed(&ctx_prereq) { + return true; + } + + let is_long_lived = graph + .prepared + .get(&ctx_prereq) + .map(|p| p.is_long_lived) + .unwrap_or(false); + + if is_long_lived && graph.is_warm_up_complete(&ctx_prereq) { + return true; + } + + false + }); + + if all_prereqs_ready { + ready.push(ctx_task_id); + } + } + } + + ready +} + +/// Find tasks that should be marked as failed because a prerequisite failed. +pub fn find_tasks_to_fail( + graph: &TaskGraph, + running: &HashSet, +) -> Vec { + // Collect all contexts that have pending work + let active_contexts: HashSet<&String> = graph + .tasks + .keys() + .chain(running.iter()) + .map(|ctx_id| &ctx_id.context_id) + .collect(); + + let mut to_fail = Vec::new(); + + // For each context, check which tasks should fail + for context_id in active_contexts { + for (task_id, prerequisites) in &graph.resolved.tasks { + let ctx_task_id = ContextualTaskId::new(task_id.clone(), context_id.clone()); + + // Consider tasks that are either prepared OR are target tasks without scripts + let is_prepared = graph.prepared.contains_key(&ctx_task_id); + let is_target_without_script = !is_prepared && graph.is_target(&ctx_task_id); + + if !is_prepared && !is_target_without_script { + continue; + } + + // Skip if already completed, failed, script finished (e.g. waiting for subtasks), or running + let task_state = graph.get_state(&ctx_task_id); + if task_state.is_terminal() || task_state.is_script_finished() || running.contains(&ctx_task_id) { + continue; + } + + // Check if any prerequisite failed or was cancelled (in the same context) + let any_prereq_failed = prerequisites.iter().any(|prereq| { + let ctx_prereq = ContextualTaskId::new(prereq.clone(), context_id.clone()); + graph.is_failed_or_cancelled(&ctx_prereq) + }); + + if any_prereq_failed { + to_fail.push(ctx_task_id); + } + } + } + + to_fail +} diff --git a/packages/zpm/src/daemon/scheduler/mod.rs b/packages/zpm/src/daemon/scheduler/mod.rs new file mode 100644 index 00000000..c590f638 --- /dev/null +++ b/packages/zpm/src/daemon/scheduler/mod.rs @@ -0,0 +1,4 @@ +pub mod dependencies; +mod state; + +pub use state::{ContextualTaskId, PreparedTask}; diff --git a/packages/zpm/src/daemon/scheduler/state.rs b/packages/zpm/src/daemon/scheduler/state.rs new file mode 100644 index 00000000..0a2dc1ec --- /dev/null +++ b/packages/zpm/src/daemon/scheduler/state.rs @@ -0,0 +1,28 @@ +use std::collections::BTreeMap; + +use zpm_tasks::TaskId; +use zpm_utils::Path; + +/// A task ID scoped to a specific execution context. +/// Same TaskId can exist in multiple contexts and run in parallel. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ContextualTaskId { + pub task_id: TaskId, + pub context_id: String, +} + +impl ContextualTaskId { + pub fn new(task_id: TaskId, context_id: String) -> Self { + Self { task_id, context_id } + } +} + +#[derive(Debug, Clone)] +pub struct PreparedTask { + pub script: String, + pub cwd: Path, + pub env: BTreeMap, + pub prefix: String, + pub args: Vec, + pub is_long_lived: bool, +} diff --git a/packages/zpm/src/daemon/server/connection.rs b/packages/zpm/src/daemon/server/connection.rs new file mode 100644 index 00000000..19c179e9 --- /dev/null +++ b/packages/zpm/src/daemon/server/connection.rs @@ -0,0 +1,271 @@ +// ============================================================================ +// Connection Handler - Command-Based +// +// All state access goes through commands. No Arc references. +// Subscriptions are created via commands and cleaned up via commands. +// ============================================================================ + +use std::net::SocketAddr; +use std::sync::Arc; + +use futures::stream::StreamExt; +use futures::SinkExt; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; + +use super::super::coordinator_commands::{CommandSender, CoordinatorCommand}; +use super::super::coordinator_state::SubscriptionId; +use super::super::handlers::{create_subscription_if_needed, dispatch_request}; +use super::super::ipc::{ + DaemonMessage, DaemonNotification, DaemonRequest, DaemonRequestEnvelope, + DaemonResponse, SubscriptionScope, +}; +use crate::project::Project; + +// ============================================================================ +// Connection Context +// ============================================================================ + +/// Connection context with only immutable data and command channel. +/// All mutable state access goes through commands. +pub struct ConnectionContext { + pub project: Arc, + pub command_tx: CommandSender, +} + +// ============================================================================ +// Subscription Guard +// ============================================================================ + +/// Guard that removes subscription when dropped (via command) +struct SubscriptionGuard { + subscription_id: SubscriptionId, + command_tx: CommandSender, +} + +impl SubscriptionGuard { + fn new(subscription_id: SubscriptionId, command_tx: CommandSender) -> Self { + Self { + subscription_id, + command_tx, + } + } +} + +impl Drop for SubscriptionGuard { + fn drop(&mut self) { + let _ = self.command_tx.send(CoordinatorCommand::RemoveSubscription { + subscription_id: self.subscription_id, + }); + } +} + +// ============================================================================ +// Connection Handler +// ============================================================================ + +pub async fn handle_connection( + stream: tokio::net::TcpStream, + addr: SocketAddr, + ctx: Arc, +) -> Result<(), zpm_switch::Error> { + let ws_stream = tokio_tungstenite::accept_async(stream) + .await + .map_err(|e| { + zpm_switch::Error::SocketReadError(std::sync::Arc::new(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + ))) + })?; + + let (mut write, mut read) = ws_stream.split(); + + // Subscription guards - cleaned up when connection drops + let mut subscription_guards: Vec = Vec::new(); + + // Notification receivers from subscriptions + let mut notification_receivers: Vec> = Vec::new(); + + loop { + let notification_future = poll_notifications(&mut notification_receivers); + + tokio::select! { + biased; + + // Handle incoming messages + msg_opt = read.next() => { + let msg = match msg_opt { + Some(Ok(m)) => m, + Some(Err(e)) => { + eprintln!("WebSocket error from {}: {}", addr, e); + break; + } + None => break, + }; + + match msg { + Message::Text(text) => { + let envelope: DaemonRequestEnvelope = match serde_json::from_str(&text) { + Ok(r) => r, + Err(e) => { + let error_response = DaemonMessage::response( + 0, + DaemonResponse::Error { + message: format!("Invalid request: {}", e), + }, + ); + + if let Ok(error_json) = serde_json::to_string(&error_response) { + let _ = write.send(Message::Text(error_json.into())).await; + } + continue; + } + }; + + let request_id = envelope.request_id; + let request = envelope.request; + + // Create subscription if needed (via command) + let subscription_id = if let DaemonRequest::PushTasks { + output_subscription, + status_subscription, + context_id, + .. + } = &request + { + if *output_subscription != SubscriptionScope::None + || *status_subscription != SubscriptionScope::None + { + match create_subscription_if_needed( + *output_subscription, + *status_subscription, + context_id.clone(), + &ctx.command_tx, + ) + .await + { + Some((sub_id, rx)) => { + let guard = SubscriptionGuard::new( + sub_id, + ctx.command_tx.clone(), + ); + subscription_guards.push(guard); + notification_receivers.push(rx); + Some(sub_id) + } + None => None, + } + } else { + None + } + } else { + None + }; + + // Dispatch request via commands + let response = dispatch_request( + request, + &ctx.project, + subscription_id, + &ctx.command_tx, + ) + .await; + + let message = DaemonMessage::response(request_id, response); + + let response_json = serde_json::to_string(&message) + .map_err(|e| zpm_switch::Error::InvalidDaemonMessage(e.to_string()))?; + + write + .send(Message::Text(response_json.into())) + .await + .map_err(|e| { + zpm_switch::Error::SocketWriteError(std::sync::Arc::new( + std::io::Error::new(std::io::ErrorKind::Other, e.to_string()), + )) + })?; + } + Message::Close(frame) => { + write.send(Message::Close(frame)).await.ok(); + break; + } + Message::Ping(data) => { + write.send(Message::Pong(data)).await.ok(); + } + _ => {} + } + } + + // Handle notifications from subscriptions + Some(notification) = notification_future => { + let message = DaemonMessage::notification(notification); + + let notification_json = serde_json::to_string(&message) + .map_err(|e| zpm_switch::Error::InvalidDaemonMessage(e.to_string()))?; + + if write.send(Message::Text(notification_json.into())).await.is_err() { + break; + } + } + } + } + + // subscription_guards dropped here, sending RemoveSubscription commands + Ok(()) +} + +// ============================================================================ +// Accept Loop +// ============================================================================ + +pub async fn run_accept_loop( + listener: tokio::net::TcpListener, + ctx: Arc, +) { + loop { + match listener.accept().await { + Ok((stream, addr)) => { + let ctx = ctx.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, addr, ctx).await { + eprintln!("Connection error from {}: {}", addr, e); + } + }); + } + Err(e) => { + eprintln!("Failed to accept connection: {}", e); + } + } + } +} + +// ============================================================================ +// Notification Polling +// ============================================================================ + +async fn poll_notifications( + receivers: &mut Vec>, +) -> Option { + if receivers.is_empty() { + std::future::pending::>().await + } else { + futures::future::poll_fn(|cx| { + let mut i = 0; + while i < receivers.len() { + match receivers[i].poll_recv(cx) { + std::task::Poll::Ready(Some(notif)) => { + return std::task::Poll::Ready(Some(notif)); + } + std::task::Poll::Ready(None) => { + receivers.swap_remove(i); + } + std::task::Poll::Pending => { + i += 1; + } + } + } + std::task::Poll::Pending + }) + .await + } +} diff --git a/packages/zpm/src/daemon/server/mod.rs b/packages/zpm/src/daemon/server/mod.rs new file mode 100644 index 00000000..553ee23f --- /dev/null +++ b/packages/zpm/src/daemon/server/mod.rs @@ -0,0 +1,29 @@ +pub mod connection; + +use std::net::SocketAddr; + +use tokio::net::TcpListener; + +use super::ipc::DAEMON_BASE_PORT; +use crate::error::Error; + +// Re-exports from connection module are available via super::server::connection + +pub async fn bind_to_available_port() -> Result<(TcpListener, u16), Error> { + for port in DAEMON_BASE_PORT..=DAEMON_BASE_PORT + 100 { + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + if let Ok(listener) = TcpListener::bind(addr).await { + return Ok((listener, port)); + } + } + + Err(zpm_switch::Error::FailedToBindSocket(std::sync::Arc::new(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + format!( + "Could not bind to any port in range {}-{}", + DAEMON_BASE_PORT, + DAEMON_BASE_PORT + 100 + ), + ))) + .into()) +} diff --git a/packages/zpm/src/error.rs b/packages/zpm/src/error.rs index c1d1362b..303eb393 100644 --- a/packages/zpm/src/error.rs +++ b/packages/zpm/src/error.rs @@ -378,9 +378,6 @@ pub enum Error { #[error("Invalid task name: {0}")] TaskNameParseError(String), - #[error("Not running inside a task context (ZPM_TASK_IPC_SOCKET not set)")] - NotInTaskContext, - #[error("IPC connection failed: {0}")] IpcConnectionFailed(String), @@ -390,6 +387,12 @@ pub enum Error { #[error("Task push failed: {0}")] TaskPushFailed(String), + #[error("Task execution failed: {0}")] + TaskExecutionFailed(String), + + #[error("Missing context_id: task operations require a context_id (either provided directly or inherited from parent task)")] + MissingContextId, + #[error("JSON serialization error: {0}")] JsonSerializeError(String), diff --git a/packages/zpm/src/ipc.rs b/packages/zpm/src/ipc.rs deleted file mode 100644 index 2933a2f7..00000000 --- a/packages/zpm/src/ipc.rs +++ /dev/null @@ -1,202 +0,0 @@ -use interprocess::local_socket::{ - GenericFilePath, GenericNamespaced, ListenerOptions, ToFsName, ToNsName, - traits::tokio::{Listener, Stream}, - tokio::{prelude::*, RecvHalf, SendHalf}, -}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::{mpsc, oneshot}; - -use crate::error::Error; - -pub const IPC_SOCKET_ENV: &str = "ZPM_TASK_IPC_SOCKET"; -pub const IPC_CURRENT_TASK_ENV: &str = "ZPM_TASK_CURRENT"; - -pub struct PushRequest { - pub task_name: String, - pub parent_task_id: Option, - pub response_tx: oneshot::Sender, -} - -pub enum PushResponse { - Ok, - Error(String), -} - -pub struct TaskIpcServer { - socket_name: String, - listener: LocalSocketListener, -} - -impl TaskIpcServer { - pub async fn new() -> Result { - let pid - = std::process::id(); - - let random: u64 - = rand::random(); - - let socket_name - = format!("zpm-task-{}-{:x}.sock", pid, random); - - let name - = socket_name.clone().to_ns_name::() - .or_else(|_| socket_name.clone().to_fs_name::()) - .map_err(|e| Error::IpcError(e.to_string()))?; - - let opts - = ListenerOptions::new().name(name); - - let listener - = opts.create_tokio() - .map_err(|e| Error::IpcError(e.to_string()))?; - - Ok(Self { - socket_name, - listener, - }) - } - - pub fn socket_name(&self) -> &str { - &self.socket_name - } - - pub async fn accept_connection(&self) -> Result { - self.listener.accept().await - .map_err(|e| Error::IpcError(e.to_string())) - } - - pub async fn run(self, task_sender: mpsc::Sender) { - loop { - match self.accept_connection().await { - Ok(stream) => { - let sender - = task_sender.clone(); - - tokio::spawn(async move { - if let Err(e) = handle_connection(stream, sender).await { - eprintln!("IPC connection error: {}", e); - } - }); - } - Err(e) => { - eprintln!("IPC accept error: {}", e); - } - } - } - } -} - -async fn handle_connection( - stream: LocalSocketStream, - sender: mpsc::Sender, -) -> Result<(), Error> { - let (recver, mut send) - = stream.split(); - - let mut lines - = BufReader::new(recver).lines(); - - while let Some(line) = lines.next_line().await.map_err(|e| Error::IpcError(e.to_string()))? { - let response - = if let Some(rest) = line.strip_prefix("PUSH ") { - let (task_name, parent_task_id) - = if let Some((task, parent)) = rest.split_once(" FROM ") { - (task.trim().to_string(), Some(parent.trim().to_string())) - } else { - (rest.trim().to_string(), None) - }; - - let (response_tx, response_rx) - = oneshot::channel(); - - let request - = PushRequest { - task_name, - parent_task_id, - response_tx, - }; - - if sender.send(request).await.is_ok() { - match response_rx.await { - Ok(PushResponse::Ok) => "OK\n".to_string(), - Ok(PushResponse::Error(msg)) => format!("ERR {}\n", msg), - Err(_) => "ERR Internal error\n".to_string(), - } - } else { - "ERR Server shutting down\n".to_string() - } - } else { - "ERR Unknown command\n".to_string() - }; - - send.write_all(response.as_bytes()).await - .map_err(|e| Error::IpcError(e.to_string()))?; - - send.flush().await - .map_err(|e| Error::IpcError(e.to_string()))?; - } - - Ok(()) -} - -pub struct TaskIpcClient { - send: SendHalf, - recv: BufReader, -} - -impl TaskIpcClient { - pub async fn connect() -> Result { - let socket_name - = std::env::var(IPC_SOCKET_ENV) - .map_err(|_| Error::NotInTaskContext)?; - - Self::connect_to(&socket_name).await - } - - pub async fn connect_to(socket_name: &str) -> Result { - let name - = socket_name.to_ns_name::() - .or_else(|_| socket_name.to_fs_name::()) - .map_err(|e| Error::IpcConnectionFailed(e.to_string()))?; - - let stream - = LocalSocketStream::connect(name).await - .map_err(|e| Error::IpcConnectionFailed(e.to_string()))?; - - let (recv, send) - = stream.split(); - - Ok(Self { - send, - recv: BufReader::new(recv), - }) - } - - pub async fn push_task(&mut self, task_name: &str, parent_task_id: Option<&str>) -> Result<(), Error> { - let message - = match parent_task_id { - Some(parent) => format!("PUSH {} FROM {}\n", task_name, parent), - None => format!("PUSH {}\n", task_name), - }; - - self.send.write_all(message.as_bytes()).await - .map_err(|e| Error::IpcError(e.to_string()))?; - - self.send.flush().await - .map_err(|e| Error::IpcError(e.to_string()))?; - - let mut response - = String::new(); - - self.recv.read_line(&mut response).await - .map_err(|e| Error::IpcError(e.to_string()))?; - - if response.starts_with("OK") { - Ok(()) - } else if let Some(msg) = response.strip_prefix("ERR ") { - Err(Error::TaskPushFailed(msg.trim().to_string())) - } else { - Err(Error::IpcError(format!("Unknown response: {}", response.trim()))) - } - } -} diff --git a/packages/zpm/src/lib.rs b/packages/zpm/src/lib.rs index 33f3e45c..d1aed9c4 100644 --- a/packages/zpm/src/lib.rs +++ b/packages/zpm/src/lib.rs @@ -6,6 +6,7 @@ pub mod cache; pub mod commands; pub mod constraints; pub mod content_flags; +pub mod daemon; pub mod descriptor_loose; pub mod diff_finder; pub mod manifest_finder; @@ -18,7 +19,6 @@ pub mod graph; pub mod http_npm; pub mod http; pub mod install; -pub mod ipc; pub mod linker; pub mod lockfile; pub mod manifest; diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index 6b2e4d56..5011e9e0 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -338,6 +338,7 @@ pub struct ScriptEnvironment { node_args: Vec, target_output: TargetOutput, stdin: Option, + signal_delegation: bool, } impl ScriptEnvironment { @@ -349,6 +350,7 @@ impl ScriptEnvironment { node_args: Vec::new(), target_output: TargetOutput::default(), stdin: None, + signal_delegation: false, }; if let Ok(val) = std::env::var("YARNSW_DETECTED_ROOT") { @@ -425,6 +427,19 @@ impl ScriptEnvironment { self } + /// Enables signal delegation mode. + /// + /// When enabled, SIGINT is ignored in the parent process while waiting + /// for child processes to complete. This allows the child to handle + /// the signal and exit gracefully, with its exit code properly propagated. + /// + /// This is useful when the parent is a wrapper (like yarn-switch) that + /// should delegate signal handling to the actual command being run. + pub fn enable_signal_delegation(mut self) -> Self { + self.signal_delegation = true; + self + } + pub fn with_project(mut self, project: &Project) -> Self { self.remove_pnp_loader(); @@ -623,6 +638,15 @@ impl ScriptEnvironment { } } + // If signal delegation is enabled, ignore SIGINT while waiting for the child. + // This allows the child to handle the signal and exit gracefully. + #[cfg(unix)] + let _guard = if self.signal_delegation { + Some(zpm_utils::IgnoreSigint::new()) + } else { + None + }; + let output = match &self.target_output { TargetOutput::Inherit => { Output { @@ -651,6 +675,10 @@ impl ScriptEnvironment { cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); + // Put the child in its own process group so we can kill the entire group if needed + #[cfg(unix)] + cmd.process_group(0); + let mut child = cmd.spawn() .map_err(|e| Error::SpawnFailed { name: program.to_string(), path: self.cwd.clone(), error: Arc::new(Box::new(e)) })?; @@ -698,14 +726,14 @@ impl ScriptEnvironment { /// Spawns a script and returns the running process with piped stdout/stderr. /// Use this when you need to read output incrementally (e.g., for interlaced task output). pub async fn spawn_script(&mut self, script: &str, args: I) -> Result where I: IntoIterator, S: AsRef + ToString { - let mut final_script = script.to_string(); - + // Pass args as bash positional parameters ($1, $2, etc.) + // Format: bash -c "script" yarn-script arg1 arg2 ... + let mut bash_args = vec!["-c".to_string(), script.to_string(), "yarn-script".to_string()]; for arg in args { - final_script.push(' '); - final_script.push_str(&shell_escape(arg.to_string().as_str())); + bash_args.push(arg.to_string()); } - self.spawn_exec("bash", ["-c", &final_script, "yarn-script"]).await + self.spawn_exec("bash", bash_args.iter().map(|s| s.as_str())).await } /// Runs a script with inherited stdio (output goes directly to terminal). @@ -725,6 +753,15 @@ impl ScriptEnvironment { cmd.stderr(std::process::Stdio::inherit()); cmd.stdin(std::process::Stdio::inherit()); + // If signal delegation is enabled, ignore SIGINT while waiting for the child. + // This allows the child to handle the signal and exit gracefully. + #[cfg(unix)] + let _guard = if self.signal_delegation { + Some(zpm_utils::IgnoreSigint::new()) + } else { + None + }; + let status = cmd.status().await .map_err(|e| Error::SpawnFailed { name: "bash".to_string(), path: self.cwd.clone(), error: Arc::new(Box::new(e)) })?; diff --git a/taskfile b/taskfile index 3a3552af..8eb38a89 100644 --- a/taskfile +++ b/taskfile @@ -1,2 +1,3 @@ +@long-lived doc: cd documentation && yarn astro dev diff --git a/tests/acceptance-tests/pkg-tests-core/sources/utils/makeTemporaryEnv.ts b/tests/acceptance-tests/pkg-tests-core/sources/utils/makeTemporaryEnv.ts index c1847e1a..77aa2edd 100644 --- a/tests/acceptance-tests/pkg-tests-core/sources/utils/makeTemporaryEnv.ts +++ b/tests/acceptance-tests/pkg-tests-core/sources/utils/makeTemporaryEnv.ts @@ -7,10 +7,55 @@ import * as tests from './tests'; const {generatePkgDriver} = tests; const {execFile} = exec; +const baseEnv = (nativePath: string, nativeHomePath: string, registryUrl: string, rcEnv: Record, env?: Record) => ({ + [`HOME`]: nativeHomePath, + [`USERPROFILE`]: nativeHomePath, + [`PATH`]: `${nativePath}/bin${delimiter}${process.env.PATH}`, + [`RUST_BACKTRACE`]: `1`, + [`YARN_IS_TEST_ENV`]: `true`, + [`YARN_GLOBAL_FOLDER`]: `${nativePath}/.yarn/global`, + [`YARN_NPM_REGISTRY_SERVER`]: registryUrl, + [`YARN_UNSAFE_HTTP_WHITELIST`]: new URL(registryUrl).hostname, + [`YARN_NODE_DIST_URL`]: `${registryUrl}/node/dist`, + // Otherwise we'd send telemetry event when running tests + [`YARN_ENABLE_TELEMETRY`]: `0`, + // Otherwise snapshots relying on this would break each time it's bumped + [`YARN_CACHE_VERSION_OVERRIDE`]: `0`, + // Otherwise the output isn't stable between runs + [`YARN_ENABLE_PROGRESS_BARS`]: `false`, + [`YARN_ENABLE_TIMERS`]: `false`, + [`FORCE_COLOR`]: `0`, + // Otherwise the output wouldn't be the same on CI vs non-CI + [`YARN_ENABLE_INLINE_BUILDS`]: `false`, + // Otherwise we would more often test the fallback rather than the real logic + [`YARN_PNP_FALLBACK_MODE`]: `none`, + // Otherwise tests fail on systems where this is globally set to true + [`YARN_ENABLE_GLOBAL_CACHE`]: `false`, + // To make sure we can call Git commands + [`GIT_AUTHOR_NAME`]: `John Doe`, + [`GIT_AUTHOR_EMAIL`]: `john.doe@example.org`, + [`GIT_COMMITTER_NAME`]: `John Doe`, + [`GIT_COMMITTER_EMAIL`]: `john.doe@example.org`, + // Older versions of Windows need this set to not have node throw an error + [`NODE_SKIP_PLATFORM_CHECK`]: `1`, + // We don't want the PnP runtime to be accidentally injected + [`NODE_OPTIONS`]: ``, + ...rcEnv, + ...env, +}); + +const getYarnBinaryPath = () => { + return process.env.TEST_BINARY + ?? require.resolve(`${__dirname}/../../../../../target/release/yarn-bin`); +}; + const mte = generatePkgDriver({ getName() { return `yarn`; }, + getYarnBinary() { + return getYarnBinaryPath(); + }, async runDriver( path, [command, ...args], @@ -27,8 +72,7 @@ const mte = generatePkgDriver({ ? [projectFolder] : []; - const yarnBinary = process.env.TEST_BINARY - ?? require.resolve(`${__dirname}/../../../../../target/release/yarn-bin`); + const yarnBinary = getYarnBinaryPath(); const yarnBinaryArgs = yarnBinary.match(/\.[cm]?js$/) ? [process.execPath, yarnBinary] @@ -38,41 +82,54 @@ const mte = generatePkgDriver({ cwd: cwd || path, stdin, env: { - [`HOME`]: nativeHomePath, - [`USERPROFILE`]: nativeHomePath, - [`PATH`]: `${nativePath}/bin${delimiter}${process.env.PATH}`, - [`RUST_BACKTRACE`]: `1`, - [`YARN_IS_TEST_ENV`]: `true`, - [`YARN_GLOBAL_FOLDER`]: `${nativePath}/.yarn/global`, - [`YARN_NPM_REGISTRY_SERVER`]: registryUrl, - [`YARN_UNSAFE_HTTP_WHITELIST`]: new URL(registryUrl).hostname, - [`YARN_NODE_DIST_URL`]: `${registryUrl}/node/dist`, + ...baseEnv(nativePath, nativeHomePath, registryUrl, rcEnv, env), [`YARNSW_DEFAULT`]: process.env.YARNSW_DEFAULT, - // Otherwise we'd send telemetry event when running tests - [`YARN_ENABLE_TELEMETRY`]: `0`, - // Otherwise snapshots relying on this would break each time it's bumped - [`YARN_CACHE_VERSION_OVERRIDE`]: `0`, - // Otherwise the output isn't stable between runs - [`YARN_ENABLE_PROGRESS_BARS`]: `false`, - [`YARN_ENABLE_TIMERS`]: `false`, - [`FORCE_COLOR`]: `0`, - // Otherwise the output wouldn't be the same on CI vs non-CI - [`YARN_ENABLE_INLINE_BUILDS`]: `false`, - // Otherwise we would more often test the fallback rather than the real logic - [`YARN_PNP_FALLBACK_MODE`]: `none`, - // Otherwise tests fail on systems where this is globally set to true - [`YARN_ENABLE_GLOBAL_CACHE`]: `false`, - // To make sure we can call Git commands - [`GIT_AUTHOR_NAME`]: `John Doe`, - [`GIT_AUTHOR_EMAIL`]: `john.doe@example.org`, - [`GIT_COMMITTER_NAME`]: `John Doe`, - [`GIT_COMMITTER_EMAIL`]: `john.doe@example.org`, - // Older versions of Windows need this set to not have node throw an error - [`NODE_SKIP_PLATFORM_CHECK`]: `1`, - // We don't want the PnP runtime to be accidentally injected - [`NODE_OPTIONS`]: ``, - ...rcEnv, - ...env, + }, + }); + + if (process.env.JEST_LOG_SPAWNS) { + console.log(`===== stdout:`); + console.log(res.stdout); + console.log(`===== stderr:`); + console.log(res.stderr); + } + + return res; + }, + async runSwitchDriver( + path, + [command, ...args], + {cwd, execArgv = [], projectFolder, registryUrl, env, stdin, ...config}, + ) { + const rcEnv: Record = {}; + for (const [key, value] of Object.entries(config)) + rcEnv[`YARN_${key.replace(/([A-Z])/g, `_$1`).toUpperCase()}`] = Array.isArray(value) ? value.join(`,`) : value; + + const nativePath = npath.fromPortablePath(path); + const nativeHomePath = npath.dirname(nativePath); + + const cwdArgs = typeof projectFolder !== `undefined` + ? [projectFolder] + : []; + + const switchBinary = process.env.TEST_SWITCH_BINARY + ?? require.resolve(`${__dirname}/../../../../../target/release/yarn`); + + const yarnBinBinary = getYarnBinaryPath(); + + const switchBinaryArgs = switchBinary.match(/\.[cm]?js$/) + ? [process.execPath, switchBinary] + : [switchBinary]; + + const res = await execFile(switchBinaryArgs[0]!, [...execArgv, ...switchBinaryArgs.slice(1), ...cwdArgs, command, ...args], { + cwd: cwd || path, + stdin, + env: { + ...baseEnv(nativePath, nativeHomePath, registryUrl, rcEnv, env), + // Point Yarn Switch to the test registry for downloading Yarn releases + [`YARNSW_NPM_REGISTRY_SERVER`]: registryUrl, + // Use the local yarn-bin as the default when no packageManager field is present + [`YARNSW_DEFAULT`]: `local:${yarnBinBinary}`, }, }); diff --git a/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts b/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts index 80c42100..4bc5fe57 100644 --- a/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts +++ b/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts @@ -77,6 +77,8 @@ export enum RequestType { BulkAdvisories = `bulkAdvisories`, NodeDistIndex = `nodeDistIndex`, NodeDistTarball = `nodeDistTarball`, + YarnSwitchInfo = `yarnSwitchInfo`, + YarnSwitchTarball = `yarnSwitchTarball`, } export type Request = { @@ -119,6 +121,13 @@ export type Request = { } | { type: RequestType.NodeDistTarball; name: string; +} | { + type: RequestType.YarnSwitchInfo; + platform: string; +} | { + type: RequestType.YarnSwitchTarball; + platform: string; + version: string; }; export class Login { @@ -705,6 +714,74 @@ export const startPackageServer = ({type}: {type: keyof typeof packageServerUrls stream.pipeline(tar, gzip, response, () => {}); }, + + async [RequestType.YarnSwitchInfo](parsedRequest, request, response) { + if (parsedRequest.type !== RequestType.YarnSwitchInfo) + throw new Error(`Assertion failed: Invalid request type`); + + const {platform} = parsedRequest; + const name = `@yarnpkg/yarn-${platform}`; + const serverUrl = await startPackageServer(); + + // Return package info with available versions + const data = JSON.stringify({ + name, + versions: { + [`6.0.0`]: { + name, + version: `6.0.0`, + bin: {yarn: `yarn-bin`}, + dist: { + shasum: `fake-shasum-6.0.0`, + tarball: `${serverUrl}/@yarnpkg/yarn-${platform}/-/yarn-${platform}-6.0.0.tgz`, + }, + }, + }, + [`dist-tags`]: { + latest: `6.0.0`, + }, + }); + + response.writeHead(200, {[`Content-Type`]: `application/json`}); + response.end(data); + }, + + async [RequestType.YarnSwitchTarball](parsedRequest, request, response) { + if (parsedRequest.type !== RequestType.YarnSwitchTarball) + throw new Error(`Assertion failed: Invalid request type`); + + const {platform, version} = parsedRequest; + + response.writeHead(200, { + [`Content-Type`]: `application/octet-stream`, + [`Transfer-Encoding`]: `chunked`, + }); + + // Create a fake yarn binary tarball that contains: + // - package/package.json with bin entry + // - package/yarn-bin (executable that outputs version info) + const tar = tarStream.pack(); + + // Add package.json + const packageJson = JSON.stringify({ + name: `@yarnpkg/yarn-${platform}`, + version, + bin: {yarn: `yarn-bin`}, + }); + tar.entry({name: `package/package.json`}, packageJson); + + // Add fake yarn-bin executable + const fakeYarnBin = `#!/usr/bin/env bash +echo "Fake Yarn ${version}" +exit 0 +`; + tar.entry({name: `package/yarn-bin`, mode: 0o755}, fakeYarnBin); + + tar.finalize(); + + const gzip = zlib.createGzip(); + stream.pipeline(tar, gzip, response, () => {}); + }, }; const sendError = (res: ServerResponse, statusCode: number, errorMessage: string): void => { @@ -737,6 +814,19 @@ export const startPackageServer = ({type}: {type: keyof typeof packageServerUrls type: RequestType.NodeDistTarball, name: match[2]!, }; + } else if ((match = url.match(/^\/@yarnpkg\/yarn-([a-z0-9-]+)\/-\/yarn-\1-(.+)\.tgz$/))) { + // Yarn Switch tarball: /@yarnpkg/yarn-{platform}/-/yarn-{platform}-{version}.tgz + return { + type: RequestType.YarnSwitchTarball, + platform: match[1]!, + version: match[2]!, + }; + } else if ((match = url.match(/^\/@yarnpkg\/yarn-([a-z0-9-]+)$/))) { + // Yarn Switch package info: /@yarnpkg/yarn-{platform} + return { + type: RequestType.YarnSwitchInfo, + platform: match[1]!, + }; } else { let registry: {registry: string} | undefined; if ((match = url.match(/^\/registry\/([a-z]+)\//))) { @@ -1018,20 +1108,26 @@ export type Run = (...args: Array | [...Array, Partial) => Promise>; export type RunFunction = ( - {path, run, source}: + {path, run, runSwitch, source, yarnBinary}: { path: PortablePath; run: Run; + runSwitch: Run; source: Source; + yarnBinary: string; } ) => Promise; export const generatePkgDriver = ({ getName, runDriver, + runSwitchDriver, + getYarnBinary, }: { getName: () => string; runDriver: PackageRunDriver; + runSwitchDriver?: PackageRunDriver; + getYarnBinary?: () => string; }): PackageDriver => { const withConfig = (definition: Record): PackageDriver => { const makeTemporaryEnv: PackageDriver = (packageJson, subDefinition, fn) => { @@ -1092,6 +1188,29 @@ export const generatePkgDriver = ({ }; }; + const runSwitch = async (...args: Array) => { + if (!runSwitchDriver) + throw new Error(`runSwitch is not available - no runSwitchDriver was provided`); + + let callDefinition = {}; + + if (args.length > 0 && typeof args[args.length - 1] === `object`) + callDefinition = args.pop(); + + const {stdout, stderr, ...rest} = await runSwitchDriver(path, args, { + registryUrl, + ...definition, + ...subDefinition, + ...callDefinition, + }); + + return { + stdout: cleanup(stdout), + stderr: cleanup(stderr), + ...rest, + }; + }; + const source = async (script: string, callDefinition: Record = {}): Promise> => { const scriptWrapper = ` Promise.resolve().then(async () => ${script}).then(result => { @@ -1129,26 +1248,10 @@ export const generatePkgDriver = ({ } }; + const yarnBinary = getYarnBinary?.() ?? ``; + try { - // To pass [citgm](https://github.com/nodejs/citgm), we need to suppress timeout failures - // So add env variable TEST_IGNORE_TIMEOUT_FAILURES to turn on this suppression - // TODO: investigate whether this is still needed. - if (process.env.TEST_IGNORE_TIMEOUT_FAILURES) { - let timer: NodeJS.Timeout | undefined; - await Promise.race([ - new Promise(resolve => { - // Resolve 1s ahead of the jest timeout - timer = setTimeout(resolve, TEST_TIMEOUT - 1000); - }), - fn!({path, run, source}), - ]).finally(() => { - if (timer) { - clearTimeout(timer); - } - }); - return; - } - await fn!({path, run, source}); + await fn!({path, run, runSwitch, source, yarnBinary}); } catch (error: any) { error.message = `Temporary fixture folder: ${npath.fromPortablePath(path)}\n\n${error.message}`; throw error; diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/cache.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/cache.test.ts new file mode 100644 index 00000000..26712010 --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/cache.test.ts @@ -0,0 +1,58 @@ +describe(`Commands`, () => { + describe(`switch cache`, () => { + test( + `it should cache downloaded versions`, + makeTemporaryEnv({ + packageManager: `yarn@6.0.0`, + }, async ({path, runSwitch}) => { + // First run should download + await expect(runSwitch(`--version`)).resolves.toMatchObject({ + code: 0, + }); + + // Second run should use cache (same result, but faster) + await expect(runSwitch(`--version`)).resolves.toMatchObject({ + code: 0, + stdout: expect.stringContaining(`Fake Yarn 6.0.0`), + }); + }), + ); + + test( + `it should show cache list`, + makeTemporaryEnv({ + packageManager: `yarn@6.0.0`, + }, async ({path, runSwitch}) => { + // Download a version first + await runSwitch(`--version`); + + // Check cache list includes the downloaded version + await expect(runSwitch(`switch`, `cache`)).resolves.toMatchObject({ + code: 0, + stdout: expect.stringContaining(`6.0.0`), + }); + }), + ); + + test( + `it should clear cache`, + makeTemporaryEnv({ + packageManager: `yarn@6.0.0`, + }, async ({path, runSwitch}) => { + // Download a version first + await runSwitch(`--version`); + + // Clear the cache + await expect(runSwitch(`switch`, `cache`, `--clear`)).resolves.toMatchObject({ + code: 0, + }); + + // Cache list should now be empty (or show no versions) + const result = await runSwitch(`switch`, `cache`); + expect(result.code).toBe(0); + // After clearing, either empty or no 6.0.0 + expect(result.stdout).not.toContain(`6.0.0`); + }), + ); + }); +}); diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/daemon.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/daemon.test.ts new file mode 100644 index 00000000..4946ccaf --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/daemon.test.ts @@ -0,0 +1,187 @@ +describe(`Commands`, () => { + describe(`switch daemon`, () => { + test( + `it should list daemons (empty initially)`, + makeTemporaryEnv({}, async ({path, runSwitch}) => { + // First kill all daemons to ensure clean state + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // List should show no daemons + const result = await runSwitch(`switch`, `daemon`); + expect(result.code).toBe(0); + expect(result.stdout).toContain(`No live daemons found`); + }), + ); + + test( + `it should list daemons as JSON`, + makeTemporaryEnv({}, async ({path, runSwitch}) => { + // First kill all daemons to ensure clean state + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // List as JSON should return empty array + const result = await runSwitch(`switch`, `daemon`, `--json`); + expect(result.code).toBe(0); + const daemons = JSON.parse(result.stdout); + expect(daemons).toEqual([]); + }), + ); + + test( + `it should start a daemon for the current project`, + makeTemporaryEnv({}, async ({path, runSwitch, yarnBinary}) => { + // Kill all daemons first + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Link the actual test yarn binary (which has the daemon command) + await runSwitch(`switch`, `link`, yarnBinary); + + // Start daemon + const startResult = await runSwitch(`switch`, `daemon`, `--start`); + expect(startResult.code).toBe(0); + expect(startResult.stdout).toContain(`Started daemon`); + expect(startResult.stdout).toContain(`PID:`); + + // Verify daemon appears in list + const listResult = await runSwitch(`switch`, `daemon`, `--json`); + expect(listResult.code).toBe(0); + const daemons = JSON.parse(listResult.stdout); + expect(daemons.length).toBe(1); + expect(typeof daemons[0].pid).toBe(`number`); + + // Clean up + await runSwitch(`switch`, `daemon`, `--kill-all`); + await runSwitch(`switch`, `unlink`); + }), + ); + + test( + `it should warn when daemon is already running`, + makeTemporaryEnv({}, async ({path, runSwitch, yarnBinary}) => { + // Kill all daemons first + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Link the actual test yarn binary + await runSwitch(`switch`, `link`, yarnBinary); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--start`); + + // Try to start again + const secondStart = await runSwitch(`switch`, `daemon`, `--start`); + expect(secondStart.code).toBe(0); + expect(secondStart.stdout).toContain(`already running`); + + // Clean up + await runSwitch(`switch`, `daemon`, `--kill-all`); + await runSwitch(`switch`, `unlink`); + }), + ); + + test( + `it should kill daemon for current project`, + makeTemporaryEnv({}, async ({path, runSwitch, yarnBinary}) => { + // Kill all daemons first + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Link the actual test yarn binary + await runSwitch(`switch`, `link`, yarnBinary); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--start`); + + // Kill it + const killResult = await runSwitch(`switch`, `daemon`, `--kill`); + expect(killResult.code).toBe(0); + expect(killResult.stdout).toContain(`Stopped daemon`); + + // Verify no daemons + const listResult = await runSwitch(`switch`, `daemon`, `--json`); + expect(listResult.code).toBe(0); + const daemons = JSON.parse(listResult.stdout); + expect(daemons).toEqual([]); + + // Clean up + await runSwitch(`switch`, `unlink`); + }), + ); + + test( + `it should kill all daemons`, + makeTemporaryEnv({}, async ({path, runSwitch, yarnBinary}) => { + // Kill all daemons first + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Link the actual test yarn binary + await runSwitch(`switch`, `link`, yarnBinary); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--start`); + + // Kill all + const killResult = await runSwitch(`switch`, `daemon`, `--kill-all`); + expect(killResult.code).toBe(0); + + // Verify no daemons + const listResult = await runSwitch(`switch`, `daemon`, `--json`); + expect(listResult.code).toBe(0); + const daemons = JSON.parse(listResult.stdout); + expect(daemons).toEqual([]); + + // Clean up + await runSwitch(`switch`, `unlink`); + }), + ); + + test( + `it should handle kill with no running daemon`, + makeTemporaryEnv({}, async ({path, runSwitch}) => { + // Kill all daemons first + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Try to kill when none is running + const killResult = await runSwitch(`switch`, `daemon`, `--kill`); + expect(killResult.code).toBe(0); + expect(killResult.stdout).toContain(`No daemon`); + }), + ); + + test( + `it should send ping and receive pong`, + makeTemporaryEnv({}, async ({path, runSwitch, yarnBinary}) => { + // Kill all daemons first + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Link the actual test yarn binary + await runSwitch(`switch`, `link`, yarnBinary); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--start`); + + // Send ping message + const sendResult = await runSwitch(`switch`, `daemon`, `--send`, `{"type":"ping"}`); + expect(sendResult.code).toBe(0); + const response = JSON.parse(sendResult.stdout); + expect(response.type).toBe(`pong`); + + // Clean up + await runSwitch(`switch`, `daemon`, `--kill-all`); + await runSwitch(`switch`, `unlink`); + }), + ); + + test( + `it should error when sending to non-running daemon`, + makeTemporaryEnv({}, async ({path, runSwitch}) => { + // Kill all daemons first + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Try to send when no daemon is running - should throw + await expect(runSwitch(`switch`, `daemon`, `--send`, `{"type":"ping"}`)).rejects.toMatchObject({ + code: 1, + stdout: expect.stringContaining(`No daemon is running`), + }); + }), + ); + }); +}); diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/proxy.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/proxy.test.ts new file mode 100644 index 00000000..4a015980 --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/proxy.test.ts @@ -0,0 +1,73 @@ +import {npath, ppath, xfs} from '@yarnpkg/fslib'; +import {spawn} from 'child_process'; + +import {RunFunction} from '../../../../pkg-tests-core/sources/utils/tests'; + +function cleanupDaemon(cb: RunFunction): RunFunction { + return async args => { + try { + await cb(args); + } finally { + await args.runSwitch(`switch`, `daemon`, `--kill-all`); + } + }; +} + +describe(`Commands`, () => { + describe(`switch proxy`, () => { + test( + `it should exit with code 0 when a long-lived task receives SIGINT`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, yarnBinary}) => { + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` echo "server-started"`, + ` sleep 60`, + ].join(`\n`)); + + await run(`install`); + + // Spawn the long-lived task + const child = spawn(yarnBinary, [`tasks`, `run`, `server`], { + cwd: npath.fromPortablePath(path), + env: {...process.env}, + stdio: [`ignore`, `pipe`, `pipe`], + }); + + // Wait for the task to start + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timeout waiting for server to start`)); + }, 10000); + + child.stdout?.on(`data`, (data: Buffer) => { + if (data.toString().includes(`server-started`)) { + clearTimeout(timeout); + resolve(); + } + }); + + child.on(`error`, err => { + clearTimeout(timeout); + reject(err); + }); + }); + + // Send SIGINT to the child process + child.kill(`SIGINT`); + + // Wait for the process to exit and check the exit code + const exitCode = await new Promise(resolve => { + child.on(`close`, code => { + resolve(code); + }); + }); + + // The process should exit with code 0, not 130 (SIGINT) + expect(exitCode).toBe(0); + })), + ); + }); +}); diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/version.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/version.test.ts new file mode 100644 index 00000000..07dc5aad --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/version.test.ts @@ -0,0 +1,37 @@ +describe(`Commands`, () => { + describe(`switch`, () => { + test( + `it should show switch version`, + makeTemporaryEnv({}, async ({path, runSwitch}) => { + await expect(runSwitch(`switch`, `--version`)).resolves.toMatchObject({ + code: 0, + stdout: expect.stringMatching(/^\d+\.\d+\.\d+/), + }); + }), + ); + + test( + `it should show switch which`, + makeTemporaryEnv({}, async ({path, runSwitch}) => { + await expect(runSwitch(`switch`, `which`)).resolves.toMatchObject({ + code: 0, + stdout: expect.stringContaining(`yarn`), + }); + }), + ); + + test( + `it should download and run yarn when packageManager is set`, + makeTemporaryEnv({ + packageManager: `yarn@6.0.0`, + }, async ({path, runSwitch}) => { + // This should trigger Yarn Switch to download the fake 6.0.0 release + // and execute it with --version + await expect(runSwitch(`--version`)).resolves.toMatchObject({ + code: 0, + stdout: expect.stringContaining(`Fake Yarn 6.0.0`), + }); + }), + ); + }); +}); diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/push.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/push.test.ts index d6eb5b66..7612ef66 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/push.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/push.test.ts @@ -6,7 +6,7 @@ describe(`Commands`, () => { `it should fail when not running inside a task context`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -14,7 +14,7 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `push`, `build`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `push`, `build`)).rejects.toMatchObject({ code: 1, stdout: expect.stringContaining(`Not running inside a task context`), }); @@ -25,7 +25,7 @@ describe(`Commands`, () => { `it should allow pushing a task from within a running task`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `setup:`, ` echo "setup-done"`, @@ -37,7 +37,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `trigger`); expect(stdout).toContain(`trigger-done`); expect(stdout).toContain(`setup-done`); }), @@ -47,7 +47,7 @@ describe(`Commands`, () => { `it should allow pushing multiple tasks at once`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `task-a:`, ` echo "task-a-done"`, @@ -62,7 +62,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `trigger`); expect(stdout).toContain(`trigger-done`); expect(stdout).toContain(`task-a-done`); expect(stdout).toContain(`task-b-done`); @@ -73,7 +73,7 @@ describe(`Commands`, () => { `it should fail when pushing a nonexistent task`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `trigger:`, ` set -e`, @@ -83,7 +83,7 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `trigger`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `--standalone`, `trigger`)).rejects.toMatchObject({ code: 1, }); }), @@ -93,7 +93,7 @@ describe(`Commands`, () => { `it should wait for pushed tasks to complete before task run exits`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `slow-task:`, ` sleep 0.2 && echo "slow-task-done"`, @@ -105,7 +105,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `trigger`); expect(stdout).toContain(`trigger-done`); expect(stdout).toContain(`slow-task-done`); }), @@ -115,7 +115,7 @@ describe(`Commands`, () => { `it should handle pushed tasks with dependencies`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `dep-task:`, ` echo "dep-task-done"`, @@ -130,7 +130,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `trigger`); expect(stdout).toContain(`trigger-done`); expect(stdout).toContain(`dep-task-done`); expect(stdout).toContain(`main-task-done`); @@ -141,7 +141,7 @@ describe(`Commands`, () => { `it should fail the task run when a pushed task fails`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `failing-task:`, ` echo "about-to-fail"`, @@ -155,7 +155,7 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `trigger`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `--standalone`, `trigger`)).rejects.toMatchObject({ code: 1, }); }), @@ -165,7 +165,7 @@ describe(`Commands`, () => { `it should not run the same task twice when pushed multiple times`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `counter:`, ` echo "counter-ran"`, @@ -178,7 +178,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `trigger`); expect(stdout).toContain(`trigger-done`); const matches = stdout.match(/counter-ran/g); expect(matches).toHaveLength(1); diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/run.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/run.test.ts index 8d8517fa..40f8c02c 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/run.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/run.test.ts @@ -1,4 +1,16 @@ -import {ppath, xfs} from '@yarnpkg/fslib'; +import {ppath, xfs} from '@yarnpkg/fslib'; + +import {RunFunction} from '../../../../pkg-tests-core/sources/utils/tests'; + +function cleanupDaemon(cb: RunFunction): RunFunction { + return async args => { + try { + await cb(args); + } finally { + await args.runSwitch(`switch`, `daemon`, `--kill-all`); + } + }; +} describe(`Commands`, () => { describe(`tasks run`, () => { @@ -6,7 +18,7 @@ describe(`Commands`, () => { `it should run a simple task`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -14,7 +26,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `build`); expect(stdout).toEqual(`building\n`); }), ); @@ -23,7 +35,7 @@ describe(`Commands`, () => { `it should run a task with dependencies in order`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `setup:`, ` echo "setup"`, @@ -34,7 +46,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `build`); expect(stdout).toEqual(`setup\nbuild\n`); }), ); @@ -43,7 +55,7 @@ describe(`Commands`, () => { `it should show prefixes with verbose level 1`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -51,7 +63,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `-v`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `-v`, `build`); expect(stdout).toEqual(`[test-package:build]: building\n`); }), ); @@ -60,7 +72,7 @@ describe(`Commands`, () => { `it should show prologue and epilogue with verbose level 2`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -68,7 +80,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `-vv`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `-vv`, `build`); expect(stdout).toEqual(`[test-package:build]: Process started\n[test-package:build]: building\n[test-package:build]: Process exited (exit code 0)\n`); }), ); @@ -77,7 +89,7 @@ describe(`Commands`, () => { `it should hide dependency output with --silent-dependencies`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `setup:`, ` echo "setup-output"`, @@ -88,16 +100,131 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `--silent-dependencies`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--silent-dependencies`, `build`); expect(stdout).toEqual(`build-output\n`); }), ); + test( + `it should output JSON with --json flag`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `build:`, + ` echo "building"`, + ].join(`\n`)); + + await run(`install`); + + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `build`); + const lines = stdout.trim().split(`\n`); + + expect(lines.length).toBe(3); + + const events = lines.map(line => JSON.parse(line)); + + expect(events[0]).toEqual({ + type: `task-started`, + taskId: `test-package:build`, + }); + expect(events[1]).toEqual({ + type: `output`, + taskId: `test-package:build`, + stream: `stdout`, + line: `building`, + }); + expect(events[2]).toEqual({ + type: `task-completed`, + taskId: `test-package:build`, + exitCode: 0, + }); + }), + ); + + test( + `it should output JSON for stderr with --json flag`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `build:`, + ` echo "error message" >&2`, + ].join(`\n`)); + + await run(`install`); + + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `build`); + const lines = stdout.trim().split(`\n`); + const events = lines.map(line => JSON.parse(line)); + + const outputEvent = events.find(e => e.type === `output`); + expect(outputEvent).toEqual({ + type: `output`, + taskId: `test-package:build`, + stream: `stderr`, + line: `error message`, + }); + }), + ); + + test( + `it should output JSON for task dependencies with --json flag`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `setup:`, + ` echo "setup"`, + ``, + `build: setup`, + ` echo "build"`, + ].join(`\n`)); + + await run(`install`); + + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `build`); + const lines = stdout.trim().split(`\n`); + const events = lines.map(line => JSON.parse(line)); + + // Should have events for both tasks + const taskStartedEvents = events.filter(e => e.type === `task-started`); + const taskCompletedEvents = events.filter(e => e.type === `task-completed`); + + expect(taskStartedEvents.length).toBe(2); + expect(taskCompletedEvents.length).toBe(2); + + // Verify setup runs before build + const setupStartIdx = events.findIndex(e => e.type === `task-started` && e.taskId === `test-package:setup`); + const buildStartIdx = events.findIndex(e => e.type === `task-started` && e.taskId === `test-package:build`); + expect(setupStartIdx).toBeLessThan(buildStartIdx); + }), + ); + + test( + `it should output JSON for failed tasks with --json flag`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `build:`, + ` echo "failing"`, + ` exit 1`, + ].join(`\n`)); + + await run(`install`); + + await expect(runSwitch(`tasks`, `run`, `--standalone`, `--json`, `build`)).rejects.toMatchObject({ + code: 1, + }); + }), + ); + test( `it should show dependency output on failure even with --silent-dependencies`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `setup:`, ` echo "setup-failure-output"`, @@ -109,18 +236,39 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `--silent-dependencies`, `build`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `--standalone`, `--silent-dependencies`, `build`)).rejects.toMatchObject({ stdout: `[test-package:setup]: Process started\n[test-package:setup]: setup-failure-output\n[test-package:setup]: Process exited (exit code 1)\n`, code: 1, }); }), ); + test( + `it should not duplicate target task output on failure with --silent-dependencies`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `build:`, + ` echo "build-output"`, + ` exit 1`, + ].join(`\n`)); + + await run(`install`); + + // Target task output should appear exactly once (streamed live), not duplicated + await expect(runSwitch(`tasks`, `run`, `--standalone`, `--silent-dependencies`, `build`)).rejects.toMatchObject({ + stdout: `build-output\n`, + code: 1, + }); + }), + ); + test( `it should forward yarn run to task run with silent dependencies`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `setup:`, ` echo "setup-output"`, @@ -131,16 +279,16 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`run`, `build`); + const {stdout} = await runSwitch(`run`, `build`); expect(stdout).toEqual(`build-output\n`); - }), + })), ); test( `it should forward yarn run to task run and show verbose output on failure`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `setup:`, ` echo "setup-failure-output"`, @@ -152,18 +300,18 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`run`, `build`)).rejects.toMatchObject({ + await expect(runSwitch(`run`, `build`)).rejects.toMatchObject({ stdout: `[test-package:setup]: Process started\n[test-package:setup]: setup-failure-output\n[test-package:setup]: Process exited (exit code 1)\n`, code: 1, }); - }), + })), ); test( `it should pass arguments to the target task`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `greet:`, ` echo "Hello $1"`, @@ -171,7 +319,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `greet`, `World`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `greet`, `World`); expect(stdout).toEqual(`Hello World\n`); }), ); @@ -180,7 +328,7 @@ describe(`Commands`, () => { `it should fail when the task does not exist`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -188,7 +336,7 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `nonexistent`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `--standalone`, `nonexistent`)).rejects.toMatchObject({ code: 1, }); }), @@ -198,10 +346,10 @@ describe(`Commands`, () => { `it should fail when there is no taskfile`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await run(`install`); - await expect(run(`tasks`, `run`, `build`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `--standalone`, `build`)).rejects.toMatchObject({ code: 1, }); }), @@ -211,7 +359,7 @@ describe(`Commands`, () => { `it should run parallel dependencies concurrently`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `task-a:`, ` sleep 0.1 && echo "task-a"`, @@ -225,7 +373,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `build`); const lines = stdout.trim().split(`\n`); expect(lines).toHaveLength(3); @@ -242,7 +390,7 @@ describe(`Commands`, () => { }, { [`packages/pkg-a`]: {name: `pkg-a`}, [`packages/pkg-b`]: {name: `pkg-b`, dependencies: {[`pkg-a`]: `workspace:*`}}, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `packages/pkg-a/taskfile` as any), [ `build:`, ` echo "building-pkg-a"`, @@ -255,7 +403,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `build`, {cwd: ppath.join(path, `packages/pkg-b` as any)}); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `build`, {cwd: ppath.join(path, `packages/pkg-b` as any)}); expect(stdout).toEqual(`building-pkg-a\nbuilding-pkg-b\n`); }), ); @@ -268,7 +416,7 @@ describe(`Commands`, () => { }, { [`packages/pkg-a`]: {name: `pkg-a`}, [`packages/pkg-b`]: {name: `pkg-b`, dependencies: {[`pkg-a`]: `workspace:*`}}, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `packages/pkg-a/taskfile` as any), [ `build:`, ` echo "building-pkg-a"`, @@ -281,7 +429,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `--silent-dependencies`, `build`, {cwd: ppath.join(path, `packages/pkg-b` as any)}); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--silent-dependencies`, `build`, {cwd: ppath.join(path, `packages/pkg-b` as any)}); expect(stdout).toEqual(`building-pkg-b\n`); }), ); @@ -290,7 +438,7 @@ describe(`Commands`, () => { `it should hide pushed subtask output with --silent-dependencies`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `subtask:`, ` echo "subtask-output"`, @@ -302,7 +450,7 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `--silent-dependencies`, `main`); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--silent-dependencies`, `main`); expect(stdout).toEqual(`main-output\n`); }), ); @@ -311,7 +459,7 @@ describe(`Commands`, () => { `it should return the exit code of the failed task`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` exit 42`, @@ -319,10 +467,2046 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `build`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `--standalone`, `build`)).rejects.toMatchObject({ code: 42, }); }), ); + + test( + `it should re-run the same task when called multiple times`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + const counterFile = ppath.join(path, `counter`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `build:`, + ` count=$(cat counter 2>/dev/null || echo 0)`, + ` count=$((count + 1))`, + ` echo $count > counter`, + ` echo "run $count"`, + ].join(`\n`)); + + await run(`install`); + + const {stdout: stdout1} = await runSwitch(`tasks`, `run`, `--standalone`, `build`); + expect(stdout1).toEqual(`run 1\n`); + + const {stdout: stdout2} = await runSwitch(`tasks`, `run`, `--standalone`, `build`); + expect(stdout2).toEqual(`run 2\n`); + + const {stdout: stdout3} = await runSwitch(`tasks`, `run`, `--standalone`, `build`); + expect(stdout3).toEqual(`run 3\n`); + }), + ); + + test( + `it should stream log lines in real-time`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Create a task that outputs lines with delays and includes script-side timestamps + // Use Python for cross-platform millisecond timestamps + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `stream-test:`, + ` python3 -c "import time; print(f'ts:{int(time.time()*1000)}:line1')"`, + ` sleep 0.5`, + ` python3 -c "import time; print(f'ts:{int(time.time()*1000)}:line2')"`, + ` sleep 0.5`, + ` python3 -c "import time; print(f'ts:{int(time.time()*1000)}:line3')"`, + ].join(`\n`)); + + await run(`install`); + + // Measure total execution time + const startTime = Date.now(); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `stream-test`); + const endTime = Date.now(); + const totalTime = endTime - startTime; + + // Parse timestamps from script output + // Format: ts:1234567890123:lineN + const timestampRegex = /^ts:(\d+):(.+)$/; + const lines = stdout.trim().split(`\n`); + + expect(lines.length).toBe(3); + + const timestamps: Array = []; + const messages: Array = []; + + for (const line of lines) { + const match = line.match(timestampRegex); + expect(match).not.toBeNull(); + if (match?.[1] && match[2]) { + timestamps.push(parseInt(match[1], 10)); + messages.push(match[2]); + } + } + + // Verify the messages are correct + expect(messages).toEqual([`line1`, `line2`, `line3`]); + + // Verify script-side timestamps are properly spaced (at least 400ms apart) + // This proves the script's sleep commands executed between echo statements + for (let i = 1; i < timestamps.length; i++) { + const diff = timestamps[i]! - timestamps[i - 1]!; + expect(diff).toBeGreaterThanOrEqual(400); + } + + // Verify total execution time is reasonable (at least 900ms for two 500ms sleeps) + // This proves output wasn't queued and released at the end + expect(totalTime).toBeGreaterThanOrEqual(900); + }), + ); + + describe(`@long-lived tasks`, () => { + test( + `it should unblock dependents after warm-up period`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Create a long-lived task (simulates a dev server) and a dependent task + // The dependent should start after 500ms warm-up, not wait for server to exit + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` echo "server-started"`, + ` sleep 10`, + ``, + `client: server`, + ` echo "client-started"`, + ].join(`\n`)); + + await run(`install`); + + // Run the client task - it should complete quickly after warm-up + // even though the server would take 10 seconds if we waited for it + const startTime = Date.now(); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `client`); + const endTime = Date.now(); + const totalTime = endTime - startTime; + + // Should complete in under 3 seconds (warm-up is 500ms + some overhead) + // If it waited for server, it would take 10+ seconds + expect(totalTime).toBeLessThan(3000); + expect(stdout).toContain(`client-started`); + }), + ); + + test( + `it should attach to existing long-lived task on second invocation`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Create a long-lived task that writes to a file on each start + const counterFile = ppath.join(path, `server-starts`); + await xfs.writeFilePromise(counterFile, `0`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` count=$(cat server-starts)`, + ` count=$((count + 1))`, + ` echo $count > server-starts`, + ` echo "server-start-$count"`, + ` sleep 10`, + ].join(`\n`)); + + await run(`install`); + + // Start the server first time in background (we'll detach via timeout) + const serverPromise1 = runSwitch(`tasks`, `run`, `server`).catch(() => {}); + + // Wait for warm-up + await new Promise(resolve => setTimeout(resolve, 700)); + + // Second invocation should attach to existing, not start new + const serverPromise2 = runSwitch(`tasks`, `run`, `server`).catch(() => {}); + + // Wait a bit for the second command to complete its attach + await new Promise(resolve => setTimeout(resolve, 300)); + + // Check that server only started once + const startCount = await xfs.readFilePromise(counterFile, `utf8`); + expect(startCount.trim()).toEqual(`1`); + + // Clean up + await runSwitch(`tasks`, `stop`, `server`).catch(() => {}); + })), + ); + + test( + `it should allow stopping a long-lived task`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + const pidFile = ppath.join(path, `server.pid`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` echo $$ > server.pid`, + ` echo "server-running"`, + ` sleep 60`, + ].join(`\n`)); + + await run(`install`); + + // Start the server in background + const serverPromise = runSwitch(`tasks`, `run`, `server`).catch(() => {}); + + // Wait for warm-up and pid file to be written + await new Promise(resolve => setTimeout(resolve, 700)); + + // Verify server is running (pid file exists) + const pidExists = await xfs.existsPromise(pidFile); + expect(pidExists).toBe(true); + + // Stop the server + const {stdout: stopOutput} = await runSwitch(`tasks`, `stop`, `server`); + expect(stopOutput).toContain(`stopped successfully`); + + // Wait a bit for process cleanup + await new Promise(resolve => setTimeout(resolve, 200)); + })), + ); + + test( + `it should continue running after client disconnects`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + const markerFile = ppath.join(path, `still-running`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` echo "server-started"`, + ` sleep 1`, + ` echo "still-running" > still-running`, + ` sleep 10`, + ].join(`\n`)); + + await run(`install`); + + // Start server and simulate client disconnect by using a short timeout + // We use Promise.race to simulate the client disconnecting + await Promise.race([ + runSwitch(`tasks`, `run`, `server`).catch(() => {}), + new Promise(resolve => setTimeout(resolve, 700)), + ]); + + // Wait for the marker file to be created (proves server continued running) + await new Promise(resolve => setTimeout(resolve, 800)); + + const markerExists = await xfs.existsPromise(markerFile); + expect(markerExists).toBe(true); + + // Clean up + await runSwitch(`tasks`, `stop`, `server`).catch(() => {}); + })), + ); + + test( + `it should use fixed context ID for long-lived tasks`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Create two separate short-lived tasks and verify they get different context IDs + // Then verify long-lived tasks always get the same fixed context ID + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` echo "server: $ZPM_TASK_CURRENT"`, + ` sleep 5`, + ].join(`\n`)); + + await run(`install`); + + // Start server first time + const server1Promise = runSwitch(`tasks`, `run`, `-v`, `server`).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 700)); + + // Get output from first invocation + // The context ID should be the fixed long-lived context ID + // 4d84fea4-e0d4-4df6-8190-f312b86968b3 + + // Start second invocation - should attach to same task + const server2Promise = runSwitch(`tasks`, `run`, `-v`, `server`).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 300)); + + // Stop and clean up + await runSwitch(`tasks`, `stop`, `server`).catch(() => {}); + })), + ); + + test( + `it should use daemon (not standalone) for long-lived tasks via yarn run`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This test verifies that `yarn run ` (the implicit path through + // TaskRunSilentDependencies::new) uses the Switch daemon rather than + // spawning an ephemeral standalone daemon. If standalone mode is + // incorrectly used, the long-lived task dies when the first command + // exits, so the second invocation would start a new process instead + // of attaching to the existing one. + + const counterFile = ppath.join(path, `server-starts`); + await xfs.writeFilePromise(counterFile, `0`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` count=$(cat server-starts)`, + ` count=$((count + 1))`, + ` echo $count > server-starts`, + ` echo "server-start-$count"`, + ` sleep 10`, + ].join(`\n`)); + + await run(`install`); + + // First invocation via `yarn run` (the implicit path). + // This goes through TaskRunSilentDependencies::new() which computes + // `standalone` from environment variables. + const server1 = runSwitch(`run`, `server`).catch(() => {}); + + // Wait for warm-up + script execution + await new Promise(resolve => setTimeout(resolve, 1200)); + + // Second invocation via `yarn run` should attach to the same + // daemon-managed long-lived task, not start a new one. + const server2 = runSwitch(`run`, `server`).catch(() => {}); + + await new Promise(resolve => setTimeout(resolve, 500)); + + // If the daemon was used correctly, the server was started only once. + // If standalone mode was incorrectly triggered, the first ephemeral + // daemon died after server1 settled, and server2 would have spawned + // a fresh process (count = 2). + const startCount = await xfs.readFilePromise(counterFile, `utf8`); + expect(startCount.trim()).toEqual(`1`); + + // Clean up + await runSwitch(`tasks`, `stop`, `server`).catch(() => {}); + })), + ); + + test( + `it should not double-spawn a long-lived dependency resolved transitively`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Regression test: when task A depends on long-lived task B, pushing + // only A should not spawn two instances of B. Previously, add_task + // would prepare B under A's context (B@) in addition to the + // long-lived context (B@__long_lived__), causing process_ready_tasks + // to spawn both. + const counterFile = ppath.join(path, `server-starts`); + await xfs.writeFilePromise(counterFile, `0`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` count=$(cat server-starts)`, + ` count=$((count + 1))`, + ` echo $count > server-starts`, + ` echo "server-start-$count"`, + ` sleep 10`, + ``, + `client: server`, + ` echo "client-done"`, + ].join(`\n`)); + + await run(`install`); + + // Push only "client" - server is pulled in as a transitive dependency. + // It should start exactly once under the long-lived context. + const clientResult = await runSwitch(`tasks`, `run`, `client`); + + // Wait a bit to let any duplicate spawn settle + await new Promise(resolve => setTimeout(resolve, 500)); + + const startCount = await xfs.readFilePromise(counterFile, `utf8`); + expect(startCount.trim()).toEqual(`1`); + + expect(clientResult.stdout).toContain(`client-done`); + + // Clean up + await runSwitch(`tasks`, `stop`, `server`).catch(() => {}); + })), + ); + + test( + `it should allow re-running a long-lived task after stopping it`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Regression test for stop_long_lived double-cleanup: stop_long_lived + // calls close_task (evicting output, removing registries) before the + // process has actually exited. When the process later exits, + // task_script_finished runs close_task a second time. This could + // corrupt state such that re-starting the same long-lived task fails + // or produces ghost entries. + const counterFile = ppath.join(path, `server-starts`); + await xfs.writeFilePromise(counterFile, `0`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` count=$(cat server-starts)`, + ` count=$((count + 1))`, + ` echo $count > server-starts`, + ` echo "server-running-$count"`, + ` sleep 60`, + ].join(`\n`)); + + await run(`install`); + + // Start → warm-up → stop → restart cycle + const run1 = runSwitch(`tasks`, `run`, `server`).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 1200)); + + // Stop the server + const {stdout: stopOutput} = await runSwitch(`tasks`, `stop`, `server`); + expect(stopOutput).toContain(`stopped successfully`); + + // Wait for process to actually die and TaskCompleted to be processed + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Verify the long-lived registry is clean (tasks list should be empty) + const {stdout: listOutput1} = await runSwitch(`tasks`, `--json`); + const tasks1 = listOutput1.trim() === `` ? [] : listOutput1.trim().split(`\n`).map((l: string) => JSON.parse(l)); + + // After stop + process death, the long-lived registry should be clean. + expect(tasks1).toHaveLength(0); + + // Re-start the same long-lived task — should work cleanly. + // If stop_long_lived corrupted state (double close_task eviction, + // stale graph entries under LONG_LIVED_CONTEXT_ID), this second + // run will either fail silently or not actually start a new process. + const run2 = runSwitch(`tasks`, `run`, `server`).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Verify server was started a second time (count == 2). + // If stop_long_lived left the task in a terminal state in the graph + // under LONG_LIVED_CONTEXT_ID, add_task cannot re-add it and the + // second invocation silently fails — counter stays at 1. + const startCount = await xfs.readFilePromise(counterFile, `utf8`); + expect(startCount.trim()).toEqual(`2`); + + // Clean up + await runSwitch(`tasks`, `stop`, `server`).catch(() => {}); + })), + ); + + test( + `it should fail dependents if long-lived task fails before warm-up`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Create a long-lived task that exits immediately (before 500ms warm-up) + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` echo "server-failed"`, + ` exit 1`, + ``, + `client: server`, + ` echo "client-started"`, + ].join(`\n`)); + + await run(`install`); + + // Run the client task - it should fail because server failed before warm-up + await expect(runSwitch(`tasks`, `run`, `--standalone`, `client`)).rejects.toMatchObject({ + code: 1, + }); + }), + ); + }); + + describe(`dependency resolution`, () => { + test( + `it should handle diamond dependency pattern correctly`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Diamond pattern: target depends on B and C, both depend on D + // D should only run once, not twice + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `task-d:`, + ` echo "task-d"`, + ``, + `task-b: task-d`, + ` echo "task-b"`, + ``, + `task-c: task-d`, + ` echo "task-c"`, + ``, + `target: task-b task-c`, + ` echo "target"`, + ].join(`\n`)); + + await run(`install`); + + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `target`); + const events = stdout.trim().split(`\n`).map(line => JSON.parse(line)); + + // D should be started exactly once + const taskDStarted = events.filter(e => e.type === `task-started` && e.taskId === `test-package:task-d`); + expect(taskDStarted.length).toBe(1); + + // All tasks should complete exactly once + const completedTasks = events.filter(e => e.type === `task-completed`).map(e => e.taskId); + expect(completedTasks.sort()).toEqual([ + `test-package:target`, + `test-package:task-b`, + `test-package:task-c`, + `test-package:task-d`, + ]); + + // D should start before B and C + const startEvents = events.filter(e => e.type === `task-started`); + const dStartIdx = startEvents.findIndex(e => e.taskId === `test-package:task-d`); + const bStartIdx = startEvents.findIndex(e => e.taskId === `test-package:task-b`); + const cStartIdx = startEvents.findIndex(e => e.taskId === `test-package:task-c`); + expect(dStartIdx).toBeLessThan(bStartIdx); + expect(dStartIdx).toBeLessThan(cStartIdx); + }), + ); + + test( + `it should handle deep transitive dependency chains`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Create a chain: level-5 -> level-4 -> level-3 -> level-2 -> level-1 + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `level-1:`, + ` echo "level-1"`, + ``, + `level-2: level-1`, + ` echo "level-2"`, + ``, + `level-3: level-2`, + ` echo "level-3"`, + ``, + `level-4: level-3`, + ` echo "level-4"`, + ``, + `level-5: level-4`, + ` echo "level-5"`, + ].join(`\n`)); + + await run(`install`); + + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `level-5`); + const events = stdout.trim().split(`\n`).map(line => JSON.parse(line)); + + // Extract task-started events in order + const startOrder = events + .filter(e => e.type === `task-started`) + .map(e => e.taskId.replace(`test-package:`, ``)); + + // Should start in correct dependency order + expect(startOrder).toEqual([ + `level-1`, + `level-2`, + `level-3`, + `level-4`, + `level-5`, + ]); + }), + ); + + test( + `it should handle mixed parallel and sequential dependencies`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Create: target -> (a&, b&) -> c (sequential) + // So a and b run in parallel after c completes + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `dep-c:`, + ` echo "dep-c"`, + ``, + `dep-a: dep-c`, + ` sleep 0.1 && echo "dep-a"`, + ``, + `dep-b: dep-c`, + ` sleep 0.1 && echo "dep-b"`, + ``, + `target: dep-a& dep-b&`, + ` echo "target"`, + ].join(`\n`)); + + await run(`install`); + + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `target`); + const events = stdout.trim().split(`\n`).map(line => JSON.parse(line)); + + const startEvents = events.filter(e => e.type === `task-started`); + const completedEvents = events.filter(e => e.type === `task-completed`); + + // dep-c must start first + expect(startEvents[0].taskId).toBe(`test-package:dep-c`); + + // dep-c must complete before dep-a and dep-b start + const cCompletedIdx = events.findIndex(e => e.type === `task-completed` && e.taskId === `test-package:dep-c`); + const aStartIdx = events.findIndex(e => e.type === `task-started` && e.taskId === `test-package:dep-a`); + const bStartIdx = events.findIndex(e => e.type === `task-started` && e.taskId === `test-package:dep-b`); + expect(cCompletedIdx).toBeLessThan(aStartIdx); + expect(cCompletedIdx).toBeLessThan(bStartIdx); + + // target must complete last + expect(completedEvents[completedEvents.length - 1].taskId).toBe(`test-package:target`); + }), + ); + }); + + describe(`error handling and failure propagation`, () => { + test( + `it should fail all pending dependents when a task fails`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Create: target -> middle -> failing-base + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `failing-base:`, + ` echo "failing"`, + ` exit 1`, + ``, + `middle: failing-base`, + ` echo "middle-should-not-run"`, + ``, + `target: middle`, + ` echo "target-should-not-run"`, + ].join(`\n`)); + + await run(`install`); + + const result = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `target`).catch(e => e); + expect(result.code).toBe(1); + + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // Only failing-base should have started + const startedTasks = events.filter((e: any) => e.type === `task-started`).map((e: any) => e.taskId); + expect(startedTasks).toEqual([`test-package:failing-base`]); + + // middle and target should never have started + expect(startedTasks).not.toContain(`test-package:middle`); + expect(startedTasks).not.toContain(`test-package:target`); + + // failing-base should have completed with non-zero exit code + const failingBaseCompleted = events.find((e: any) => e.type === `task-completed` && e.taskId === `test-package:failing-base`); + expect(failingBaseCompleted).toBeDefined(); + expect(failingBaseCompleted.exitCode).toBe(1); + }), + ); + + test( + `it should fail parallel siblings when one fails`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Create: target -> (fast-fail&, slow-success&) + // fast-fail should cause target to fail even though slow-success might complete + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `fast-fail:`, + ` echo "fast-fail"`, + ` exit 1`, + ``, + `slow-success:`, + ` sleep 0.5 && echo "slow-success"`, + ``, + `target: fast-fail& slow-success&`, + ` echo "target-should-not-run"`, + ].join(`\n`)); + + await run(`install`); + + const result = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `target`).catch(e => e); + expect(result.code).toBe(1); + + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // target should never have started (its dependency failed) + const startedTasks = events.filter((e: any) => e.type === `task-started`).map((e: any) => e.taskId); + expect(startedTasks).not.toContain(`test-package:target`); + }), + ); + + test( + `it should propagate pushed subtask failure to parent`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `failing-subtask:`, + ` echo "subtask-failing"`, + ` exit 42`, + ``, + `parent:`, + ` echo "parent-start"`, + ` yarn tasks push failing-subtask`, + ` echo "parent-end"`, + ].join(`\n`)); + + await run(`install`); + + await expect(runSwitch(`tasks`, `run`, `--standalone`, `parent`)).rejects.toMatchObject({ + code: 42, + }); + }), + ); + + test( + `it should handle multiple pushed subtasks with one failure`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `subtask-ok:`, + ` echo "subtask-ok"`, + ``, + `subtask-fail:`, + ` echo "subtask-fail"`, + ` exit 1`, + ``, + `parent:`, + ` echo "parent-start"`, + ` yarn tasks push subtask-ok`, + ` yarn tasks push subtask-fail`, + ` echo "parent-end"`, + ].join(`\n`)); + + await run(`install`); + + await expect(runSwitch(`tasks`, `run`, `--standalone`, `parent`)).rejects.toMatchObject({ + code: 1, + }); + }), + ); + + test( + `it should report correct exit code when target task directly fails`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // When the target task itself fails, its exit code should be preserved + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `target:`, + ` exit 77`, + ].join(`\n`)); + + await run(`install`); + + await expect(runSwitch(`tasks`, `run`, `--standalone`, `target`)).rejects.toMatchObject({ + code: 77, + }); + }), + ); + + test( + `it should fail with code 1 when a dependency fails`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // When a dependency fails (not the target), the exit code is normalized to 1 + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `deep-fail:`, + ` exit 77`, + ``, + `target: deep-fail`, + ` echo "should-not-run"`, + ].join(`\n`)); + + await run(`install`); + + await expect(runSwitch(`tasks`, `run`, `--standalone`, `target`)).rejects.toMatchObject({ + code: 1, + }); + }), + ); + }); + + describe(`concurrent operations`, () => { + test( + `it should handle concurrent requests for same long-lived task`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This tests the race condition fix - multiple concurrent requests + // for the same long-lived task should not start multiple instances + const counterFile = ppath.join(path, `start-counter`); + await xfs.writeFilePromise(counterFile, `0`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` count=$(cat start-counter)`, + ` count=$((count + 1))`, + ` echo $count > start-counter`, + ` echo "server-$count"`, + ` sleep 10`, + ].join(`\n`)); + + await run(`install`); + + // Explicitly start daemon first to ensure all requests use the same daemon + await runSwitch(`switch`, `daemon`, `--open`); + + // Add a small delay to ensure daemon is fully ready + await new Promise(resolve => setTimeout(resolve, 200)); + + // Fire 3 concurrent requests for the same long-lived task + const promises = [ + runSwitch(`tasks`, `run`, `server`).catch(() => {}), + runSwitch(`tasks`, `run`, `server`).catch(() => {}), + runSwitch(`tasks`, `run`, `server`).catch(() => {}), + ]; + + // Wait for warm-up and some processing time + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Check that server only started once + const startCount = await xfs.readFilePromise(counterFile, `utf8`); + expect(startCount.trim()).toBe(`1`); + + // Clean up + await runSwitch(`tasks`, `stop`, `server`).catch(() => {}); + })), + ); + + test( + `it should handle concurrent different tasks without interference`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Run multiple independent tasks concurrently + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `task-a:`, + ` sleep 0.2 && echo "task-a-done"`, + ``, + `task-b:`, + ` sleep 0.2 && echo "task-b-done"`, + ``, + `task-c:`, + ` sleep 0.2 && echo "task-c-done"`, + ].join(`\n`)); + + await run(`install`); + + // Run all three concurrently + const [resultA, resultB, resultC] = await Promise.all([ + runSwitch(`tasks`, `run`, `--standalone`, `task-a`), + runSwitch(`tasks`, `run`, `--standalone`, `task-b`), + runSwitch(`tasks`, `run`, `--standalone`, `task-c`), + ]); + + expect(resultA.stdout.trim()).toBe(`task-a-done`); + expect(resultB.stdout.trim()).toBe(`task-b-done`); + expect(resultC.stdout.trim()).toBe(`task-c-done`); + }), + ); + + test( + `it should isolate contexts between concurrent executions`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Two concurrent executions of the same task graph should use separate contexts + const outputFile = ppath.join(path, `output`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `setup:`, + ` echo "setup-$(date +%s%N)" >> output`, + ``, + `build: setup`, + ` echo "build-$(date +%s%N)" >> output`, + ].join(`\n`)); + + await run(`install`); + + // Clear output file + await xfs.writeFilePromise(outputFile, ``); + + // Run the same task twice concurrently (not using --standalone so they share daemon) + const [result1, result2] = await Promise.all([ + runSwitch(`tasks`, `run`, `build`), + runSwitch(`tasks`, `run`, `build`), + ]); + + // Both should succeed + expect(result1.code).toBe(0); + expect(result2.code).toBe(0); + + // Check that setup ran twice (once per context) + const output = await xfs.readFilePromise(outputFile, `utf8`); + const setupLines = output.trim().split(`\n`).filter(l => l.startsWith(`setup-`)); + expect(setupLines.length).toBe(2); + })), + ); + }); + + describe(`scalability`, () => { + test( + `it should handle a large dependency graph efficiently`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Create a graph with 21 tasks: 10 leaf tasks, 5 mid-level, 3 second-level, 2 top-level, 1 root + // This tests the scheduler's ability to handle complex graphs + const tasks = [ + // 10 leaf tasks (no dependencies) + ...Array.from({length: 10}, (_, i) => `leaf-${i}:\n echo "leaf-${i}"`), + ``, + // 5 mid-level tasks (each depends on 2 leaf tasks) + ...Array.from({length: 5}, (_, i) => + `mid-${i}: leaf-${i * 2} leaf-${i * 2 + 1}\n echo "mid-${i}"`, + ), + ``, + // 3 second-level tasks + `second-0: mid-0 mid-1\n echo "second-0"`, + `second-1: mid-2 mid-3\n echo "second-1"`, + `second-2: mid-4\n echo "second-2"`, + ``, + // 2 top-level tasks + `top-0: second-0 second-1\n echo "top-0"`, + `top-1: second-2\n echo "top-1"`, + ``, + // Root task + `root: top-0 top-1\n echo "root"`, + ]; + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), tasks.join(`\n`)); + + await run(`install`); + + const startTime = Date.now(); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `root`); + const elapsed = Date.now() - startTime; + + const lines = stdout.trim().split(`\n`); + + // All 21 tasks should have run + expect(lines.length).toBe(21); + + // Root should be last + expect(lines[lines.length - 1]).toBe(`root`); + + // Each mid task should appear after its specific leaf dependencies + // mid-0 depends on leaf-0 and leaf-1, mid-1 depends on leaf-2 and leaf-3, etc. + const getIndex = (name: string) => lines.indexOf(name); + + for (let i = 0; i < 5; i++) { + const midIndex = getIndex(`mid-${i}`); + const leaf1Index = getIndex(`leaf-${i * 2}`); + const leaf2Index = getIndex(`leaf-${i * 2 + 1}`); + + expect(midIndex).toBeGreaterThan(leaf1Index); + expect(midIndex).toBeGreaterThan(leaf2Index); + } + + // Should complete in reasonable time (under 5 seconds for simple echo commands) + expect(elapsed).toBeLessThan(5000); + }), + ); + + test( + `it should handle many parallel tasks`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Create 10 parallel tasks that all run concurrently + const parallelCount = 10; + const tasks = [ + ...Array.from({length: parallelCount}, (_, i) => + `parallel-${i}:\n sleep 0.2 && echo "parallel-${i}"`, + ), + ``, + `root: ${Array.from({length: parallelCount}, (_, i) => `parallel-${i}&`).join(` `)}\n echo "root"`, + ]; + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), tasks.join(`\n`)); + + await run(`install`); + + const startTime = Date.now(); + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `root`); + const elapsed = Date.now() - startTime; + + const lines = stdout.trim().split(`\n`); + + // All parallel tasks + root should run + expect(lines.length).toBe(parallelCount + 1); + + // Root should be last + expect(lines[lines.length - 1]).toBe(`root`); + + // Since tasks run in parallel (0.2s each), total time should be much less than 10 * 0.2s = 2s + // Allow some overhead, but it should be under 1.5 seconds if parallel + expect(elapsed).toBeLessThan(1500); + }), + ); + }); + + describe(`task cancellation semantics`, () => { + test( + `it should output task-cancelled in JSON when dependency fails (task never started)`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // When a dependency fails, dependent tasks should be cancelled (not failed) + // because they never actually started + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `failing-dep:`, + ` echo "failing"`, + ` exit 1`, + ``, + `dependent: failing-dep`, + ` echo "should-never-run"`, + ].join(`\n`)); + + await run(`install`); + + const result = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `dependent`).catch(e => e); + expect(result.code).toBe(1); + + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // The failing-dep task should have completed with exit code 1 + const failingDepCompleted = events.find((e: any) => e.type === `task-completed` && e.taskId === `test-package:failing-dep`); + expect(failingDepCompleted).toBeDefined(); + expect(failingDepCompleted.exitCode).toBe(1); + + // The dependent task should be cancelled (not started, not failed) + const dependentCancelled = events.find((e: any) => e.type === `task-cancelled` && e.taskId === `test-package:dependent`); + expect(dependentCancelled).toBeDefined(); + + // The dependent task should NOT have a task-started event + const dependentStarted = events.find((e: any) => e.type === `task-started` && e.taskId === `test-package:dependent`); + expect(dependentStarted).toBeUndefined(); + + // The dependent task should NOT have a task-completed event + const dependentCompleted = events.find((e: any) => e.type === `task-completed` && e.taskId === `test-package:dependent`); + expect(dependentCompleted).toBeUndefined(); + }), + ); + + test( + `it should output task-completed with exitCode in JSON when task itself fails`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // When a task itself fails (not its dependency), it should show task-completed + // with the actual exit code + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `failing-task:`, + ` echo "running"`, + ` exit 42`, + ].join(`\n`)); + + await run(`install`); + + const result = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `failing-task`).catch(e => e); + expect(result.code).toBe(42); + + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // The task should have started + const started = events.find((e: any) => e.type === `task-started` && e.taskId === `test-package:failing-task`); + expect(started).toBeDefined(); + + // The task should have completed with the actual exit code (not cancelled) + const completed = events.find((e: any) => e.type === `task-completed` && e.taskId === `test-package:failing-task`); + expect(completed).toBeDefined(); + expect(completed.exitCode).toBe(42); + + // Should NOT have a task-cancelled event + const cancelled = events.find((e: any) => e.type === `task-cancelled` && e.taskId === `test-package:failing-task`); + expect(cancelled).toBeUndefined(); + }), + ); + + test( + `it should cancel multiple pending dependents when a task fails`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Multiple tasks waiting on the same failing dependency should all be cancelled + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `failing-base:`, + ` exit 1`, + ``, + `dep-a: failing-base`, + ` echo "a"`, + ``, + `dep-b: failing-base`, + ` echo "b"`, + ``, + `target: dep-a dep-b`, + ` echo "target"`, + ].join(`\n`)); + + await run(`install`); + + const result = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `target`).catch(e => e); + expect(result.code).toBe(1); + + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // All dependent tasks should be cancelled + const cancelledTasks = events + .filter((e: any) => e.type === `task-cancelled`) + .map((e: any) => e.taskId) + .sort(); + + expect(cancelledTasks).toContain(`test-package:dep-a`); + expect(cancelledTasks).toContain(`test-package:dep-b`); + expect(cancelledTasks).toContain(`test-package:target`); + }), + ); + + test( + `it should cancel no-script aggregator tasks when context is cancelled`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Regression test: cancel_context only iterated prepared.keys(), + // but tasks with no script (pure dependency aggregators) exist in + // graph.tasks without a prepared entry. If cancel_context misses + // them, they complete normally after the subscriber is gone, + // broadcasting to dead channels and leaking state. + // + // The setup: "target" has no script (aggregator) and depends on + // "slow-dep" which sleeps. We cancel the context while slow-dep + // is running, expecting both target and slow-dep to be cancelled. + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `slow-dep:`, + ` echo "slow-dep-started"`, + ` sleep 30`, + ``, + `target: slow-dep`, + ].join(`\n`)); + + await run(`install`); + + // Run the target task (which has no script) in background + const taskPromise = runSwitch(`tasks`, `run`, `--json`, `target`).catch(e => e); + + // Wait for slow-dep to start + await new Promise(resolve => setTimeout(resolve, 800)); + + // Get stats to confirm task metadata exists before cancellation + const {stdout: statsBefore} = await runSwitch(`tasks`, `stats`, `--json`); + const before = JSON.parse(statsBefore); + expect(before.tasksCount).toBeGreaterThan(0); + + // Cancel by killing the daemon (which triggers context cleanup) + await runSwitch(`switch`, `daemon`, `--kill`); + + const result = await taskPromise; + + // The key assertion: after cancellation + cleanup, the internal state + // should not have leaked entries. Start a fresh daemon and verify. + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + const {stdout: statsAfter} = await runSwitch(`tasks`, `stats`, `--json`); + const after = JSON.parse(statsAfter); + + // Fresh daemon should have clean state — zero tasks + expect(after.tasksCount).toBe(0); + })), + ); + }); + + describe(`subscription timing`, () => { + test( + `it should not miss task-started events when subscribing rapidly`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This tests the fix for subscription registration timing + // Tasks should be added to subscription before sending response + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `quick-task:`, + ` echo "done"`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon first + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run multiple rapid subscriptions + const results = await Promise.all([ + runSwitch(`tasks`, `run`, `--json`, `quick-task`), + runSwitch(`tasks`, `run`, `--json`, `quick-task`), + runSwitch(`tasks`, `run`, `--json`, `quick-task`), + ]); + + // Each result should have received the task-started event + for (const result of results) { + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + const startedEvents = events.filter((e: any) => e.type === `task-started`); + expect(startedEvents.length).toBeGreaterThanOrEqual(1); + } + })), + ); + + test( + `it should not miss events for very fast completing tasks`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This tests that even tasks completing nearly instantly + // have their TaskStarted and TaskCompleted events received + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `instant:`, + ` true`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run the instant task multiple times sequentially with JSON output + for (let i = 0; i < 5; i++) { + const result = await runSwitch(`tasks`, `run`, `--json`, `instant`); + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // Must have task-started event + const started = events.filter((e: any) => e.type === `task-started`); + expect(started.length).toBe(1); + + // Must have task-completed event + const completed = events.filter((e: any) => e.type === `task-completed`); + expect(completed.length).toBe(1); + expect(completed[0].exitCode).toBe(0); + } + })), + ); + + test( + `it should not send duplicate task-completed events`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Verify that task-completed is only sent once, not duplicated + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `task:`, + ` echo "output"`, + ].join(`\n`)); + + await run(`install`); + + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + const result = await runSwitch(`tasks`, `run`, `--json`, `task`); + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // Count completed events for this task + const completedEvents = events.filter((e: any) => + e.type === `task-completed` && e.taskId.includes(`test-package:task`), + ); + + // Should only have exactly one task-completed event + expect(completedEvents.length).toBe(1); + })), + ); + + test( + `it should not send duplicate events for failed tasks`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Verify that failed tasks don't receive both TaskFailed AND TaskCompleted + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `failing:`, + ` exit 1`, + ].join(`\n`)); + + await run(`install`); + + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + const result = await runSwitch(`tasks`, `run`, `--json`, `failing`).catch(e => e); + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // Count all terminal events for this task + const terminalEvents = events.filter((e: any) => + (e.type === `task-completed` || e.type === `task-failed`) && + e.taskId.includes(`test-package:failing`), + ); + + // Should only have exactly one terminal event + expect(terminalEvents.length).toBe(1); + expect(terminalEvents[0].type).toBe(`task-completed`); + expect(terminalEvents[0].exitCode).toBe(1); + })), + ); + }); + + describe(`warm-up timer behavior`, () => { + test( + `it should not send warm-up-complete for tasks that fail during warm-up`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Long-lived task that crashes before 500ms warm-up period + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `crashing-server:`, + ` echo "server starting"`, + ` sleep 0.1`, + ` exit 1`, + ``, + `client: crashing-server`, + ` echo "client started"`, + ].join(`\n`)); + + await run(`install`); + + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run client which depends on the crashing long-lived server + const result = await runSwitch(`tasks`, `run`, `--json`, `client`).catch(e => e); + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // Should NOT have warm-up-complete for the crashing server + const warmUpEvents = events.filter((e: any) => + e.type === `task-warm-up-complete` && + e.taskId.includes(`crashing-server`), + ); + + expect(warmUpEvents.length).toBe(0); + + // Server should have started + const serverStarted = events.find((e: any) => + e.type === `task-started` && + e.taskId.includes(`crashing-server`), + ); + expect(serverStarted).toBeDefined(); + + // Server should have completed with failure + const serverCompleted = events.find((e: any) => + e.type === `task-completed` && + e.taskId.includes(`crashing-server`), + ); + expect(serverCompleted).toBeDefined(); + expect(serverCompleted.exitCode).toBe(1); + })), + ); + + test( + `it should send warm-up-complete only once per long-lived task`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` echo "server running"`, + ` sleep 10`, + ].join(`\n`)); + + await run(`install`); + + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Start server and wait for warm-up + const serverPromise = runSwitch(`tasks`, `run`, `--json`, `server`).catch(() => {}); + + // Wait for warm-up (500ms) plus some buffer + await new Promise(resolve => setTimeout(resolve, 800)); + + // Start a second request that attaches to the existing server + const attachPromise = runSwitch(`tasks`, `run`, `--json`, `server`).catch(() => {}); + + // Wait a bit more + await new Promise(resolve => setTimeout(resolve, 300)); + + // Clean up + await runSwitch(`tasks`, `stop`, `server`).catch(() => {}); + + // The warm-up should have been sent exactly once for the server + // (This is verified by the fact that attachPromise should see + // the task as already warmed up, not schedule another warm-up) + })), + ); + }); + + describe(`subtask state management`, () => { + test( + `it should wait for all pushed subtasks before completing parent`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // Tests the WaitingForSubtasks state - parent should wait for all subtasks + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `slow-subtask:`, + ` sleep 0.3 && echo "slow-done"`, + ``, + `fast-subtask:`, + ` echo "fast-done"`, + ``, + `parent:`, + ` echo "parent-start"`, + ` yarn tasks push slow-subtask`, + ` yarn tasks push fast-subtask`, + ` echo "parent-script-done"`, + ].join(`\n`)); + + await run(`install`); + + const {stdout} = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `parent`); + const events = stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // Parent should complete last (after both subtasks) + const completedEvents = events.filter((e: any) => e.type === `task-completed`); + const parentCompleted = completedEvents.findIndex((e: any) => e.taskId === `test-package:parent`); + const slowCompleted = completedEvents.findIndex((e: any) => e.taskId === `test-package:slow-subtask`); + const fastCompleted = completedEvents.findIndex((e: any) => e.taskId === `test-package:fast-subtask`); + + // Parent should complete after both subtasks + expect(parentCompleted).toBeGreaterThan(slowCompleted); + expect(parentCompleted).toBeGreaterThan(fastCompleted); + }), + ); + + test( + `it should propagate subtask failure exit code to parent`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // When a subtask fails, the parent should fail with the subtask's exit code + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `failing-subtask:`, + ` exit 55`, + ``, + `parent:`, + ` echo "parent-start"`, + ` yarn tasks push failing-subtask`, + ` echo "parent-script-done"`, + ].join(`\n`)); + + await run(`install`); + + const result = await runSwitch(`tasks`, `run`, `--standalone`, `--json`, `parent`).catch(e => e); + expect(result.code).toBe(55); + + const events = result.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // Subtask should have failed with exit code 55 + const subtaskCompleted = events.find((e: any) => e.type === `task-completed` && e.taskId === `test-package:failing-subtask`); + expect(subtaskCompleted).toBeDefined(); + expect(subtaskCompleted.exitCode).toBe(55); + + // Parent should also complete (not be cancelled) since it did start + const parentCompleted = events.find((e: any) => e.type === `task-completed` && e.taskId === `test-package:parent`); + expect(parentCompleted).toBeDefined(); + expect(parentCompleted.exitCode).toBe(55); + }), + ); + + test( + `it should use parent exit code when parent script fails before subtasks`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // When parent script itself fails, its exit code should be used + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `subtask:`, + ` echo "subtask"`, + ``, + `parent:`, + ` yarn tasks push subtask`, + ` exit 66`, + ].join(`\n`)); + + await run(`install`); + + const result = await runSwitch(`tasks`, `run`, `--standalone`, `parent`).catch(e => e); + expect(result.code).toBe(66); + }), + ); + }); + + describe(`daemon signal propagation`, () => { + test( + `it should terminate child processes when daemon receives SIGTERM`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + const pidFile = ppath.join(path, `task.pid`); + const runningMarker = ppath.join(path, `running`); + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `long-task:`, + ` echo $$ > task.pid`, + ` touch running`, + ` sleep 60`, + ].join(`\n`)); + + await run(`install`); + + // Start the task in background + const taskPromise = runSwitch(`tasks`, `run`, `long-task`).catch(() => {}); + + // Wait for task to start and write its PID + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Verify task is running + const taskRunning = await xfs.existsPromise(runningMarker); + expect(taskRunning).toBe(true); + + // Read the task PID + const taskPid = parseInt(await xfs.readFilePromise(pidFile, `utf8`), 10); + + // Kill the daemon (which should propagate signals to children) + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Wait for signal propagation and process cleanup + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Check if the task process is still running + let processStillRunning = false; + try { + // Sending signal 0 checks if process exists without actually sending a signal + process.kill(taskPid, 0); + processStillRunning = true; + } catch { + // Process doesn't exist, which is expected + processStillRunning = false; + } + + expect(processStillRunning).toBe(false); + }), + ); + + test( + `it should send SIGKILL after 5 seconds if SIGTERM is ignored`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + const pidFile = ppath.join(path, `task.pid`); + const runningMarker = ppath.join(path, `running`); + + // Create a task that traps SIGTERM and ignores it + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `stubborn-task:`, + ` trap '' TERM`, + ` echo $$ > task.pid`, + ` touch running`, + ` sleep 120`, + ].join(`\n`)); + + await run(`install`); + + // Start the task in background + const taskPromise = runSwitch(`tasks`, `run`, `stubborn-task`).catch(() => {}); + + // Wait for task to start and write its PID + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Verify task is running + const taskRunning = await xfs.existsPromise(runningMarker); + expect(taskRunning).toBe(true); + + // Read the task PID + const taskPid = parseInt(await xfs.readFilePromise(pidFile, `utf8`), 10); + + // Kill the daemon - it should send SIGTERM, wait 5s, then SIGKILL + const startTime = Date.now(); + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Wait for the full signal propagation cycle (SIGTERM + 5s + SIGKILL + cleanup) + await new Promise(resolve => setTimeout(resolve, 7000)); + const elapsed = Date.now() - startTime; + + // Check if the task process is still running + let processStillRunning = false; + try { + process.kill(taskPid, 0); + processStillRunning = true; + } catch { + processStillRunning = false; + } + + // Process should be killed even though it ignored SIGTERM + expect(processStillRunning).toBe(false); + }), + 15000, // Increase timeout for this test (5s wait + overhead) + ); + }); + + describe(`daemon lifecycle management`, () => { + test( + `it should clean up stale daemon and start fresh`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This tests the stale daemon cleanup behavior + // When a daemon is detected as alive but unresponsive, it should be killed + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `build:`, + ` echo "building"`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run a task to verify daemon is working + const {stdout: stdout1} = await runSwitch(`tasks`, `run`, `build`); + expect(stdout1).toContain(`building`); + + // Kill daemon forcefully (simulating a crash that leaves stale state) + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Start daemon again - should work even if there was stale state + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run task again - should succeed with fresh daemon + const {stdout: stdout2} = await runSwitch(`tasks`, `run`, `build`); + expect(stdout2).toContain(`building`); + })), + ); + + test( + `it should gracefully shutdown when project root changes`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This tests the graceful shutdown behavior when project root is modified + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `@long-lived`, + `server:`, + ` echo "server-started"`, + ` sleep 60`, + ``, + `build:`, + ` echo "building"`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon and get a server running + const serverPromise = runSwitch(`tasks`, `run`, `server`).catch(() => {}); + + // Wait for warm-up + await new Promise(resolve => setTimeout(resolve, 700)); + + // Store daemon info before the change + // (In real scenario, modifying yarn.lock or package.json would trigger shutdown) + + // Kill all daemons - this simulates the graceful shutdown + // The actual project root watcher behavior is internal to the daemon + await runSwitch(`switch`, `daemon`, `--kill-all`); + + // Daemon should be stopped now + // Verify by trying to start a new one (should succeed) + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run a quick task to verify new daemon works + const {stdout} = await runSwitch(`tasks`, `run`, `build`); + expect(stdout).toContain(`building`); + })), + ); + }); + + describe(`memory management`, () => { + test( + `it should handle many sequential task executions without issues`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This tests that task metadata is properly cleaned up after execution + // Running many tasks sequentially should not cause issues + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `task:`, + ` echo "iteration"`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run the same task many times (this would accumulate metadata without cleanup) + const iterations = 20; + for (let i = 0; i < iterations; i++) { + const {stdout} = await runSwitch(`tasks`, `run`, `task`); + expect(stdout).toContain(`iteration`); + } + + // All iterations should have succeeded - no memory/state issues + })), + ); + + test( + `it should handle many concurrent task executions`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This tests memory management under concurrent load + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `task-a:`, + ` sleep 0.1 && echo "a"`, + ``, + `task-b:`, + ` sleep 0.1 && echo "b"`, + ``, + `task-c:`, + ` sleep 0.1 && echo "c"`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run multiple batches of concurrent tasks + for (let batch = 0; batch < 5; batch++) { + const results = await Promise.all([ + runSwitch(`tasks`, `run`, `task-a`), + runSwitch(`tasks`, `run`, `task-b`), + runSwitch(`tasks`, `run`, `task-c`), + ]); + + expect(results[0].stdout).toContain(`a`); + expect(results[1].stdout).toContain(`b`); + expect(results[2].stdout).toContain(`c`); + } + })), + ); + }); + + describe(`cross-context isolation`, () => { + test( + `it should not report tasks from other contexts as completed`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This test verifies that tasks from one context do not appear in another context's output. + // Context "abc" runs task A, Context "xyz" runs task C. + // Task A should NOT see task C's events, and vice versa. + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `task-a:`, + ` sleep 0.1 && echo "task-a-done"`, + ``, + `task-c:`, + ` sleep 0.1 && echo "task-c-done"`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run task-a and task-c concurrently in different contexts + const [resultA, resultC] = await Promise.all([ + runSwitch(`tasks`, `run`, `--json`, `task-a`), + runSwitch(`tasks`, `run`, `--json`, `task-c`), + ]); + + // Parse events from each result + const eventsA = resultA.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + const eventsC = resultC.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // Context A should only see task-a events, never task-c + const taskAStarted = eventsA.filter((e: any) => e.type === `task-started`); + const taskACompleted = eventsA.filter((e: any) => e.type === `task-completed`); + + expect(taskAStarted.length).toBe(1); + expect(taskAStarted[0].taskId).toContain(`task-a`); + expect(taskACompleted.length).toBe(1); + expect(taskACompleted[0].taskId).toContain(`task-a`); + + // Context A should NOT see task-c events + const taskCInA = eventsA.filter((e: any) => + e.taskId && e.taskId.includes(`task-c`), + ); + expect(taskCInA.length).toBe(0); + + // Context C should only see task-c events, never task-a + const taskCStarted = eventsC.filter((e: any) => e.type === `task-started`); + const taskCCompleted = eventsC.filter((e: any) => e.type === `task-completed`); + + expect(taskCStarted.length).toBe(1); + expect(taskCStarted[0].taskId).toContain(`task-c`); + expect(taskCCompleted.length).toBe(1); + expect(taskCCompleted[0].taskId).toContain(`task-c`); + + // Context C should NOT see task-a events + const taskAInC = eventsC.filter((e: any) => + e.taskId && e.taskId.includes(`task-a`), + ); + expect(taskAInC.length).toBe(0); + })), + ); + + test( + `it should not spuriously schedule tasks from other contexts`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Tests that find_ready_tasks only considers tasks prepared in the current context. + // If context "abc" pushes task A with dependency D, + // and context "xyz" pushes task C (no dependencies), + // then context "xyz" should NOT accidentally schedule task D. + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `dep-d:`, + ` echo "dep-d-done"`, + ``, + `task-a: dep-d`, + ` echo "task-a-done"`, + ``, + `task-c:`, + ` echo "task-c-done"`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run task-a and task-c concurrently + const [resultA, resultC] = await Promise.all([ + runSwitch(`tasks`, `run`, `--json`, `task-a`), + runSwitch(`tasks`, `run`, `--json`, `task-c`), + ]); + + // Parse events from task-c's context + const eventsC = resultC.stdout.trim().split(`\n`).map((line: string) => JSON.parse(line)); + + // Context C should NOT have any dep-d events (dep-d belongs to context A) + const depDInC = eventsC.filter((e: any) => + e.taskId && e.taskId.includes(`dep-d`), + ); + expect(depDInC.length).toBe(0); + + // Context C should only see task-c + const allTaskIds = eventsC + .filter((e: any) => e.taskId) + .map((e: any) => e.taskId); + for (const taskId of allTaskIds) { + expect(taskId).toContain(`task-c`); + } + })), + ); + }); + + describe(`output framing`, () => { + test( + `it should not print Process started for tasks that never ran due to dependency failure`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // When a dependency fails, dependent tasks are marked as failed via find_tasks_to_fail + // and TaskCompleted { exit_code: 1 } is broadcast. However, these tasks never actually ran, + // so we should NOT print "Process started" for them. + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `failing-dep:`, + ` echo "failing-output"`, + ` exit 1`, + ``, + `dependent: failing-dep`, + ` echo "dependent-should-not-run"`, + ].join(`\n`)); + + await run(`install`); + + // Run with --silent-dependencies which triggers on_task_completed for failed deps + const result = await runSwitch(`tasks`, `run`, `--standalone`, `--silent-dependencies`, `dependent`).catch(e => e); + expect(result.code).toBe(1); + + // The output should show "Process started" for the failing-dep (which actually ran) + // but NOT for the dependent task (which never ran) + expect(result.stdout).toContain(`[test-package:failing-dep]: Process started`); + expect(result.stdout).toContain(`[test-package:failing-dep]: failing-output`); + expect(result.stdout).toContain(`[test-package:failing-dep]: Process exited (exit code 1)`); + + // The dependent task should NOT have "Process started" since it never ran + // (it was cancelled due to dependency failure) + expect(result.stdout).not.toContain(`[test-package:dependent]: Process started`); + }), + ); + + test( + `it should not print framing for tasks with no output even on failure`, + makeTemporaryEnv({ + name: `test-package`, + }, async ({path, run, runSwitch}) => { + // A task that fails without producing output should not have framing printed + + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `silent-fail:`, + ` exit 1`, + ``, + `dependent: silent-fail`, + ` echo "should-not-run"`, + ].join(`\n`)); + + await run(`install`); + + const result = await runSwitch(`tasks`, `run`, `--standalone`, `--silent-dependencies`, `dependent`).catch(e => e); + expect(result.code).toBe(1); + + // Since silent-fail produces no output, even though it ran and failed, + // we should not print any framing for it + expect(result.stdout).not.toContain(`Process started`); + expect(result.stdout).not.toContain(`Process exited`); + }), + ); + }); + + describe(`memory management - task metadata cleanup`, () => { + test( + `it should clean up task metadata after many sequential executions`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This test verifies that running many tasks doesn't cause unbounded + // growth in the tasks/prepared/subtasks maps + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `task:`, + ` echo "iteration"`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Get initial stats + const initialStats = await runSwitch(`tasks`, `stats`, `--json`); + const initial = JSON.parse(initialStats.stdout); + + // Run the same task many times - each run creates a new context + const iterations = 30; + for (let i = 0; i < iterations; i++) { + await runSwitch(`tasks`, `run`, `task`); + } + + // Get final stats + const finalStats = await runSwitch(`tasks`, `stats`, `--json`); + const final = JSON.parse(finalStats.stdout); + + // The daemon has a max_closed_tasks limit (default 100), so after many runs + // the metadata should be bounded. We check that growth is not proportional + // to iterations - allow some leeway for configuration defaults. + // + // If there's a memory leak, tasksCount would be >= iterations. + // With proper cleanup, it should be bounded by max_closed_tasks. + const maxExpectedTasks = 100; // Based on default max_closed_tasks + + // The tasks count should be bounded, not growing unboundedly + expect(final.tasksCount).toBeLessThanOrEqual(maxExpectedTasks); + + // Similarly for prepared and subtasks + expect(final.preparedCount).toBeLessThanOrEqual(maxExpectedTasks); + })), + ); + + test( + `it should properly complete tasks without scripts`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // This test verifies that tasks without scripts (pure dependency aggregators) + // are properly completed and cleaned up + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `# A task with a script`, + `actual-work:`, + ` echo "doing work"`, + ``, + `# A task WITHOUT a script - just aggregates dependencies`, + `# This type of task was not being properly completed/cleaned up`, + `aggregate: actual-work`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Run the aggregate task multiple times + for (let i = 0; i < 10; i++) { + const {stdout} = await runSwitch(`tasks`, `run`, `aggregate`); + expect(stdout).toContain(`doing work`); + } + + // Get stats to verify cleanup + const statsResult = await runSwitch(`tasks`, `stats`, `--json`); + const stats = JSON.parse(statsResult.stdout); + + // Verify the counts are bounded (not growing unboundedly) + // Each run has 2 tasks (aggregate + actual-work), so 10 runs = 20 task instances + // With cleanup, this should be bounded + expect(stats.tasksCount).toBeLessThanOrEqual(100); + expect(stats.preparedCount).toBeLessThanOrEqual(100); + })), + ); + + test( + `it should track closed_tasks correctly for eviction`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(async ({path, run, runSwitch}) => { + // Verify that closed_tasks queue is properly populated + await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ + `task:`, + ` echo "test"`, + ].join(`\n`)); + + await run(`install`); + + // Start daemon + await runSwitch(`switch`, `daemon`, `--open`); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Get initial stats + const initialStats = await runSwitch(`tasks`, `stats`, `--json`); + const initial = JSON.parse(initialStats.stdout); + + // Run task once + await runSwitch(`tasks`, `run`, `task`); + + // Get stats after running + const afterStats = await runSwitch(`tasks`, `stats`, `--json`); + const after = JSON.parse(afterStats.stdout); + + // closed_tasks should have increased (task was marked as closed) + expect(after.closedTasksCount).toBeGreaterThanOrEqual(initial.closedTasksCount); + })), + ); + }); }); });