diff --git a/Cargo.lock b/Cargo.lock index 6757ad9f..e2c82a73 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", @@ -4299,8 +4510,10 @@ dependencies = [ "spki", "thiserror 2.0.18", "tokio", + "tokio-tungstenite", "tower", "url", + "uuid", "wax", "zpm-allocator", "zpm-config", @@ -4438,14 +4651,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 +4706,7 @@ dependencies = [ "fundu", "hex", "indexmap 2.13.0", + "libc", "num", "ouroboros", "rkyv", diff --git a/Cargo.toml b/Cargo.toml index 0caf8f3a..b3804327 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-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..90d3223d --- /dev/null +++ b/packages/zpm-switch/src/commands/switch/daemon_kill.rs @@ -0,0 +1,65 @@ +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(()); + } + + if daemons::kill_process(daemon.pid) { + 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..b927f9c2 --- /dev/null +++ b/packages/zpm-switch/src/commands/switch/daemon_kill_all.rs @@ -0,0 +1,83 @@ +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; + } + + if daemons::kill_process(daemon.pid) { + 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..56c7cc1c --- /dev/null +++ b/packages/zpm-switch/src/commands/switch/daemon_open.rs @@ -0,0 +1,199 @@ +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(()); + } + } + + 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)?; + + self.wait_for_ready(port).await?; + + 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 + tokio_tungstenite::connect_async(&url) + .await + .map_err(|e| { + Error::DaemonConnectionFailed(Arc::new(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + e.to_string(), + ))) + })?; + + 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..561792a8 --- /dev/null +++ b/packages/zpm-switch/src/daemons.rs @@ -0,0 +1,171 @@ +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)?)?; + + 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 + } +} + +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..bc59b4da 100644 --- a/packages/zpm-utils/src/process.rs +++ b/packages/zpm-utils/src/process.rs @@ -1,6 +1,45 @@ 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) }; + 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..ceacb04b 100644 --- a/packages/zpm/Cargo.toml +++ b/packages/zpm/Cargo.toml @@ -57,8 +57,11 @@ 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" [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..ed310449 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,12 @@ 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), + 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..aa0cd068 100644 --- a/packages/zpm/src/commands/tasks/mod.rs +++ b/packages/zpm/src/commands/tasks/mod.rs @@ -1,2 +1,39 @@ +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 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..9f872ae4 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,16 +28,41 @@ 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)) + Ok(ExitStatus::from_raw(0 << 8)) } } 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..987770d2 --- /dev/null +++ b/packages/zpm/src/commands/tasks/run_buffered.rs @@ -0,0 +1,134 @@ +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) {} + + 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(); + } + } + + async fn on_task_failed( + &mut self, + _ctx: &mut TaskRunContext, + task_id: &str, + error: &str, + is_target: bool, + ) -> Option { + if is_target { + Some(Error::IpcError(format!("Task {} failed: {}", format_task_id(task_id), error))) + } else { + None + } + } + + 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..264f2be3 --- /dev/null +++ b/packages/zpm/src/commands/tasks/run_interlaced.rs @@ -0,0 +1,147 @@ +use std::io::Write; +use std::process::ExitStatus; + +use async_trait::async_trait; +use clipanion::cli; + +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, +} + +#[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) { + let mut stdout + = std::io::stdout().lock(); + + 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 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 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_failed( + &mut self, + _ctx: &mut TaskRunContext, + task_id: &str, + error: &str, + is_target: bool, + ) -> Option { + if is_target { + Some(Error::IpcError(format!("Task {} failed: {}", format_task_id(task_id), error))) + } else { + None + } + } + + 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, + + /// 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, + }; + + let x = run_task( + &mut handler, + &self.name, + &self.args, + self.standalone, + self.verbose_level, + ).await; + + x + } +} 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..65b8db9f --- /dev/null +++ b/packages/zpm/src/commands/tasks/run_silent_dependencies.rs @@ -0,0 +1,206 @@ +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) { + 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(); + + let mut stdout + = std::io::stdout().lock(); + + writeln!(stdout, "[{}]: Process started", format_task_id(task_id)).ok(); + + if let Some(lines) = lines { + for output_line in lines { + 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(); + } + } + } + + async fn on_task_failed( + &mut self, + ctx: &mut TaskRunContext, + task_id: &str, + error: &str, + is_target: bool, + ) -> Option { + self.stop_progress(); + + let lines + = ctx.client.get_task_output(task_id).await.ok(); + + let mut stdout + = std::io::stdout().lock(); + + writeln!(stdout, "[{}]: Process started", format_task_id(task_id)).ok(); + + if let Some(lines) = lines { + for output_line in lines { + writeln!(stdout, "[{}]: {}", format_task_id(task_id), output_line.line).ok(); + } + } + + if is_target { + Some(Error::IpcError(format!("Task {} failed: {}", format_task_id(task_id), error))) + } else { + None + } + } + + 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..12a1ccdb --- /dev/null +++ b/packages/zpm/src/commands/tasks/runner.rs @@ -0,0 +1,251 @@ +use std::collections::HashSet; +use std::os::unix::process::ExitStatusExt; +use std::process::ExitStatus; + +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); + + 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_failed( + &mut self, + ctx: &mut TaskRunContext, + task_id: &str, + error: &str, + is_target: bool, + ) -> Option; + + 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 _daemon_handle: Option; + + let mut client + = if standalone { + let (c, handle) + = DaemonClient::connect_standalone(&project.project_cwd).await?; + + _daemon_handle = Some(handle); + c + } else { + _daemon_handle = None; + DaemonClient::connect(&project.project_cwd).await? + }; + + let context_id + = Uuid::new_v4().to_string(); + + 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(ExitStatus::from_raw(0)); + } else { + continue; + } + } + n = ctx.client.recv_notification() => n?, + }; + + match notification { + DaemonNotification::TaskOutputLine { task_id, line, .. } => { + handler.on_output_line(&mut ctx, &task_id, &line).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::TaskFailed { task_id, error } => { + let is_target + = ctx.is_target(&task_id); + + if let Some(err) = handler.on_task_failed(&mut ctx, &task_id, &error, is_target).await { + ctx.client.close(); + + if standalone { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + return Err(err); + } + } + + DaemonNotification::TaskWarmUpComplete { .. } => {} + } + } + + ctx.client.close(); + + if standalone { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + Ok(ExitStatus::from_raw(ctx.exit_code << 8)) +} 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..098a566a --- /dev/null +++ b/packages/zpm/src/daemon/client.rs @@ -0,0 +1,443 @@ +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_tungstenite::tungstenite::Message; +use zpm_switch::YARN_SWITCH_PATH_ENV; + +use super::ipc::{ + AttachedLongLivedTask, BufferedOutputLine, DaemonMessage, DaemonNotification, DaemonRequest, + DaemonRequestEnvelope, DaemonResponse, LongLivedTaskInfo, SubscriptionScope, TaskSubscription, + DAEMON_SERVER_ENV, +}; +use zpm_utils::Path; + +use crate::error::Error; + +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 process that can be killed when no longer needed +pub struct StandaloneDaemonHandle { + pid: u32, +} + +impl StandaloneDaemonHandle { + pub fn kill(&self) { + #[cfg(unix)] + { + let _ = std::process::Command::new("kill") + .arg("-9") + .arg(format!("-{}", self.pid)) + .status(); + } + } +} + +impl Drop for StandaloneDaemonHandle { + fn drop(&mut self) { + self.kill(); + } +} + +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 that is not registered and will be killed when the handle is dropped + pub async fn connect_standalone(project_root: &Path) -> Result<(Self, StandaloneDaemonHandle), Error> { + let (url, pid) + = start_standalone_daemon(project_root).await?; + + let client + = Self::connect_to_url(&url).await?; + + Ok((client, StandaloneDaemonHandle { pid })) + } + + 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()))?; + + 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; + } + } + } + }); + + Ok(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()))?; + + response_rx + .await + .map_err(|_| Error::IpcError("Connection closed while waiting for response".to_string())) + } + + 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())), + } + } +} + +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()) +} + +async fn start_standalone_daemon(project_root: &Path) -> Result<(String, u32), Error> { + let current_exe + = std::env::current_exe() + .map_err(|e| Error::IpcError(format!("Failed to get current executable: {}", e)))?; + + let mut cmd + = tokio::process::Command::new(current_exe); + + cmd.args(["debug", "daemon"]) + .current_dir(project_root.to_path_buf()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .stdin(Stdio::null()); + + #[cfg(unix)] + cmd.process_group(0); + + let mut child + = cmd + .spawn() + .map_err(|e| Error::IpcError(format!("Failed to start standalone daemon: {}", e)))?; + + let pid + = child.id().ok_or_else(|| Error::IpcError("Failed to get daemon PID".to_string()))?; + + 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 port_line + = tokio::time::timeout(Duration::from_secs(10), reader.next_line()) + .await + .map_err(|_| Error::IpcError("Timeout waiting for daemon port".to_string()))? + .map_err(|e| Error::IpcError(e.to_string()))? + .ok_or_else(|| Error::IpcError("Daemon closed without printing port".to_string()))?; + + let port: u16 + = port_line.trim().parse() + .map_err(|_| Error::IpcError(format!("Invalid port from daemon: {}", port_line)))?; + + let url + = format!("ws://127.0.0.1:{}", port); + + let max_attempts + = 100; + + let poll_interval + = Duration::from_millis(50); + + for _ in 0..max_attempts { + match tokio_tungstenite::connect_async(&url).await { + Ok(_) => return Ok((url, pid)), + Err(_) => tokio::time::sleep(poll_interval).await, + } + } + + Err(Error::IpcError("Timeout waiting for daemon to be ready".to_string())) +} diff --git a/packages/zpm/src/daemon/coordinator.rs b/packages/zpm/src/daemon/coordinator.rs new file mode 100644 index 00000000..f6fce4b3 --- /dev/null +++ b/packages/zpm/src/daemon/coordinator.rs @@ -0,0 +1,344 @@ +use std::collections::{HashMap, HashSet}; +use std::io::Write; +use std::os::unix::fs::MetadataExt; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use tokio::sync::mpsc; +use super::ipc::{daemon_url, BufferedOutputLine, DaemonNotification}; +use zpm_utils::Path; + +use super::events::ExecutorEvent; +use super::executor::ExecutorPool; +use super::long_lived::LongLivedRegistry; +use super::scheduler::{format_contextual_task_id, ContextualTaskId, Scheduler}; +use super::server::{bind_to_available_port, run_accept_loop, ConnectionContext, OutputBuffer}; +use super::subscriptions::SubscriptionRegistry; +use crate::error::Error; +use crate::project::Project; + +use zpm_primitives::Ident; +use zpm_tasks::{TaskId, TaskName}; + +const LONG_LIVED_WARMUP_MS: u64 = 500; +const OUTPUT_BUFFER_MAX_LINES: usize = 1000; + +fn parse_base_task_id(contextual_task_id: &str) -> Option { + let (task_part, _context_id) + = contextual_task_id.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(TaskId { + workspace, + task_name, + }) +} + +pub async fn run_daemon(project: Arc) -> Result<(), Error> { + let (listener, port) + = bind_to_available_port().await?; + + let daemon_url_str + = daemon_url(port); + + println!("{}", port); + let _ = std::io::stdout().flush(); + + let scheduler + = Arc::new(Scheduler::new()); + + let output_buffer: OutputBuffer + = Arc::new(RwLock::new(HashMap::new())); + + let subscription_registry + = Arc::new(SubscriptionRegistry::new()); + + let long_lived_registry + = Arc::new(LongLivedRegistry::new()); + + let scheduler_for_loop + = scheduler.clone(); + + let (loop_event_tx, mut loop_event_rx) + = mpsc::unbounded_channel::(); + + let subscription_registry_for_loop + = subscription_registry.clone(); + + let subscription_registry_for_events + = subscription_registry.clone(); + + let output_buffer_for_events + = output_buffer.clone(); + + let long_lived_registry_for_events + = long_lived_registry.clone(); + + let scheduler_for_events + = scheduler.clone(); + + tokio::spawn(async move { + while let Some(event) = loop_event_rx.recv().await { + if let ExecutorEvent::Output { task_id, line, stream } = &event { + if let Ok(mut buffer) = output_buffer_for_events.write() { + let lines: &mut Vec + = buffer + .entry(task_id.to_string()) + .or_insert_with(Vec::new); + + lines.push(BufferedOutputLine { + line: line.to_string(), + stream: stream.as_str().to_string(), + }); + + if lines.len() > OUTPUT_BUFFER_MAX_LINES { + let excess + = lines.len() - OUTPUT_BUFFER_MAX_LINES; + + lines.drain(0..excess); + } + } + } + + let notification + = match &event { + ExecutorEvent::Started { task_id } => { + Some(DaemonNotification::TaskStarted { + task_id: task_id.clone(), + }) + } + ExecutorEvent::Output { task_id, line, stream } => { + Some(DaemonNotification::TaskOutputLine { + task_id: task_id.clone(), + line: line.clone(), + stream: stream.as_str().to_string(), + }) + } + ExecutorEvent::Finished { .. } => None, + ExecutorEvent::Failed { task_id, error } => { + Some(DaemonNotification::TaskFailed { + task_id: task_id.clone(), + error: error.clone(), + }) + } + }; + + if let Some(n) = notification { + subscription_registry_for_events.broadcast(n.clone()); + + if let DaemonNotification::TaskStarted { task_id } = &n { + if let Some(ctx_task_id) = scheduler_for_events.parse_contextual_task_id(task_id) { + if scheduler_for_events.is_long_lived(&ctx_task_id) { + let task_id_clone + = task_id.clone(); + + let ctx_task_id_clone + = ctx_task_id.clone(); + + let registry_clone + = long_lived_registry_for_events.clone(); + + let sub_registry_clone + = subscription_registry_for_events.clone(); + + let scheduler_clone + = scheduler_for_events.clone(); + + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(LONG_LIVED_WARMUP_MS)).await; + + if let Some(base_task_id) = parse_base_task_id(&task_id_clone) { + registry_clone.mark_warm_up_complete(&base_task_id); + } + + scheduler_clone.mark_warm_up_complete(&ctx_task_id_clone); + + sub_registry_clone.broadcast(DaemonNotification::TaskWarmUpComplete { + task_id: task_id_clone, + }); + }); + } + } + } + } + } + }); + + tokio::spawn(async move { + let mut executor_pool + = ExecutorPool::new(loop_event_tx, daemon_url_str); + + let mut pending_completion: HashMap + = HashMap::new(); + + loop { + let running: HashSet<_> + = executor_pool.running_tasks().cloned().collect(); + + let tasks_to_fail + = scheduler_for_loop.tasks_to_fail(&running); + + for task_id in tasks_to_fail { + scheduler_for_loop.mark_failed(&task_id); + + let task_id_str + = format_contextual_task_id(&task_id); + + subscription_registry_for_loop.broadcast(DaemonNotification::TaskCompleted { + task_id: task_id_str, + exit_code: 1, + }); + } + + let ready_tasks + = scheduler_for_loop.ready_tasks(&running); + + for (task_id, prepared_opt) in ready_tasks { + if let Some(prepared) = prepared_opt { + executor_pool.spawn(task_id, prepared); + } else { + scheduler_for_loop.mark_completed(&task_id); + + let task_id_str + = format_contextual_task_id(&task_id); + + subscription_registry_for_loop.broadcast(DaemonNotification::TaskCompleted { + task_id: task_id_str, + exit_code: 0, + }); + } + } + + if executor_pool.is_empty() { + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } + + if let Some((task_id, result)) = executor_pool.wait_next().await { + tokio::time::sleep(Duration::from_millis(10)).await; + + match result { + Ok(status) => { + let exit_code + = status.code().unwrap_or(-1); + + scheduler_for_loop.mark_script_finished(&task_id); + + if !status.success() { + scheduler_for_loop.mark_failed(&task_id); + + let task_id_str + = format_contextual_task_id(&task_id); + + subscription_registry_for_loop + .broadcast(DaemonNotification::TaskCompleted { + task_id: task_id_str, + exit_code, + }); + + let parents + = scheduler_for_loop.find_parents(&task_id); + + for parent in parents { + if pending_completion.remove(&parent).is_some() { + scheduler_for_loop.mark_failed(&parent); + + let parent_id_str + = format_contextual_task_id(&parent); + + subscription_registry_for_loop + .broadcast(DaemonNotification::TaskCompleted { + task_id: parent_id_str, + exit_code, + }); + } + } + } else { + if scheduler_for_loop.try_complete_task(&task_id) { + let task_id_str + = format_contextual_task_id(&task_id); + + subscription_registry_for_loop + .broadcast(DaemonNotification::TaskCompleted { + task_id: task_id_str, + exit_code, + }); + + let parents + = scheduler_for_loop.find_parents(&task_id); + + for parent in parents { + if let Some(&parent_exit_code) = pending_completion.get(&parent) + { + if scheduler_for_loop.try_complete_task(&parent) { + pending_completion.remove(&parent); + + let parent_id_str + = format_contextual_task_id(&parent); + + subscription_registry_for_loop.broadcast( + DaemonNotification::TaskCompleted { + task_id: parent_id_str, + exit_code: parent_exit_code, + }, + ); + } + } + } + } else { + pending_completion.insert(task_id, exit_code); + } + } + } + Err(e) => { + eprintln!("Task execution error: {}", e); + } + } + } + } + }); + + let project_root + = project.project_cwd.clone(); + + let initial_inode + = project_root.fs_metadata()?.ino(); + + tokio::spawn(async move { + watch_project_root(project_root, initial_inode).await; + }); + + let ctx + = Arc::new(ConnectionContext { + project, + scheduler, + subscription_registry, + output_buffer, + long_lived_registry, + }); + + run_accept_loop(listener, ctx).await; + + Ok(()) +} + +async fn watch_project_root(project_root: Path, initial_inode: u64) { + 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) { + std::process::exit(0); + } + } +} diff --git a/packages/zpm/src/daemon/events.rs b/packages/zpm/src/daemon/events.rs new file mode 100644 index 00000000..f5fc7c28 --- /dev/null +++ b/packages/zpm/src/daemon/events.rs @@ -0,0 +1,54 @@ +use zpm_tasks::TaskId; + +use super::scheduler::PreparedTask; + +#[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", + } + } +} + +#[derive(Debug, Clone)] +pub enum SchedulerEvent { + TaskReady { + task_id: TaskId, + prepared: PreparedTask, + }, + TaskCompleted { + task_id: TaskId, + exit_code: i32, + }, + TaskFailed { + task_id: TaskId, + error: String, + }, +} + +#[derive(Debug, Clone)] +pub enum ExecutorEvent { + Started { + task_id: String, + }, + Output { + task_id: String, + line: String, + stream: Stream, + }, + Finished { + task_id: String, + exit_code: i32, + }, + Failed { + task_id: String, + error: String, + }, +} diff --git a/packages/zpm/src/daemon/executor/mod.rs b/packages/zpm/src/daemon/executor/mod.rs new file mode 100644 index 00000000..7fcaec0d --- /dev/null +++ b/packages/zpm/src/daemon/executor/mod.rs @@ -0,0 +1,7 @@ +mod output; +mod pool; +mod runner; + +pub use output::OutputLine; +pub use pool::ExecutorPool; +pub use runner::TaskRunner; diff --git a/packages/zpm/src/daemon/executor/output.rs b/packages/zpm/src/daemon/executor/output.rs new file mode 100644 index 00000000..e37eed3b --- /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 tokio::sync::mpsc; + +use super::super::events::Stream; + +pub struct OutputLine { + pub line: String, + pub stream: Stream, +} + +pub async fn stream_output( + stdout: ChildStdout, + stderr: ChildStderr, + tx: mpsc::UnboundedSender, +) { + let mut stdout_reader = BufReader::new(stdout).lines(); + let mut stderr_reader = BufReader::new(stderr).lines(); + + loop { + tokio::select! { + line = stdout_reader.next_line() => { + match line { + Ok(Some(line)) => { + let _ = tx.send(OutputLine { + line, + stream: Stream::Stdout, + }); + } + Ok(None) => break, + Err(_) => break, + } + } + line = stderr_reader.next_line() => { + match line { + Ok(Some(line)) => { + let _ = tx.send(OutputLine { + line, + stream: Stream::Stderr, + }); + } + Ok(None) => {} + Err(_) => {} + } + } + } + } + + while let Ok(Some(line)) = stderr_reader.next_line().await { + let _ = tx.send(OutputLine { + line, + stream: Stream::Stderr, + }); + } +} diff --git a/packages/zpm/src/daemon/executor/pool.rs b/packages/zpm/src/daemon/executor/pool.rs new file mode 100644 index 00000000..85f46188 --- /dev/null +++ b/packages/zpm/src/daemon/executor/pool.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; +use std::process::ExitStatus; + +use futures::future::select_all; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use super::super::events::ExecutorEvent; +use super::super::scheduler::{format_contextual_task_id, ContextualTaskId, PreparedTask}; +use super::output::OutputLine; +use super::runner::TaskRunner; +use crate::error::Error; + +pub struct ExecutorPool { + handles: HashMap>>, + event_tx: mpsc::UnboundedSender, + daemon_url: String, +} + +impl ExecutorPool { + pub fn new(event_tx: mpsc::UnboundedSender, daemon_url: String) -> Self { + Self { + handles: HashMap::new(), + event_tx, + daemon_url, + } + } + + pub fn spawn(&mut self, task_id: ContextualTaskId, prepared: PreparedTask) { + let task_id_str = format_contextual_task_id(&task_id); + let event_tx = self.event_tx.clone(); + let task_id_clone = task_id.clone(); + let daemon_url = self.daemon_url.clone(); + + let _ = event_tx.send(ExecutorEvent::Started { + task_id: task_id_str.clone(), + }); + + let (output_tx, mut output_rx) = mpsc::unbounded_channel::(); + + let event_tx_output = event_tx.clone(); + let task_id_for_output = task_id_str.clone(); + + tokio::spawn(async move { + while let Some(output) = output_rx.recv().await { + let _ = event_tx_output.send(ExecutorEvent::Output { + task_id: task_id_for_output.clone(), + line: output.line, + stream: output.stream, + }); + } + }); + + let handle = tokio::spawn(async move { + let runner = TaskRunner::new(prepared, task_id_str, daemon_url); + let result = runner.run(output_tx).await; + result.map(|status| (task_id_clone, status)) + }); + + self.handles.insert(task_id, handle); + } + + pub fn running_tasks(&self) -> impl Iterator { + self.handles.keys() + } + + pub fn running_count(&self) -> usize { + self.handles.len() + } + + pub fn is_empty(&self) -> bool { + self.handles.is_empty() + } + + pub async fn wait_next(&mut self) -> Option<(ContextualTaskId, Result)> { + if self.handles.is_empty() { + return None; + } + + let handles: Vec<_> = self.handles.drain().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, remaining) = select_all(futures).await; + + for (future, task_id) in remaining.into_iter().zip( + task_ids + .iter() + .enumerate() + .filter(|(i, _)| *i != idx) + .map(|(_, id)| id.clone()), + ) { + self.handles.insert( + task_id, + tokio::spawn(async move { future.await.unwrap() }), + ); + } + + let completed_task_id = task_ids[idx].clone(); + let task_id_str = format_contextual_task_id(&completed_task_id); + + match result { + Ok(Ok((_, status))) => { + let exit_code = status.code().unwrap_or(-1); + let _ = self.event_tx.send(ExecutorEvent::Finished { + task_id: task_id_str, + exit_code, + }); + Some((completed_task_id, Ok(status))) + } + Ok(Err(e)) => { + let _ = self.event_tx.send(ExecutorEvent::Failed { + task_id: task_id_str, + error: e.to_string(), + }); + Some((completed_task_id, Err(e))) + } + Err(e) => { + let _ = self.event_tx.send(ExecutorEvent::Failed { + task_id: task_id_str, + error: e.to_string(), + }); + Some(( + completed_task_id, + Err(Error::TaskJoinError(e.to_string())), + )) + } + } + } +} diff --git a/packages/zpm/src/daemon/executor/runner.rs b/packages/zpm/src/daemon/executor/runner.rs new file mode 100644 index 00000000..426de1ac --- /dev/null +++ b/packages/zpm/src/daemon/executor/runner.rs @@ -0,0 +1,66 @@ +use std::process::ExitStatus; + +use tokio::sync::mpsc; +use super::super::ipc::{TASK_CURRENT_ENV, DAEMON_SERVER_ENV}; + +use super::super::scheduler::PreparedTask; +use super::output::{stream_output, OutputLine}; +use crate::error::Error; +use crate::script::ScriptEnvironment; + +pub struct TaskRunner { + prepared: PreparedTask, + task_id: String, + daemon_url: String, +} + +impl TaskRunner { + pub fn new(prepared: PreparedTask, task_id: String, daemon_url: String) -> Self { + Self { prepared, task_id, daemon_url } + } + + pub async fn run( + self, + output_tx: mpsc::UnboundedSender, + ) -> Result { + let mut env + = ScriptEnvironment::new()?; + + for (key, value) in &self.prepared.env { + env = env.with_env_variable(key, value); + } + + env = env.with_env_variable(TASK_CURRENT_ENV, &self.task_id); + 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?; + + let child_stdout + = running + .child + .stdout + .take() + .expect("Failed to capture stdout"); + + let child_stderr + = running + .child + .stderr + .take() + .expect("Failed to capture stderr"); + + stream_output(child_stdout, child_stderr, output_tx).await; + + let status + = running.child.wait().await?; + + Ok(status) + } +} diff --git a/packages/zpm/src/daemon/handlers/list_long_lived_tasks.rs b/packages/zpm/src/daemon/handlers/list_long_lived_tasks.rs new file mode 100644 index 00000000..37b2e19c --- /dev/null +++ b/packages/zpm/src/daemon/handlers/list_long_lived_tasks.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; +use std::time::UNIX_EPOCH; + +use zpm_tasks::{parse, TaskId}; +use zpm_utils::ToFileString; + +use super::super::ipc::{DaemonResponse, LongLivedTaskInfo, LongLivedTaskStatus}; +use super::super::long_lived::LongLivedRegistry; +use crate::project::Project; +use crate::tasks::TASK_FILE_NAME; + +pub fn handle_list_long_lived_tasks( + project: &Project, + long_lived_registry: &Arc, +) -> DaemonResponse { + let mut tasks: Vec + = Vec::new(); + + let running_entries + = long_lived_registry.list_all_entries(); + + for workspace in &project.workspaces { + let task_file_path + = workspace.path.with_join_str(TASK_FILE_NAME); + + let Ok(content) = task_file_path.fs_read_text() else { + continue; + }; + + let Ok(task_file) = parse(&content) else { + continue; + }; + + for (task_name, task) in &task_file.tasks { + let is_long_lived + = task.attributes.iter().any(|attr| attr.name == "long-lived"); + + if !is_long_lived { + continue; + } + + let task_id + = TaskId { + workspace: workspace.name.clone(), + task_name: task_name.clone(), + }; + + let status + = running_entries + .iter() + .find(|entry| entry.task_id == task_id) + .map(|entry| { + let started_at_ms + = entry.started_at + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + LongLivedTaskStatus::Running { + started_at_ms, + process_id: entry.process_id, + } + }) + .unwrap_or(LongLivedTaskStatus::Stopped); + + tasks.push(LongLivedTaskInfo { + workspace: workspace.name.to_file_string(), + task_name: task_name.as_str().to_string(), + status, + }); + } + } + + DaemonResponse::LongLivedTaskList { tasks } +} diff --git a/packages/zpm/src/daemon/handlers/mod.rs b/packages/zpm/src/daemon/handlers/mod.rs new file mode 100644 index 00000000..a6c9e57a --- /dev/null +++ b/packages/zpm/src/daemon/handlers/mod.rs @@ -0,0 +1,69 @@ +mod list_long_lived_tasks; +mod push_tasks; +mod stop_task; + +use std::sync::Arc; + +use super::ipc::{BufferedOutputLine, DaemonRequest, DaemonResponse}; +use super::long_lived::LongLivedRegistry; +use super::scheduler::Scheduler; +use super::server::OutputBuffer; +use super::subscriptions::{SubscriptionId, SubscriptionRegistry}; +use crate::project::Project; + +pub use list_long_lived_tasks::handle_list_long_lived_tasks; +pub use push_tasks::handle_push_tasks; +pub use stop_task::handle_stop_task; + +pub fn dispatch_request( + request: DaemonRequest, + scheduler: &Scheduler, + project: &Project, + output_buffer: &OutputBuffer, + subscription_registry: &SubscriptionRegistry, + long_lived_registry: &Arc, + subscription_id: Option, +) -> 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.as_deref(), + workspace.as_deref(), + context_id.as_deref(), + scheduler, + project, + subscription_registry, + long_lived_registry, + subscription_id, + ), + DaemonRequest::GetTaskOutput { task_id } => { + let lines: Vec + = output_buffer + .read() + .ok() + .and_then(|buffer| buffer.get(&task_id).cloned()) + .unwrap_or_default(); + + DaemonResponse::TaskOutput { task_id, lines } + } + DaemonRequest::StopTask { task_name, workspace } => { + handle_stop_task( + &task_name, + workspace.as_deref(), + project, + long_lived_registry, + ) + } + DaemonRequest::ListLongLivedTasks => { + handle_list_long_lived_tasks(project, long_lived_registry) + } + } +} diff --git a/packages/zpm/src/daemon/handlers/push_tasks.rs b/packages/zpm/src/daemon/handlers/push_tasks.rs new file mode 100644 index 00000000..68d2292d --- /dev/null +++ b/packages/zpm/src/daemon/handlers/push_tasks.rs @@ -0,0 +1,172 @@ +use std::sync::Arc; + +use zpm_primitives::Ident; +use zpm_tasks::{TaskId, TaskName}; + +use std::time::SystemTime; + +use super::super::ipc::{AttachedLongLivedTask, DaemonResponse, TaskSubscription, LONG_LIVED_CONTEXT_ID}; +use super::super::long_lived::LongLivedRegistry; +use super::super::scheduler::{format_contextual_task_id, Scheduler}; +use super::super::subscriptions::{SubscriptionId, SubscriptionRegistry}; +use crate::project::Project; + +pub fn handle_push_tasks( + tasks: &[TaskSubscription], + parent_task_id: Option<&str>, + workspace: Option<&str>, + context_id: Option<&str>, + scheduler: &Scheduler, + project: &Project, + subscription_registry: &SubscriptionRegistry, + long_lived_registry: &Arc, + subscription_id: Option, +) -> DaemonResponse { + let mut task_ids + = Vec::new(); + + let mut dependency_ids + = Vec::new(); + + let mut total_dependency_count + = 0; + + let mut attached_long_lived + = Vec::new(); + + for task_sub in tasks { + let task_id + = build_task_id(&task_sub.name, workspace, project); + + let is_long_lived + = task_id + .as_ref() + .and_then(|tid| check_if_long_lived(project, tid)) + .unwrap_or(false); + + if is_long_lived { + if let Some(ref tid) = task_id { + if let Some(existing) = long_lived_registry.get_existing(tid) { + task_ids.push(existing.contextual_task_id.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.contextual_task_id.clone(), + started_at_ms, + }); + + if let Some(sub_id) = subscription_id { + subscription_registry.add_tasks_to_subscription( + sub_id, + vec![existing.contextual_task_id], + vec![], + ); + } + + continue; + } + } + } + + let effective_context_id + = if is_long_lived { + Some(LONG_LIVED_CONTEXT_ID) + } else { + context_id + }; + + match scheduler.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); + + if is_long_lived { + if let Some(tid) = task_id { + long_lived_registry.register(tid, target_id_str.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); + } + } + + total_dependency_count += resolved_ctx_task_ids.len().saturating_sub(1); + } + Err(e) => { + return DaemonResponse::Error { + message: e.to_string(), + }; + } + } + } + + if let Some(sub_id) = subscription_id { + subscription_registry.add_tasks_to_subscription( + sub_id, + task_ids.clone(), + dependency_ids, + ); + } + + DaemonResponse::TasksEnqueued { + task_ids, + dependency_count: total_dependency_count, + attached_long_lived, + } +} + +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 }) +} + +fn check_if_long_lived(project: &Project, task_id: &TaskId) -> Option { + let workspace + = project.workspace_by_ident(&task_id.workspace).ok()?; + + let task_file_path + = workspace.taskfile_path(); + + let content + = task_file_path.fs_read_text().ok()?; + + let task_file + = zpm_tasks::parse(&content).ok()?; + + let task + = task_file.tasks.get(task_id.task_name.as_str())?; + + Some(task.attributes.iter().any(|attr| attr.name == "long-lived")) +} diff --git a/packages/zpm/src/daemon/handlers/stop_task.rs b/packages/zpm/src/daemon/handlers/stop_task.rs new file mode 100644 index 00000000..3dae36c9 --- /dev/null +++ b/packages/zpm/src/daemon/handlers/stop_task.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use zpm_primitives::Ident; +use zpm_tasks::{TaskId, TaskName}; + +use super::super::ipc::DaemonResponse; +use super::super::long_lived::LongLivedRegistry; +use crate::project::Project; + +pub fn handle_stop_task( + task_name: &str, + workspace: Option<&str>, + project: &Project, + long_lived_registry: &Arc, +) -> DaemonResponse { + let task_id + = match build_task_id(task_name, workspace, project) { + Some(tid) => tid, + None => { + return DaemonResponse::TaskStopped { + success: false, + error: Some(format!("Could not resolve task: {}", task_name)), + }; + } + }; + + let entry + = match long_lived_registry.get_existing(&task_id) { + Some(e) => e, + None => { + return DaemonResponse::TaskStopped { + success: false, + error: Some(format!("No running long-lived task found: {}", task_name)), + }; + } + }; + + if let Some(pid) = entry.process_id { + let _ = std::process::Command::new("kill") + .arg("-TERM") + .arg(pid.to_string()) + .status(); + + long_lived_registry.remove(&task_id); + + DaemonResponse::TaskStopped { + success: true, + error: None, + } + } else { + long_lived_registry.remove(&task_id); + + DaemonResponse::TaskStopped { + success: true, + error: Some("Task had no process ID, removed from registry".to_string()), + } + } +} + +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 }) +} diff --git a/packages/zpm/src/daemon/ipc.rs b/packages/zpm/src/daemon/ipc.rs new file mode 100644 index 00000000..c021287c --- /dev/null +++ b/packages/zpm/src/daemon/ipc.rs @@ -0,0 +1,175 @@ +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, +} + +#[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, + }, + 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, + }, + TaskFailed { + task_id: String, + error: 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/long_lived.rs b/packages/zpm/src/daemon/long_lived.rs new file mode 100644 index 00000000..16da824f --- /dev/null +++ b/packages/zpm/src/daemon/long_lived.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; +use std::sync::RwLock; +use std::time::SystemTime; + +use zpm_tasks::TaskId; + +#[derive(Debug, Clone)] +pub struct LongLivedEntry { + pub task_id: TaskId, + pub contextual_task_id: String, + pub warm_up_complete: bool, + pub process_id: Option, + pub started_at: SystemTime, +} + +struct LongLivedRegistryInner { + entries: HashMap, +} + +pub struct LongLivedRegistry { + inner: RwLock, +} + +impl LongLivedRegistry { + pub fn new() -> Self { + Self { + inner: RwLock::new(LongLivedRegistryInner { + entries: HashMap::new(), + }), + } + } + + pub fn register(&self, task_id: TaskId, contextual_task_id: String) { + let mut inner + = self.inner.write().unwrap(); + + inner.entries.insert( + task_id.clone(), + LongLivedEntry { + task_id, + contextual_task_id, + warm_up_complete: false, + process_id: None, + started_at: SystemTime::now(), + }, + ); + } + + pub fn get_existing(&self, task_id: &TaskId) -> Option { + let inner + = self.inner.read().unwrap(); + + inner.entries.get(task_id).cloned() + } + + pub fn set_process_id(&self, task_id: &TaskId, process_id: u32) { + let mut inner + = self.inner.write().unwrap(); + + if let Some(entry) = inner.entries.get_mut(task_id) { + entry.process_id = Some(process_id); + } + } + + pub fn mark_warm_up_complete(&self, task_id: &TaskId) -> bool { + let mut inner + = self.inner.write().unwrap(); + + if let Some(entry) = inner.entries.get_mut(task_id) { + entry.warm_up_complete = true; + true + } else { + false + } + } + + pub fn is_warm_up_complete(&self, task_id: &TaskId) -> bool { + let inner + = self.inner.read().unwrap(); + + inner + .entries + .get(task_id) + .map(|e| e.warm_up_complete) + .unwrap_or(false) + } + + pub fn remove(&self, task_id: &TaskId) -> Option { + let mut inner + = self.inner.write().unwrap(); + + inner.entries.remove(task_id) + } + + pub fn get_by_contextual_id(&self, contextual_task_id: &str) -> Option { + let inner + = self.inner.read().unwrap(); + + inner + .entries + .values() + .find(|e| e.contextual_task_id == contextual_task_id) + .cloned() + } + + pub fn list_all_entries(&self) -> Vec { + let inner + = self.inner.read().unwrap(); + + inner.entries.values().cloned().collect() + } +} diff --git a/packages/zpm/src/daemon/mod.rs b/packages/zpm/src/daemon/mod.rs new file mode 100644 index 00000000..216bf77e --- /dev/null +++ b/packages/zpm/src/daemon/mod.rs @@ -0,0 +1,28 @@ +mod client; +mod coordinator; +mod events; +mod executor; +mod handlers; +mod ipc; +mod long_lived; +mod presentation; +mod scheduler; +mod server; +mod subscriptions; + +pub use client::{DaemonClient, PushTasksResult, StandaloneDaemonHandle}; +pub use coordinator::run_daemon; +pub use events::{ExecutorEvent, SchedulerEvent, Stream}; +pub use executor::{ExecutorPool, OutputLine, TaskRunner}; +pub use handlers::dispatch_request; +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::{format_task_id, PreparedTask, Scheduler}; +pub use server::{bind_to_available_port, run_accept_loop, ConnectionContext}; +pub use long_lived::{LongLivedEntry, LongLivedRegistry}; +pub use subscriptions::{SubscriptionGuard, SubscriptionId, SubscriptionRegistry}; 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..5b087153 --- /dev/null +++ b/packages/zpm/src/daemon/scheduler/dependencies.rs @@ -0,0 +1,126 @@ +use std::collections::{BTreeMap, HashSet}; + +use zpm_tasks::ResolvedTasks; + +use super::state::{ContextualTaskId, PreparedTask}; + +/// 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. +pub fn find_ready_tasks( + resolved: &ResolvedTasks, + completed: &HashSet, + failed: &HashSet, + script_finished: &HashSet, + warm_up_complete: &HashSet, + running: &HashSet, + targets: &HashSet, + prepared: &BTreeMap, +) -> Vec { + // Collect all contexts that have pending work (including newly added targets) + let active_contexts: HashSet<&String> = completed + .iter() + .chain(failed.iter()) + .chain(script_finished.iter()) + .chain(running.iter()) + .chain(targets.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 &resolved.tasks { + let ctx_task_id = ContextualTaskId::new(task_id.clone(), context_id.clone()); + + // Skip if already completed, failed, finished, or running + if completed.contains(&ctx_task_id) + || failed.contains(&ctx_task_id) + || script_finished.contains(&ctx_task_id) + || 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 failed.contains(&ctx_prereq) { + return false; + } + + if completed.contains(&ctx_prereq) { + return true; + } + + let is_long_lived + = prepared + .get(&ctx_prereq) + .map(|p| p.is_long_lived) + .unwrap_or(false); + + if is_long_lived && warm_up_complete.contains(&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( + resolved: &ResolvedTasks, + completed: &HashSet, + failed: &HashSet, + running: &HashSet, +) -> Vec { + // Collect all contexts that have pending work + let active_contexts: HashSet<&String> = completed + .iter() + .chain(failed.iter()) + .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 &resolved.tasks { + let ctx_task_id = ContextualTaskId::new(task_id.clone(), context_id.clone()); + + // Skip if already completed, failed, or running + if completed.contains(&ctx_task_id) + || failed.contains(&ctx_task_id) + || running.contains(&ctx_task_id) + { + continue; + } + + // Check if any prerequisite failed (in the same context) + let any_prereq_failed = prerequisites.iter().any(|prereq| { + let ctx_prereq = ContextualTaskId::new(prereq.clone(), context_id.clone()); + failed.contains(&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..a4e2fb44 --- /dev/null +++ b/packages/zpm/src/daemon/scheduler/mod.rs @@ -0,0 +1,184 @@ +mod dependencies; +mod state; + +use std::collections::HashSet; +use std::sync::RwLock; + +use zpm_primitives::Ident; +use zpm_tasks::{TaskId, TaskName}; +use zpm_utils::ToFileString; + +pub use state::{ContextualTaskId, PreparedTask}; + +use crate::error::Error; +use crate::project::Project; + +use self::state::SchedulerState; + +pub struct Scheduler { + state: RwLock, +} + +impl Scheduler { + pub fn new() -> Self { + Self { + state: RwLock::new(SchedulerState::new()), + } + } + + pub fn add_task( + &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 mut state = self.state.write().unwrap(); + state.add_task(project, task_name, parent_task_id, args, workspace_override, context_id) + } + + pub fn ready_tasks(&self, running: &HashSet) -> Vec<(ContextualTaskId, Option)> { + let state + = self.state.read().unwrap(); + + let ready_ids + = dependencies::find_ready_tasks( + &state.resolved, + &state.completed, + &state.failed, + &state.script_finished, + &state.warm_up_complete, + running, + &state.targets, + &state.prepared, + ); + + ready_ids + .into_iter() + .map(|ctx_task_id| { + let prepared + = state.prepared.get(&ctx_task_id).cloned(); + + (ctx_task_id, prepared) + }) + .collect() + } + + pub fn tasks_to_fail(&self, running: &HashSet) -> Vec { + let state = self.state.read().unwrap(); + + dependencies::find_tasks_to_fail( + &state.resolved, + &state.completed, + &state.failed, + running, + ) + } + + pub fn mark_script_finished(&self, task_id: &ContextualTaskId) { + let mut state = self.state.write().unwrap(); + state.script_finished.insert(task_id.clone()); + } + + pub fn mark_completed(&self, task_id: &ContextualTaskId) { + let mut state = self.state.write().unwrap(); + state.completed.insert(task_id.clone()); + } + + pub fn mark_failed(&self, task_id: &ContextualTaskId) { + let mut state = self.state.write().unwrap(); + state.failed.insert(task_id.clone()); + state.completed.insert(task_id.clone()); + } + + pub fn try_complete_task(&self, task_id: &ContextualTaskId) -> bool { + let mut state = self.state.write().unwrap(); + state.try_complete_task(task_id) + } + + pub fn find_parents(&self, task_id: &ContextualTaskId) -> Vec { + let state = self.state.read().unwrap(); + state + .subtasks + .iter() + .filter(|(_, children)| children.contains(task_id)) + .map(|(parent, _)| parent.clone()) + .collect() + } + + pub fn all_targets_completed(&self) -> bool { + let state = self.state.read().unwrap(); + state.all_targets_completed() + } + + pub fn get_prepared_task(&self, task_id: &ContextualTaskId) -> Option { + let state = self.state.read().unwrap(); + state.prepared.get(task_id).cloned() + } + + pub fn has_prepared_task(&self, task_id: &ContextualTaskId) -> bool { + let state = self.state.read().unwrap(); + state.prepared.contains_key(task_id) + } + + pub fn parse_contextual_task_id(&self, 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(), + )) + } + + pub fn is_long_lived(&self, task_id: &ContextualTaskId) -> bool { + let state + = self.state.read().unwrap(); + + state + .prepared + .get(task_id) + .map(|p| p.is_long_lived) + .unwrap_or(false) + } + + pub fn mark_warm_up_complete(&self, task_id: &ContextualTaskId) { + let mut state + = self.state.write().unwrap(); + + state.warm_up_complete.insert(task_id.clone()); + } +} + +/// Format a TaskId (without context) as "workspace:taskname" +pub fn format_task_id(task_id: &TaskId) -> String { + format!( + "{}:{}", + task_id.workspace.to_file_string(), + task_id.task_name.as_str() + ) +} + +/// Format a ContextualTaskId as "workspace:taskname@context" +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 + ) +} diff --git a/packages/zpm/src/daemon/scheduler/state.rs b/packages/zpm/src/daemon/scheduler/state.rs new file mode 100644 index 00000000..514a6620 --- /dev/null +++ b/packages/zpm/src/daemon/scheduler/state.rs @@ -0,0 +1,306 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use zpm_primitives::Ident; +use zpm_tasks::{ResolvedTasks, TaskId, TaskName}; +use zpm_utils::{DataType, Path, ToFileString}; + +use super::super::presentation::prefix_colors; +use crate::error::Error; +use crate::project::Project; + +/// 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, +} + +pub struct SchedulerState { + /// Shared task definitions (keyed by TaskId, not context-specific) + pub resolved: ResolvedTasks, + /// Tasks that have been directly requested (context-specific) + pub targets: HashSet, + /// Tasks that have fully completed (context-specific) + pub completed: HashSet, + /// Tasks that have failed (context-specific) + pub failed: HashSet, + /// Tasks whose scripts have finished (context-specific) + pub script_finished: HashSet, + /// Long-lived tasks that have completed their warm-up period (context-specific) + pub warm_up_complete: HashSet, + /// Parent-child subtask relationships (context-specific) + pub subtasks: HashMap>, + /// Prepared task execution info (context-specific) + pub prepared: BTreeMap, +} + +impl SchedulerState { + pub fn new() -> Self { + Self { + resolved: ResolvedTasks { + tasks: BTreeMap::new(), + task_files: BTreeMap::new(), + }, + targets: HashSet::new(), + completed: HashSet::new(), + failed: HashSet::new(), + script_finished: HashSet::new(), + warm_up_complete: HashSet::new(), + subtasks: HashMap::new(), + prepared: BTreeMap::new(), + } + } + + 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 self.targets.contains(&ctx_task_id) && !self.completed.contains(&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.targets.insert(ctx_task_id.clone()); + + self.prepare_new_tasks(project, &ctx_id)?; + + 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)) + } + + pub fn prepare_new_tasks(&mut self, project: &Project, context_id: &str) -> Result { + let colors: Vec<&DataType> + = prefix_colors().take(5).collect(); + + let mut color_index + = self.prepared.len(); + + let mut new_count + = 0; + + let task_ids: Vec + = self.resolved.tasks.keys().cloned().collect(); + + for task_id in task_ids { + let ctx_task_id + = ContextualTaskId::new(task_id.clone(), context_id.to_string()); + + if self.prepared.contains_key(&ctx_task_id) { + continue; + } + + 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, + PreparedTask { + script, + cwd: workspace.path.clone(), + env, + prefix, + args: vec![], + is_long_lived, + }, + ); + + new_count += 1; + } + + Ok(new_count) + } + + pub fn is_task_fully_completed(&self, task_id: &ContextualTaskId) -> bool { + if !self.script_finished.contains(task_id) { + return false; + } + + if let Some(task_subtasks) = self.subtasks.get(task_id) { + task_subtasks.iter().all(|s| self.completed.contains(s)) + } else { + true + } + } + + pub fn try_complete_task(&mut self, task_id: &ContextualTaskId) -> bool { + if self.is_task_fully_completed(task_id) { + self.completed.insert(task_id.clone()); + true + } else { + false + } + } + + pub fn all_targets_completed(&self) -> bool { + self.targets.iter().all(|t| self.completed.contains(t)) + } + + fn clear_task_state(&mut self, task_id: &ContextualTaskId) { + self.completed.remove(task_id); + self.script_finished.remove(task_id); + self.failed.remove(task_id); + self.warm_up_complete.remove(task_id); + self.targets.remove(task_id); + self.subtasks.remove(task_id); + } + + 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()) + } +} diff --git a/packages/zpm/src/daemon/server/connection.rs b/packages/zpm/src/daemon/server/connection.rs new file mode 100644 index 00000000..40e22ca8 --- /dev/null +++ b/packages/zpm/src/daemon/server/connection.rs @@ -0,0 +1,207 @@ +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, RwLock}; + +use futures::stream::StreamExt; +use futures::SinkExt; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; + +use super::super::handlers::dispatch_request; +use super::super::ipc::{ + BufferedOutputLine, DaemonMessage, DaemonNotification, DaemonRequest, DaemonRequestEnvelope, + DaemonResponse, SubscriptionScope, +}; +use super::super::long_lived::LongLivedRegistry; +use super::super::scheduler::Scheduler; +use super::super::subscriptions::{SubscriptionGuard, SubscriptionRegistry}; +use crate::project::Project; + +pub type OutputBuffer = Arc>>>; + +pub struct ConnectionContext { + pub project: Arc, + pub scheduler: Arc, + pub subscription_registry: Arc, + pub output_buffer: OutputBuffer, + pub long_lived_registry: Arc, +} + +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(); + + let mut _subscription_guards: Vec + = Vec::new(); + + let mut notification_receivers: Vec> + = Vec::new(); + + loop { + let notification_future + = poll_notifications(&mut notification_receivers); + + tokio::select! { + biased; + + 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), + }, + ); + + let error_json + = serde_json::to_string(&error_response).unwrap(); + + write.send(Message::Text(error_json.into())).await.ok(); + continue; + } + }; + + let request_id + = envelope.request_id; + + let request + = envelope.request; + + let subscription_id + = if let DaemonRequest::PushTasks { + output_subscription, + status_subscription, + context_id, + .. + } = &request + { + if *output_subscription != SubscriptionScope::None + || *status_subscription != SubscriptionScope::None + { + let (sub_id, rx) + = ctx.subscription_registry.create_subscription( + *output_subscription, + *status_subscription, + context_id.clone(), + ); + + let guard + = SubscriptionGuard::new(sub_id, ctx.subscription_registry.clone()); + + _subscription_guards.push(guard); + notification_receivers.push(rx); + Some(sub_id) + } else { + None + } + } else { + None + }; + + let response + = dispatch_request( + request, + &ctx.scheduler, + &ctx.project, + &ctx.output_buffer, + &ctx.subscription_registry, + &ctx.long_lived_registry, + subscription_id, + ); + + 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(); + } + _ => {} + } + } + + 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; + } + } + } + } + + Ok(()) +} + +async fn poll_notifications( + receivers: &mut [mpsc::UnboundedReceiver], +) -> Option { + if receivers.is_empty() { + std::future::pending::>().await + } else { + futures::future::poll_fn(|cx| { + for rx in receivers.iter_mut() { + match rx.poll_recv(cx) { + std::task::Poll::Ready(Some(notif)) => { + return std::task::Poll::Ready(Some(notif)); + } + std::task::Poll::Ready(None) => {} + std::task::Poll::Pending => {} + } + } + 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..913dc612 --- /dev/null +++ b/packages/zpm/src/daemon/server/mod.rs @@ -0,0 +1,51 @@ +mod connection; + +use std::net::SocketAddr; +use std::sync::Arc; + +use tokio::net::TcpListener; +use super::ipc::DAEMON_BASE_PORT; + +pub use connection::{handle_connection, ConnectionContext, OutputBuffer}; + +use crate::error::Error; + +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()) +} + +pub async fn run_accept_loop( + listener: TcpListener, + ctx: Arc, +) { + loop { + match listener.accept().await { + Ok((stream, addr)) => { + let ctx_clone = ctx.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, addr, ctx_clone).await { + eprintln!("Error handling connection from {}: {}", addr, e); + } + }); + } + Err(e) => { + eprintln!("Failed to accept connection: {}", e); + } + } + } +} diff --git a/packages/zpm/src/daemon/subscriptions.rs b/packages/zpm/src/daemon/subscriptions.rs new file mode 100644 index 00000000..0308ccea --- /dev/null +++ b/packages/zpm/src/daemon/subscriptions.rs @@ -0,0 +1,188 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; + +use tokio::sync::mpsc; + +use super::ipc::{DaemonNotification, SubscriptionScope}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SubscriptionId(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::TaskFailed { 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 SubscriptionEntry { + filter: SubscriptionFilter, + sender: mpsc::UnboundedSender, +} + +struct SubscriptionRegistryInner { + subscriptions: HashMap, + next_id: u64, +} + +pub struct SubscriptionRegistry { + inner: RwLock, +} + +impl SubscriptionRegistry { + pub fn new() -> Self { + Self { + inner: RwLock::new(SubscriptionRegistryInner { + subscriptions: HashMap::new(), + next_id: 1, + }), + } + } + + pub fn create_subscription( + &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 mut inner + = self.inner.write().unwrap(); + + let id + = SubscriptionId(inner.next_id); + + inner.next_id += 1; + + inner.subscriptions.insert( + id, + SubscriptionEntry { filter, sender: tx }, + ); + + (id, rx) + } + + pub fn add_tasks_to_subscription( + &self, + subscription_id: SubscriptionId, + target_task_ids: Vec, + dependency_task_ids: Vec, + ) { + let mut inner + = self.inner.write().unwrap(); + + if let Some(entry) = inner.subscriptions.get_mut(&subscription_id) { + for task_id in target_task_ids { + entry.filter.add_target_task(task_id); + } + for task_id in dependency_task_ids { + entry.filter.add_dependency_task(task_id); + } + } + } + + pub fn remove_subscription(&self, subscription_id: SubscriptionId) { + let mut inner + = self.inner.write().unwrap(); + + inner.subscriptions.remove(&subscription_id); + } + + pub fn broadcast(&self, notification: DaemonNotification) { + let inner + = self.inner.read().unwrap(); + + for entry in inner.subscriptions.values() { + if entry.filter.matches(¬ification) { + let _ = entry.sender.send(notification.clone()); + } + } + } +} + +pub struct SubscriptionGuard { + subscription_id: SubscriptionId, + registry: Arc, +} + +impl SubscriptionGuard { + pub fn new(subscription_id: SubscriptionId, registry: Arc) -> Self { + Self { + subscription_id, + registry, + } + } + + pub fn id(&self) -> SubscriptionId { + self.subscription_id + } +} + +impl Drop for SubscriptionGuard { + fn drop(&mut self) { + self.registry.remove_subscription(self.subscription_id); + } +} diff --git a/packages/zpm/src/error.rs b/packages/zpm/src/error.rs index c1d1362b..3877aa01 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,9 @@ pub enum Error { #[error("Task push failed: {0}")] TaskPushFailed(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..babaa5c1 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 { @@ -698,14 +722,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 +749,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..e01fd817 100644 --- a/taskfile +++ b/taskfile @@ -1,2 +1,25 @@ +bar: + sleep 5 + +bar2: + sleep 10 + +x: + python3 -c "import time; print(f'ts:{int(time.time()*1000)}:line1')" + sleep 1 + python3 -c "import time; print(f'ts:{int(time.time()*1000)}:line2')" + sleep 1 + python3 -c "import time; print(f'ts:{int(time.time()*1000)}:line3')" + +producer: + for x in {1..10}; do + echo "producer: $x" + sleep 1 + done + +foo: bar& bar2& + echo "foo" + +@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..19b3f4ad --- /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..31f5aebf 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 @@ -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 push`, () => { @@ -6,7 +18,7 @@ describe(`Commands`, () => { `it should fail when not running inside a task context`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -14,18 +26,18 @@ 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`), }); - }), + })), ); test( `it should allow pushing a task from within a running task`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `setup:`, ` echo "setup-done"`, @@ -37,17 +49,17 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `trigger`); expect(stdout).toContain(`trigger-done`); expect(stdout).toContain(`setup-done`); - }), + })), ); test( `it should allow pushing multiple tasks at once`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `task-a:`, ` echo "task-a-done"`, @@ -62,18 +74,18 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `trigger`); expect(stdout).toContain(`trigger-done`); expect(stdout).toContain(`task-a-done`); expect(stdout).toContain(`task-b-done`); - }), + })), ); test( `it should fail when pushing a nonexistent task`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `trigger:`, ` set -e`, @@ -83,17 +95,17 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `trigger`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `trigger`)).rejects.toMatchObject({ code: 1, }); - }), + })), ); test( `it should wait for pushed tasks to complete before task run exits`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `slow-task:`, ` sleep 0.2 && echo "slow-task-done"`, @@ -105,17 +117,17 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `trigger`); expect(stdout).toContain(`trigger-done`); expect(stdout).toContain(`slow-task-done`); - }), + })), ); test( `it should handle pushed tasks with dependencies`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `dep-task:`, ` echo "dep-task-done"`, @@ -130,18 +142,18 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `trigger`); expect(stdout).toContain(`trigger-done`); expect(stdout).toContain(`dep-task-done`); expect(stdout).toContain(`main-task-done`); - }), + })), ); test( `it should fail the task run when a pushed task fails`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `failing-task:`, ` echo "about-to-fail"`, @@ -155,17 +167,17 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `trigger`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `trigger`)).rejects.toMatchObject({ code: 1, }); - }), + })), ); test( `it should not run the same task twice when pushed multiple times`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `counter:`, ` echo "counter-ran"`, @@ -178,11 +190,11 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `trigger`); + const {stdout} = await runSwitch(`tasks`, `run`, `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..d09ea706 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}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -14,16 +26,16 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `build`); expect(stdout).toEqual(`building\n`); - }), + })), ); test( `it should run a task with dependencies in order`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `setup:`, ` echo "setup"`, @@ -34,16 +46,16 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `build`); expect(stdout).toEqual(`setup\nbuild\n`); - }), + })), ); test( `it should show prefixes with verbose level 1`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -51,16 +63,16 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `-v`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `-v`, `build`); expect(stdout).toEqual(`[test-package:build]: building\n`); - }), + })), ); test( `it should show prologue and epilogue with verbose level 2`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -68,16 +80,16 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `-vv`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `-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`); - }), + })), ); test( `it should hide dependency output 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"`, @@ -88,16 +100,16 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `--silent-dependencies`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `--silent-dependencies`, `build`); expect(stdout).toEqual(`build-output\n`); - }), + })), ); test( `it should show dependency output on failure even 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-failure-output"`, @@ -109,18 +121,18 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `--silent-dependencies`, `build`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `--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 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 +143,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 +164,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}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `greet:`, ` echo "Hello $1"`, @@ -171,16 +183,16 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `greet`, `World`); + const {stdout} = await runSwitch(`tasks`, `run`, `greet`, `World`); expect(stdout).toEqual(`Hello World\n`); - }), + })), ); test( `it should fail when the task does not exist`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` echo "building"`, @@ -188,30 +200,30 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `nonexistent`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `nonexistent`)).rejects.toMatchObject({ code: 1, }); - }), + })), ); test( `it should fail when there is no taskfile`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await run(`install`); - await expect(run(`tasks`, `run`, `build`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `build`)).rejects.toMatchObject({ code: 1, }); - }), + })), ); test( `it should run parallel dependencies concurrently`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `task-a:`, ` sleep 0.1 && echo "task-a"`, @@ -225,13 +237,13 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `build`); + const {stdout} = await runSwitch(`tasks`, `run`, `build`); const lines = stdout.trim().split(`\n`); expect(lines).toHaveLength(3); expect([lines[0], lines[1]].sort()).toEqual([`task-a`, `task-b`]); expect(lines[2]).toEqual(`build`); - }), + })), ); test( @@ -242,7 +254,7 @@ describe(`Commands`, () => { }, { [`packages/pkg-a`]: {name: `pkg-a`}, [`packages/pkg-b`]: {name: `pkg-b`, dependencies: {[`pkg-a`]: `workspace:*`}}, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `packages/pkg-a/taskfile` as any), [ `build:`, ` echo "building-pkg-a"`, @@ -255,9 +267,9 @@ 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`, `build`, {cwd: ppath.join(path, `packages/pkg-b` as any)}); expect(stdout).toEqual(`building-pkg-a\nbuilding-pkg-b\n`); - }), + })), ); test( @@ -268,7 +280,7 @@ describe(`Commands`, () => { }, { [`packages/pkg-a`]: {name: `pkg-a`}, [`packages/pkg-b`]: {name: `pkg-b`, dependencies: {[`pkg-a`]: `workspace:*`}}, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `packages/pkg-a/taskfile` as any), [ `build:`, ` echo "building-pkg-a"`, @@ -281,16 +293,16 @@ 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`, `--silent-dependencies`, `build`, {cwd: ppath.join(path, `packages/pkg-b` as any)}); expect(stdout).toEqual(`building-pkg-b\n`); - }), + })), ); test( `it should hide pushed subtask output with --silent-dependencies`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `subtask:`, ` echo "subtask-output"`, @@ -302,16 +314,16 @@ describe(`Commands`, () => { await run(`install`); - const {stdout} = await run(`tasks`, `run`, `--silent-dependencies`, `main`); + const {stdout} = await runSwitch(`tasks`, `run`, `--silent-dependencies`, `main`); expect(stdout).toEqual(`main-output\n`); - }), + })), ); test( `it should return the exit code of the failed task`, makeTemporaryEnv({ name: `test-package`, - }, async ({path, run}) => { + }, cleanupDaemon(async ({path, run, runSwitch}) => { await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `build:`, ` exit 42`, @@ -319,10 +331,306 @@ describe(`Commands`, () => { await run(`install`); - await expect(run(`tasks`, `run`, `build`)).rejects.toMatchObject({ + await expect(runSwitch(`tasks`, `run`, `build`)).rejects.toMatchObject({ code: 42, }); - }), + })), + ); + + test( + `it should re-run the same task when called multiple times`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(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`, `build`); + expect(stdout1).toEqual(`run 1\n`); + + const {stdout: stdout2} = await runSwitch(`tasks`, `run`, `build`); + expect(stdout2).toEqual(`run 2\n`); + + const {stdout: stdout3} = await runSwitch(`tasks`, `run`, `build`); + expect(stdout3).toEqual(`run 3\n`); + })), ); + + test( + `it should stream log lines in real-time`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(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`, `stream-test`); + const endTime = Date.now(); + const totalTime = endTime - startTime; + + console.log(stdout); + + // 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) { + 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`, + }, cleanupDaemon(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`, `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 fail dependents if long-lived task fails before warm-up`, + makeTemporaryEnv({ + name: `test-package`, + }, cleanupDaemon(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`, `client`)).rejects.toMatchObject({ + code: 1, + }); + })), + ); + }); }); });