use std::{
env,
ffi::OsStr,
io,
os::fd::BorrowedFd,
path::{Path, PathBuf},
process::Command,
};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
#[cfg(unix)]
use rustix::io::{FdFlags, fcntl_getfd, fcntl_setfd};
use crate::release_set::artifact_root_path;
use super::{
error::IcpCommandError,
model::{IcpCli, LOCAL_ICP_TARGET, LocalReplicaTarget},
version::compatible_version_output,
};
pub const CANIC_ICP_IDENTITY_PASSWORD_FILE_ENV: &str = "CANIC_ICP_IDENTITY_PASSWORD_FILE";
const REQUEST_RUNTIME_WORKERS_ENV: &str = "TOKIO_WORKER_THREADS";
impl IcpCli {
#[must_use]
pub fn new(executable: impl Into<String>, environment: Option<String>) -> Self {
Self {
timing: std::sync::Arc::default(),
identity_lookup_millis: std::sync::Arc::default(),
identity_lookups: std::sync::Arc::default(),
remote_calls: std::sync::Arc::default(),
executable: executable.into(),
environment,
cwd: None,
local_replica: None,
inherited_fd: None,
identity_password_file: configured_identity_password_file(),
selected_identity: std::sync::Arc::default(),
compatible_version: std::sync::Arc::default(),
query_transport: std::sync::Arc::default(),
}
}
#[must_use]
pub(crate) fn remote_call_count(&self) -> u64 {
self.remote_calls.load(std::sync::atomic::Ordering::Relaxed)
}
pub(crate) fn identity_lookup_count(&self) -> u64 {
self.identity_lookups
.load(std::sync::atomic::Ordering::Relaxed)
}
pub(crate) fn identity_lookup_millis(&self) -> u64 {
self.identity_lookup_millis
.load(std::sync::atomic::Ordering::Relaxed)
}
pub(crate) fn record_remote_call(&self) {
self.remote_calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
#[must_use]
pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
let cwd = Some(cwd.into());
if self.cwd != cwd {
self.compatible_version = std::sync::Arc::default();
}
self.cwd = cwd;
self
}
#[must_use]
pub fn with_local_replica(mut self, target: Option<LocalReplicaTarget>) -> Self {
self.local_replica = target;
self
}
#[must_use]
pub const fn with_inherited_fd(mut self, inherited_fd: Option<i32>) -> Self {
self.inherited_fd = inherited_fd;
self
}
#[must_use]
pub fn with_identity_password_file(mut self, path: impl Into<PathBuf>) -> Self {
self.identity_password_file = Some(path.into());
self
}
#[must_use]
pub fn with_identity(mut self, identity: Option<&str>) -> Self {
if let Some(identity) = identity {
self.selected_identity =
std::sync::Arc::new(std::sync::OnceLock::from(identity.to_owned()));
}
self
}
#[must_use]
pub fn environment(&self) -> Option<&str> {
self.environment.as_deref()
}
#[must_use]
pub fn command(&self) -> Command {
let mut command = Command::new(&self.executable);
add_identity_password_file_arg(&mut command, self.identity_password_file.as_deref());
if let Some(cwd) = &self.cwd {
command.current_dir(cwd);
add_project_root_override_arg(&mut command, cwd);
}
configure_inherited_fd(&mut command, self.inherited_fd);
command
}
#[must_use]
pub fn command_in(&self, cwd: &Path) -> Command {
let mut command = Command::new(&self.executable);
add_identity_password_file_arg(&mut command, self.identity_password_file.as_deref());
command.current_dir(cwd);
add_project_root_override_arg(&mut command, cwd);
configure_inherited_fd(&mut command, self.inherited_fd);
command
}
#[must_use]
pub fn canister_command(&self) -> Command {
let mut command = self.request_command();
command.arg("canister");
command
}
pub(super) fn request_command(&self) -> Command {
let mut command = self.command();
configure_request_runtime(
&mut command,
env::var_os(REQUEST_RUNTIME_WORKERS_ENV).as_deref(),
);
command
}
pub(crate) fn add_target_args(&self, command: &mut Command) {
self.add_selected_identity_arg(command);
add_target_args(command, self.environment(), self.local_replica.as_ref());
}
pub(super) fn add_selected_identity_arg(&self, command: &mut Command) {
if let Some(identity) = self.selected_identity.get() {
command.arg("--identity").arg(identity);
}
}
}
fn configure_request_runtime(command: &mut Command, inherited: Option<&OsStr>) {
if inherited.is_none()
&& !command
.get_envs()
.any(|(key, _)| key == REQUEST_RUNTIME_WORKERS_ENV)
{
command.env(REQUEST_RUNTIME_WORKERS_ENV, "2");
}
}
fn configured_identity_password_file() -> Option<PathBuf> {
env::var_os(CANIC_ICP_IDENTITY_PASSWORD_FILE_ENV)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
}
fn add_identity_password_file_arg(command: &mut Command, path: Option<&Path>) {
if let Some(path) = path {
command.arg("--identity-password-file").arg(path);
}
}
pub(super) fn configure_inherited_fd(command: &mut Command, inherited_fd: Option<i32>) {
let Some(inherited_fd) = inherited_fd else {
return;
};
#[cfg(unix)]
unsafe {
command.pre_exec(move || {
let fd = BorrowedFd::borrow_raw(inherited_fd);
let mut flags = fcntl_getfd(fd).map_err(errno_to_io)?;
flags.remove(FdFlags::CLOEXEC);
fcntl_setfd(fd, flags).map_err(errno_to_io)
});
}
#[cfg(not(unix))]
let _ = (command, inherited_fd);
}
#[cfg(unix)]
fn errno_to_io(error: rustix::io::Errno) -> io::Error {
io::Error::from_raw_os_error(error.raw_os_error())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::test_support::temp_dir;
use rustix::fs::{FlockOperation, flock};
use std::{fs, time::Duration};
#[test]
fn request_runtime_default_preserves_explicit_settings_and_replica_context() {
let mut command = Command::new("icp");
configure_request_runtime(&mut command, None);
assert_eq!(
command
.get_envs()
.find(|(key, _)| *key == REQUEST_RUNTIME_WORKERS_ENV)
.map(|(_, value)| value),
Some(Some(OsStr::new("2")))
);
for value in [Some(OsStr::new("7")), Some(OsStr::new(""))] {
let mut inherited = Command::new("icp");
configure_request_runtime(&mut inherited, value);
assert!(
!inherited
.get_envs()
.any(|(key, _)| key == REQUEST_RUNTIME_WORKERS_ENV)
);
}
command.env(REQUEST_RUNTIME_WORKERS_ENV, "3");
configure_request_runtime(&mut command, None);
assert_eq!(
command
.get_envs()
.find(|(key, _)| *key == REQUEST_RUNTIME_WORKERS_ENV)
.map(|(_, value)| value),
Some(Some(OsStr::new("3")))
);
command.env_remove(REQUEST_RUNTIME_WORKERS_ENV);
configure_request_runtime(&mut command, None);
assert_eq!(
command
.get_envs()
.find(|(key, _)| *key == REQUEST_RUNTIME_WORKERS_ENV)
.map(|(_, value)| value),
Some(None)
);
let cli = IcpCli::new("icp", None);
for generic in [cli.command(), cli.command_in(Path::new("/tmp"))] {
assert!(
!generic
.get_envs()
.any(|(key, _)| key == REQUEST_RUNTIME_WORKERS_ENV)
);
}
}
#[test]
fn configured_descriptor_lives_through_command_descendants_only() {
let root = temp_dir("canic-icp-command-inherited-fd");
fs::create_dir_all(&root).expect("create temp root");
let lock_path = root.join("command.lock");
let owner = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.expect("open owner lock");
flock(&owner, FlockOperation::NonBlockingLockExclusive).expect("lock owner file");
let mut command = Command::new("sh");
command.args(["-c", "sleep 0.2 & wait"]);
configure_inherited_fd(&mut command, Some(std::os::fd::AsRawFd::as_raw_fd(&owner)));
let mut child = command.spawn().expect("spawn inherited command tree");
drop(owner);
let contender = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.expect("open contender lock");
assert_eq!(
flock(&contender, FlockOperation::NonBlockingLockExclusive),
Err(rustix::io::Errno::WOULDBLOCK)
);
child.wait().expect("wait for command tree");
for _ in 0..50 {
match flock(&contender, FlockOperation::NonBlockingLockExclusive) {
Ok(()) => {
fs::remove_dir_all(root).expect("remove temp root");
return;
}
Err(rustix::io::Errno::WOULDBLOCK) => {
std::thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("unexpected contender lock error: {error}"),
}
}
panic!("inherited descriptor survived the command tree");
}
}
pub(super) fn add_local_network_target(command: &mut Command) {
command.arg(LOCAL_ICP_TARGET);
}
pub fn add_target_args(
command: &mut Command,
environment: Option<&str>,
local_replica: Option<&LocalReplicaTarget>,
) {
if let Some(environment) = environment {
if let Some(local_replica) = local_replica
&& environment == local_replica.environment
{
command.env_remove("ICP_ENVIRONMENT");
command
.arg("-n")
.arg(&local_replica.url)
.arg("-k")
.arg(&local_replica.root_key);
return;
}
command.args(["-e", environment]);
}
}
pub(super) fn add_output_arg(command: &mut Command, output: &str) {
if output == "json" {
command.arg("--json");
} else {
command.args(["--output", output]);
}
}
pub(super) fn add_candid_arg(command: &mut Command, candid_path: Option<&Path>) {
if let Some(candid_path) = candid_path {
command.arg("--candid").arg(candid_path);
}
}
#[must_use]
pub fn local_canister_candid_path(
icp_root: &Path,
artifact_environment: &str,
role: &str,
) -> PathBuf {
artifact_root_path(icp_root, artifact_environment)
.join(role)
.join(format!("{role}.did"))
}
#[must_use]
pub fn existing_local_canister_candid_path(
icp_root: &Path,
artifact_environment: &str,
role: &str,
) -> Option<PathBuf> {
let path = local_canister_candid_path(icp_root, artifact_environment, role);
path.is_file().then_some(path)
}
pub(super) fn add_debug_arg(command: &mut Command, debug: bool) {
if debug {
command.arg("--debug");
}
}
pub(super) fn ensure_command_compatible(command: &Command) -> Result<(), IcpCommandError> {
let executable = command.get_program().to_string_lossy();
compatible_version_output(executable.as_ref(), command.get_current_dir()).map(|_| ())
}
fn add_project_root_override_arg(command: &mut Command, cwd: &Path) {
command.arg("--project-root-override").arg(cwd);
}
#[must_use]
pub fn command_display(command: &Command) -> String {
let mut parts = vec![command.get_program().to_string_lossy().to_string()];
parts.extend(
command
.get_args()
.map(|arg| arg.to_string_lossy().to_string()),
);
parts.join(" ")
}