diff --git a/Cargo.lock b/Cargo.lock index 795f89119..dc0d5c791 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,7 +301,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -2472,6 +2472,7 @@ dependencies = [ "slog", "snafu", "strum 0.27.2", + "sysinfo", "thiserror 2.0.17", "time", "tiny-bip39 2.0.0", @@ -2536,6 +2537,7 @@ dependencies = [ "serde_yaml", "serial_test", "snafu", + "sysinfo", "thiserror 2.0.17", "tiny-bip39 2.0.0", "tokio", @@ -3160,6 +3162,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -3231,6 +3242,25 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -4969,6 +4999,20 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "tap" version = "1.0.1" @@ -5745,12 +5789,114 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5784,7 +5930,7 @@ version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -5809,7 +5955,7 @@ version = "0.53.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" dependencies = [ - "windows-link", + "windows-link 0.2.0", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -5820,6 +5966,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 6aeb57e0c..ae1edc0df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ shellwords = "1.1.0" slog = "2.7.0" snafu = "0.8.5" strum = { version = "0.27", features = ["derive"] } +sysinfo = "0.37.2" tempfile = "3" thiserror = "2.0.16" time = "0.3.9" diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 5d1e06c29..d70d162d4 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -46,6 +46,7 @@ serde_json.workspace = true serde_yaml.workspace = true serde.workspace = true snafu.workspace = true +sysinfo.workspace = true thiserror.workspace = true tiny-bip39.workspace = true tokio.workspace = true diff --git a/crates/icp-cli/src/commands/network/mod.rs b/crates/icp-cli/src/commands/network/mod.rs index 4d7a50926..ce5b3d482 100644 --- a/crates/icp-cli/src/commands/network/mod.rs +++ b/crates/icp-cli/src/commands/network/mod.rs @@ -3,10 +3,12 @@ use clap::Subcommand; pub(crate) mod list; pub(crate) mod ping; pub(crate) mod run; +pub(crate) mod stop; #[derive(Subcommand, Debug)] pub(crate) enum Command { List(list::ListArgs), Ping(ping::PingArgs), Run(run::RunArgs), + Stop(stop::Cmd), } diff --git a/crates/icp-cli/src/commands/network/run.rs b/crates/icp-cli/src/commands/network/run.rs index 4e2b8c974..39b716db5 100644 --- a/crates/icp-cli/src/commands/network/run.rs +++ b/crates/icp-cli/src/commands/network/run.rs @@ -1,9 +1,23 @@ +use std::{ + io::{BufRead, BufReader}, + process::{Child, Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::Duration, +}; + use clap::Args; +use ic_agent::{Agent, AgentError}; use icp::{ identity::manifest::{LoadIdentityManifestError, load_identity_list}, manifest, network::{Configuration, NetworkDirectory, RunNetworkError, run_network}, }; +use sysinfo::Pid; +use tracing::debug; use crate::commands::{Context, Mode}; @@ -13,6 +27,11 @@ pub(crate) struct RunArgs { /// Name of the network to run #[arg(default_value = "local")] name: String, + + /// Starts the network in a background process. This command will exit once the network is running. + /// To stop the network, use 'icp network stop'. + #[arg(long)] + background: bool, } #[derive(Debug, thiserror::Error)] @@ -23,17 +42,26 @@ pub(crate) enum CommandError { #[error(transparent)] Locate(#[from] manifest::LocateError), + #[error(transparent)] + Agent(#[from] AgentError), + #[error("project does not contain a network named '{name}'")] Network { name: String }, #[error("network '{name}' must be a managed network")] Unmanaged { name: String }, + #[error("timed out waiting for network to start: {err}")] + Timeout { err: String }, + #[error(transparent)] Identities(#[from] LoadIdentityManifestError), #[error(transparent)] RunNetwork(#[from] RunNetworkError), + + #[error(transparent)] + SavePid(#[from] icp::network::SavePidError), } pub(crate) async fn exec(ctx: &Context, args: &RunArgs) -> Result<(), CommandError> { @@ -72,6 +100,8 @@ pub(crate) async fn exec(ctx: &Context, args: &RunArgs) -> Result<(), CommandErr &ndir, // network_root &ctx.dirs.port_descriptor(), // port_descriptor_dir ); + nd.ensure_exists() + .map_err(|e| RunNetworkError::CreateDirFailed { source: e })?; // Identities let ids = load_identity_list(&ctx.dirs.identity())?; @@ -79,18 +109,158 @@ pub(crate) async fn exec(ctx: &Context, args: &RunArgs) -> Result<(), CommandErr // Determine ICP accounts to seed let seed_accounts = ids.identities.values().map(|id| id.principal()); - eprintln!("Project root: {pdir}"); - eprintln!("Network root: {ndir}"); + debug!("Project root: {pdir}"); + debug!("Network root: {ndir}"); - run_network( - cfg, // config - nd, // nd - pdir, // project_root - seed_accounts, // seed_accounts - ) - .await?; + if args.background { + let mut child = run_in_background()?; + nd.save_background_network_runner_pid(Pid::from(child.id() as usize))?; + relay_child_output_until_healthy(ctx, &mut child, &nd).await?; + } else { + run_network( + cfg, // config + nd, // nd + pdir, // project_root + seed_accounts, // seed_accounts + ) + .await?; + } } }; + Ok(()) +} + +async fn relay_child_output_until_healthy( + ctx: &Context, + child: &mut Child, + nd: &NetworkDirectory, +) -> Result<(), CommandError> { + let stdout = child.stdout.take().expect("Failed to take child stdout"); + let stderr = child.stderr.take().expect("Failed to take child stderr"); + + let stop_printing_child_output = Arc::new(AtomicBool::new(false)); + + // Spawn threads to relay output + let term = ctx.term.clone(); + let should_stop_clone = Arc::clone(&stop_printing_child_output); + let stdout_thread = thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + if should_stop_clone.load(Ordering::Relaxed) { + break; + } + if let Ok(line) = line { + let _ = term.write_line(&line); + } + } + }); + + let term = ctx.term.clone(); + let should_stop_clone = Arc::clone(&stop_printing_child_output); + let stderr_thread = thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines() { + if should_stop_clone.load(Ordering::Relaxed) { + break; + } + if let Ok(line) = line { + let _ = term.write_line(&line); + } + } + }); + + wait_for_healthy_network(nd).await?; + + // Signal threads to stop + stop_printing_child_output.store(true, Ordering::Relaxed); + + // Don't join the threads - they're likely blocked on I/O waiting for the next line. + // They'll terminate naturally when the pipes close, or when the next line arrives. + drop(stdout_thread); + drop(stderr_thread); Ok(()) } + +#[allow(clippy::result_large_err)] +fn run_in_background() -> Result { + let exe = std::env::current_exe().expect("Failed to get current executable."); + let mut cmd = Command::new(exe); + // Skip 1 because arg0 is this executable's path. + cmd.args(std::env::args().skip(1).filter(|a| !a.eq("--background"))) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) // Capture stdout so we can relay it + .stderr(Stdio::piped()); // Capture stderr so we can relay it + + // On Unix, create a new process group so the child can continue running + // independently after the run command exits + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + + let child = cmd.spawn().expect("Failed to spawn child process."); + Ok(child) +} + +async fn retry_with_timeout(mut f: F, max_retries: usize, delay_ms: u64) -> Option +where + F: FnMut() -> Fut, + Fut: std::future::Future> + Send, +{ + let mut retries = 0; + loop { + if let Some(result) = f().await { + return Some(result); + } + if retries > max_retries { + return None; + } + retries += 1; + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } +} + +async fn wait_for_healthy_network(nd: &NetworkDirectory) -> Result<(), CommandError> { + let max_retries = 30; + let delay_ms = 1000; + + // Wait for network descriptor to be written + let network = retry_with_timeout( + || async move { nd.load_network_descriptor().unwrap_or(None) }, + max_retries, + delay_ms, + ) + .await + .ok_or(CommandError::Timeout { + err: "timed out waiting for network descriptor".to_string(), + })?; + + // Wait for network to report itself healthy + let port = network.gateway.port; + let agent = Agent::builder() + .with_url(format!("http://127.0.0.1:{port}")) + .build()?; + retry_with_timeout( + || { + let agent = agent.clone(); + async move { + let status = agent.status().await; + if let Ok(status) = status + && matches!(&status.replica_health_status, Some(status) if status == "healthy") + { + return Some(()); + } + + None + } + }, + max_retries, + delay_ms, + ) + .await + .ok_or(CommandError::Timeout { + err: "timed out waiting for network to start".to_string(), + }) +} diff --git a/crates/icp-cli/src/commands/network/stop.rs b/crates/icp-cli/src/commands/network/stop.rs new file mode 100644 index 000000000..bbfbb42fc --- /dev/null +++ b/crates/icp-cli/src/commands/network/stop.rs @@ -0,0 +1,117 @@ +use std::time::Duration; + +use clap::Parser; +use icp::{fs::remove_file, manifest, network::NetworkDirectory}; +use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; + +use crate::commands::{Context, Mode}; + +const TIMEOUT_SECS: u64 = 30; + +/// Stop a background network +#[derive(Parser, Debug)] +pub struct Cmd { + /// Name of the network to stop + #[arg(default_value = "local")] + name: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Project(#[from] icp::LoadError), + + #[error(transparent)] + Locate(#[from] manifest::LocateError), + + #[error("project does not contain a network named '{name}'")] + Network { name: String }, + + #[error("network '{name}' is not running in the background")] + NotRunning { name: String }, + + #[error(transparent)] + LoadPid(#[from] icp::network::LoadPidError), + + #[error("process {pid} did not exit within {timeout} seconds")] + Timeout { pid: Pid, timeout: u64 }, +} + +pub async fn exec(ctx: &Context, cmd: &Cmd) -> Result<(), CommandError> { + match &ctx.mode { + Mode::Global => { + unimplemented!("global mode is not implemented yet"); + } + + Mode::Project(pdir) => { + // Load project + let p = ctx.project.load().await?; + + // Check network exists + p.networks.get(&cmd.name).ok_or(CommandError::Network { + name: cmd.name.clone(), + })?; + + // Network root + let nroot = pdir.join(".icp").join("networks").join(&cmd.name); + + // Network directory + let nd = NetworkDirectory::new( + &cmd.name, // name + &nroot, // network_root + &ctx.dirs.port_descriptor(), // port_descriptor_dir + ); + + // Load PID from file + let pid = nd + .load_background_network_runner_pid()? + .ok_or(CommandError::NotRunning { + name: cmd.name.clone(), + })?; + + let _ = ctx + .term + .write_line(&format!("Stopping background network (PID: {})...", pid)); + + send_sigint(pid); + wait_for_process_exit(pid)?; + + let pid_file = nd.structure.background_network_runner_pid_file(); + let _ = remove_file(&pid_file); // Cleanup is nice, but optional + + let _ = ctx.term.write_line("Network stopped successfully"); + + Ok(()) + } + } +} + +fn send_sigint(pid: Pid) { + let mut system = System::new(); + system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + if let Some(process) = system.process(pid) { + process.kill_with(Signal::Interrupt); + } +} + +fn wait_for_process_exit(pid: Pid) -> Result<(), CommandError> { + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(TIMEOUT_SECS); + let mut system = System::new(); + + loop { + system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + if system.process(pid).is_none() { + return Ok(()); + } + + if start.elapsed() > timeout { + return Err(CommandError::Timeout { + pid, + timeout: TIMEOUT_SECS, + }); + } + + std::thread::sleep(Duration::from_millis(100)); + } +} diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index d35658837..397b7061a 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -420,6 +420,12 @@ async fn main() -> Result<(), Error> { .instrument(trace_span) .await? } + + commands::network::Command::Stop(args) => { + commands::network::stop::exec(&ctx, &args) + .instrument(trace_span) + .await? + } }, // Project diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index bd4873a2b..5ef348778 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -10,11 +10,15 @@ use predicates::{ str::{PredicateStrExt, contains}, }; use serial_test::file_serial; +use sysinfo::{Pid, ProcessesToUpdate, System}; use crate::common::{ ENVIRONMENT_RANDOM_PORT, NETWORK_RANDOM_PORT, TestContext, TestNetwork, clients, }; -use icp::{fs::write_string, prelude::*}; +use icp::{ + fs::{read_to_string, write_string}, + prelude::*, +}; mod common; @@ -293,6 +297,90 @@ fn network_seeds_preexisting_identities_icp_and_cycles_balances() { .success(); } +#[tokio::test] +async fn network_run_and_stop_background() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + // Project manifest + write_string(&project_dir.join("icp.yaml"), NETWORK_RANDOM_PORT) + .expect("failed to write project manifest"); + + // Start network in background and verify we can see child process output + ctx.icp() + .current_dir(&project_dir) + .args(["network", "run", "my-network", "--background"]) + .assert() + .success() + .stdout(contains("Created instance with id")); // part of network start output + + let network = ctx.wait_for_network_descriptor(&project_dir, "my-network"); + + // Verify PID file was written + let pid_file_path = project_dir + .join(".icp") + .join("networks") + .join("my-network") + .join("background_network_runner.pid"); + assert!( + pid_file_path.exists(), + "PID file should exist at {:?}", + pid_file_path + ); + + let pid_contents = read_to_string(&pid_file_path).expect("Failed to read PID file"); + let background_controller_pid: Pid = pid_contents + .trim() + .parse() + .expect("PID file should contain a valid process ID"); + + // Verify network is healthy with agent.status() + let agent = ic_agent::Agent::builder() + .with_url(format!("http://127.0.0.1:{}", network.gateway_port)) + .build() + .expect("Failed to build agent"); + + let status = agent.status().await.expect("Failed to get network status"); + assert!( + matches!(&status.replica_health_status, Some(health) if health == "healthy"), + "Network should be healthy" + ); + + // Stop the network + ctx.icp() + .current_dir(&project_dir) + .args(["network", "stop", "my-network"]) + .assert() + .success() + .stdout(contains(format!( + "Stopping background network (PID: {})", + background_controller_pid + ))) + .stdout(contains("Network stopped successfully")); + + // Verify PID file is removed + assert!( + !pid_file_path.exists(), + "PID file should be removed after stopping" + ); + + // Verify controller process is no longer running + let mut system = System::new(); + system.refresh_processes(ProcessesToUpdate::Some(&[background_controller_pid]), true); + assert!( + system.process(background_controller_pid).is_none(), + "Process should no longer be running" + ); + + // Verify network is no longer reachable + let status_result = agent.status().await; + assert!( + status_result.is_err(), + "Network should not be reachable after stopping" + ); +} + #[tokio::test] async fn network_starts_with_canisters_preset() { let ctx = TestContext::new(); diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 75fccb0fa..f9240fbfb 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -47,6 +47,7 @@ shellwords = { workspace = true } slog = { workspace = true } snafu = { workspace = true } strum = { workspace = true } +sysinfo = { workspace = true } thiserror = { workspace = true } time = { workspace = true } tiny-bip39 = { workspace = true } diff --git a/crates/icp/src/network/directory.rs b/crates/icp/src/network/directory.rs index 0b39bbeab..a9c04ce1a 100644 --- a/crates/icp/src/network/directory.rs +++ b/crates/icp/src/network/directory.rs @@ -1,9 +1,10 @@ -use std::io::ErrorKind; +use std::io::{ErrorKind, Seek, Write}; use snafu::prelude::*; +use sysinfo::Pid; use crate::{ - fs::{create_dir_all, json}, + fs::{create_dir_all, json, read_to_string}, network::{ config::NetworkDescriptorModel, lock::{AcquireWriteLockError, OpenFileForWriteLockError, RwFileLock}, @@ -143,6 +144,45 @@ impl NetworkDirectory { } Ok(()) } + + fn open_background_runner_pid_file_for_writelock( + &self, + ) -> Result { + RwFileLock::open_for_write(self.structure.background_network_runner_pid_file()) + } + + pub fn save_background_network_runner_pid(&self, pid: Pid) -> Result<(), SavePidError> { + let mut file_lock = self.open_background_runner_pid_file_for_writelock()?; + let mut write_guard = file_lock.acquire_write_lock()?; + + // Truncate the file first + write_guard.set_len(0).context(TruncatePidFileSnafu { + path: self.structure.background_network_runner_pid_file(), + })?; + (*write_guard) + .seek(std::io::SeekFrom::Start(0)) + .context(TruncatePidFileSnafu { + path: self.structure.background_network_runner_pid_file(), + })?; + + // Write the PID + write!(*write_guard, "{}", pid).context(WritePidSnafu { + path: self.structure.background_network_runner_pid_file(), + })?; + + Ok(()) + } + + pub fn load_background_network_runner_pid(&self) -> Result, LoadPidError> { + let path = self.structure.background_network_runner_pid_file(); + + read_to_string(&path) + .map(|content| content.trim().parse::().ok()) + .or_else(|err| match err.kind() { + ErrorKind::NotFound => Ok(None), + _ => Err(err).context(ReadPidSnafu { path: path.clone() }), + }) + } } #[derive(Debug, Snafu)] @@ -171,3 +211,33 @@ pub enum CleanupNetworkDescriptorError { #[snafu(transparent)] AcquireWriteLock { source: AcquireWriteLockError }, } + +#[derive(Debug, Snafu)] +pub enum SavePidError { + #[snafu(transparent)] + OpenFileForWriteLock { source: OpenFileForWriteLockError }, + + #[snafu(transparent)] + AcquireWriteLock { source: AcquireWriteLockError }, + + #[snafu(display("failed to truncate PID file at {path}"))] + TruncatePidFile { + source: std::io::Error, + path: PathBuf, + }, + + #[snafu(display("failed to write PID to {path}"))] + WritePid { + source: std::io::Error, + path: PathBuf, + }, +} + +#[derive(Debug, Snafu)] +pub enum LoadPidError { + #[snafu(display("failed to read PID from {path}"))] + ReadPid { + source: crate::fs::Error, + path: PathBuf, + }, +} diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index e9a25576f..02f455e15 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -17,7 +17,7 @@ use pocket_ic::{ }; use reqwest::Url; use snafu::prelude::*; -use std::{env::var, fs::read_to_string, process::ExitStatus, time::Duration}; +use std::{env::var, fs::read_to_string, io::Write, process::ExitStatus, time::Duration}; use tokio::{process::Child, select, signal::ctrl_c, time::sleep}; use uuid::Uuid; @@ -177,19 +177,27 @@ pub enum RunPocketIcError { WaitForPort { source: WaitForPortError }, } +#[derive(Debug)] pub enum ShutdownReason { CtrlC, ChildExited, } +/// Write to stderr, ignoring any errors. This is safe to use even when stderr is closed +/// (e.g., in a background process after the parent exits), unlike eprintln! which panics. +fn safe_eprintln(msg: &str) { + let _ = std::io::stderr().write_all(msg.as_bytes()); + let _ = std::io::stderr().write_all(b"\n"); +} + async fn wait_for_shutdown(child: &mut Child) -> ShutdownReason { select!( _ = ctrl_c() => { - eprintln!("Received Ctrl-C, shutting down PocketIC..."); + safe_eprintln("Received Ctrl-C, shutting down PocketIC..."); ShutdownReason::CtrlC } res = notice_child_exit(child) => { - eprintln!("PocketIC exited with status: {:?}", res.status); + safe_eprintln(&format!("PocketIC exited with status: {:?}", res.status)); ShutdownReason::ChildExited } ) diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 5fada375f..132eaa72e 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize}; -pub use directory::NetworkDirectory; +pub use directory::{LoadPidError, NetworkDirectory, SavePidError}; pub use managed::run::{RunNetworkError, run_network}; use crate::{ diff --git a/crates/icp/src/network/structure.rs b/crates/icp/src/network/structure.rs index 93e300719..bd78122b5 100644 --- a/crates/icp/src/network/structure.rs +++ b/crates/icp/src/network/structure.rs @@ -46,4 +46,10 @@ impl NetworkDirectoryStructure { pub fn pocketic_port_file(&self) -> PathBuf { self.pocketic_dir().join("port") } + + /// When running a network in the background, we store the PID of the background controlling `icp` process here. + /// This does _not_ contain pocket-ic processess. + pub fn background_network_runner_pid_file(&self) -> PathBuf { + self.network_root.join("background_network_runner.pid") + } } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7608adc5c..6f1cf3489 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -37,6 +37,7 @@ This document contains the help content for the `icp-cli` command-line program. * [`icp-cli network list`↴](#icp-cli-network-list) * [`icp-cli network ping`↴](#icp-cli-network-ping) * [`icp-cli network run`↴](#icp-cli-network-run) +* [`icp-cli network stop`↴](#icp-cli-network-stop) * [`icp-cli sync`↴](#icp-cli-sync) * [`icp-cli token`↴](#icp-cli-token) * [`icp-cli token balance`↴](#icp-cli-token-balance) @@ -526,6 +527,7 @@ Launch and manage local test networks * `list` — List networks in the project * `ping` — Try to connect to a network, and print out its status * `run` — Run a given network +* `stop` — Stop a background network @@ -559,7 +561,7 @@ Try to connect to a network, and print out its status Run a given network -**Usage:** `icp-cli network run [NAME]` +**Usage:** `icp-cli network run [OPTIONS] [NAME]` ###### **Arguments:** @@ -567,6 +569,24 @@ Run a given network Default value: `local` +###### **Options:** + +* `--background` — Starts the network in a background process. This command will exit once the network is running. To stop the network, use 'icp network stop' + + + +## `icp-cli network stop` + +Stop a background network + +**Usage:** `icp-cli network stop [NAME]` + +###### **Arguments:** + +* `` — Name of the network to stop + + Default value: `local` + ## `icp-cli sync`