diff options
author | Wolf Vollprecht <w.vollprecht@gmail.com> | 2023-09-27 15:48:00 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-27 15:48:00 +0200 |
commit | ff3600540a23d8719e5ade6ceb23d0a8eb9a87ba (patch) | |
tree | b0d7cf6a754e46eab1f6b95765f00ee77b6a3ee0 | |
parent | 96809ceee7b6f086d8b7dcac6680616b0c68b73e (diff) |
Change how `shell` works and make activation more robust (#316)
For `shell`, we used to run the activation script and then start the
shell.
The problem was that the shell also runs a `bashrc`, `zshrc` or similar
file after starting. So any PATH modifications (or env var
modifications) we had done might have been un-done by the `bashrc` file.
This PR changes how `shell` works. It first starts the shell (that runs
all `bashrc` etc. commands) and then sources an activation script.
This is only possible with some UNIX foo (forking and mirroring the
shell).
We copied the approach from `poetry` that uses `pexpect`. We forked
parts of `rexpect` here to make it work.
---------
Co-authored-by: Bas Zalmstra <zalmstra.bas@gmail.com>
Co-authored-by: Ruben Arts <ruben.arts@hotmail.com>
-rw-r--r-- | Cargo.lock | 48 | ||||
-rw-r--r-- | Cargo.toml | 20 | ||||
-rw-r--r-- | src/cli/run.rs | 22 | ||||
-rw-r--r-- | src/cli/shell.rs | 202 | ||||
-rw-r--r-- | src/lib.rs | 2 | ||||
-rw-r--r-- | src/project/environment.rs | 16 | ||||
-rw-r--r-- | src/unix/mod.rs | 5 | ||||
-rw-r--r-- | src/unix/pty_process.rs | 303 | ||||
-rw-r--r-- | src/unix/pty_session.rs | 167 | ||||
-rw-r--r-- | tests/common/mod.rs | 4 |
10 files changed, 710 insertions, 79 deletions
@@ -258,6 +258,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1181e1e0d1fce796a03db1ae795d67167da795f9cf4a39c37589e85ef57f26d3" [[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1096,6 +1107,15 @@ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" @@ -1330,7 +1350,7 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.2", "libc", "windows-sys 0.48.0", ] @@ -1356,7 +1376,7 @@ version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.2", "rustix 0.38.13", "windows-sys 0.48.0", ] @@ -1690,6 +1710,17 @@ dependencies = [ ] [[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.0", + "cfg-if", + "libc", +] + +[[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1800,7 +1831,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.2", "libc", ] @@ -1984,6 +2015,7 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" name = "pixi" version = "0.4.0" dependencies = [ + "atty", "chrono", "clap", "clap-verbosity-flag", @@ -1993,13 +2025,15 @@ dependencies = [ "dirs", "dunce", "futures 0.3.28", - "indexmap 1.9.3", + "indexmap 2.0.0", "indicatif", "insta", "is_executable", "itertools", + "libc", "miette", "minijinja", + "nix 0.27.1", "once_cell", "rattler", "rattler_conda_types", @@ -2014,9 +2048,11 @@ dependencies = [ "serde_json", "serde_spanned", "serde_with", + "signal-hook", "spdx", "strsim", "tempfile", + "thiserror", "tokio", "tokio-util", "toml", @@ -3844,7 +3880,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2769203cd13a0c6015d515be729c526d041e9cf2c0cc478d57faee85f40c6dcd" dependencies = [ - "nix", + "nix 0.26.4", "winapi", ] @@ -3881,7 +3917,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.26.4", "once_cell", "ordered-stream", "rand", @@ -16,6 +16,7 @@ rustls-tls = ["reqwest/rustls-tls", "reqwest/rustls-tls-native-roots", "rattler_ slow_integration_tests = [] [dependencies] +atty = "0.2" chrono = "0.4.28" clap = { version = "4.4.2", default-features = false, features = ["derive", "usage", "wrap_help", "std", "color", "error-context"] } clap-verbosity-flag = "2.0.1" @@ -26,7 +27,7 @@ deno_task_shell = "0.13.2" dirs = "5.0.1" dunce = "1.0.4" futures = "0.3.28" -indexmap = { version = "1.9.3", features = ["serde"] } +indexmap = { version = "2.0.0", features = ["serde"] } indicatif = "0.17.6" insta = { version = "1.31.0", features = ["yaml"] } is_executable = "1.0.1" @@ -50,6 +51,7 @@ serde_with = { version = "3.3.0", features = ["indexmap"] } spdx = "0.10.2" strsim = "0.10.0" tempfile = "3.8.0" +thiserror = "1.0.47" tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread", "signal"] } tokio-util = "0.7.8" toml_edit = { version = "0.19.14", features = ["serde"] } @@ -57,13 +59,18 @@ tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } url = "2.4.1" +[target.'cfg(unix)'.dependencies] +nix = { version = "0.27.1", default-features = false, features = ["fs", "signal", "term", "poll"] } +libc = { version = "0.2.147", default-features = false } +signal-hook = "0.3.17" + [dev-dependencies] rattler_digest = "0.9.0" serde_json = "1.0.105" tokio = { version = "1.32.0", features = ["rt"] } toml = "0.7.6" -#[patch.crates-io] +[patch.crates-io] #rattler = { git = "https://github.com/mamba-org/rattler", branch = "main" } #rattler_conda_types = { git = "https://github.com/mamba-org/rattler", branch = "main" } #rattler_digest = { git = "https://github.com/mamba-org/rattler", branch = "main" } @@ -72,3 +79,12 @@ toml = "0.7.6" #rattler_shell = { git = "https://github.com/mamba-org/rattler", branch = "main" } #rattler_solve = { git = "https://github.com/mamba-org/rattler", branch = "main" } #rattler_virtual_packages = { git = "https://github.com/mamba-org/rattler", branch = "main" } + +#rattler = { path = "../rattler/crates/rattler" } +#rattler_conda_types = { path = "../rattler/crates/rattler_conda_types" } +#rattler_digest = { path = "../rattler/crates/rattler_digest" } +#rattler_networking = { path = "../rattler/crates/rattler_networking" } +#rattler_repodata_gateway = { path = "../rattler/crates/rattler_repodata_gateway" } +#rattler_shell = { path = "../rattler/crates/rattler_shell" } +#rattler_solve = { path = "../rattler/crates/rattler_solve" } +#rattler_virtual_packages = { path = "../rattler/crates/rattler_virtual_packages" } diff --git a/src/cli/run.rs b/src/cli/run.rs index 85d1b8d..966cc20 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -222,13 +222,7 @@ pub async fn get_task_env(project: &Project) -> miette::Result<HashMap<String, S let prefix = get_up_to_date_prefix(project).await?; // Get environment variables from the activation - let additional_activation_scripts = project.activation_scripts(Platform::current())?; - let activation_env = await_in_progress( - "activating environment", - run_activation(prefix, additional_activation_scripts.into_iter().collect()), - ) - .await - .wrap_err("failed to activate environment")?; + let activation_env = run_activation_async(project, prefix).await?; // Get environment variables from the manifest let manifest_env = get_metadata_env(project); @@ -240,6 +234,20 @@ pub async fn get_task_env(project: &Project) -> miette::Result<HashMap<String, S .collect()) } +/// Runs the activation script asynchronously. This function also adds a progress bar. +pub async fn run_activation_async( + project: &Project, + prefix: Prefix, +) -> miette::Result<HashMap<String, String>> { + let additional_activation_scripts = project.activation_scripts(Platform::current())?; + await_in_progress( + "activating environment", + run_activation(prefix, additional_activation_scripts.into_iter().collect()), + ) + .await + .wrap_err("failed to activate environment") +} + /// Runs and caches the activation script. async fn run_activation( prefix: Prefix, diff --git a/src/cli/shell.rs b/src/cli/shell.rs index 5fea8e2..07eec78 100644 --- a/src/cli/shell.rs +++ b/src/cli/shell.rs @@ -1,13 +1,22 @@ -use crate::environment::get_up_to_date_prefix; -use crate::project::environment::add_metadata_as_env_vars; use crate::Project; use clap::Parser; use miette::IntoDiagnostic; use rattler_conda_types::Platform; -use rattler_shell::activation::{ActivationVariables, Activator, PathModificationBehaviour}; -use rattler_shell::shell::{Shell, ShellEnum}; +use rattler_shell::shell::{PowerShell, Shell, ShellEnum, ShellScript}; +use std::collections::HashMap; +use std::io::Write; use std::path::PathBuf; +#[cfg(target_family = "unix")] +use crate::unix::PtySession; + +use crate::environment::get_up_to_date_prefix; +use crate::project::environment::get_metadata_env; +#[cfg(target_family = "windows")] +use rattler_shell::shell::CmdExe; + +use super::run::run_activation_async; + /// Start a shell in the pixi environment of the project #[derive(Parser, Debug)] pub struct Args { @@ -16,66 +25,167 @@ pub struct Args { manifest_path: Option<PathBuf>, } -pub async fn execute(args: Args) -> miette::Result<()> { - let project = Project::load_or_else_discover(args.manifest_path.as_deref())?; +fn start_powershell( + pwsh: PowerShell, + env: &HashMap<String, String>, +) -> miette::Result<Option<i32>> { + // create a tempfile for activation + let mut temp_file = tempfile::Builder::new() + .suffix(".ps1") + .tempfile() + .into_diagnostic()?; - // Determine the current shell - let shell: ShellEnum = ShellEnum::default(); + let mut shell_script = ShellScript::new(pwsh.clone(), Platform::current()); + for (key, value) in env { + shell_script.set_env_var(key, value); + } - // Construct an activator so we can run commands from the environment - let prefix = get_up_to_date_prefix(&project).await?; - let activation_scripts: Vec<_> = project - .activation_scripts(Platform::current())? - .into_iter() - .collect(); - let mut activator = Activator::from_path(prefix.root(), shell.clone(), Platform::current()) + let mut contents = shell_script.contents; + // TODO: build a better prompt + contents.push_str("\nfunction prompt {\"PS pixi> \"}"); + temp_file.write_all(contents.as_bytes()).into_diagnostic()?; + // close the file handle, but keep the path (needed for Windows) + let temp_path = temp_file.into_temp_path(); + + let mut command = std::process::Command::new(pwsh.executable()); + command.arg("-NoLogo"); + command.arg("-NoExit"); + command.arg("-File"); + command.arg(&temp_path); + + let mut process = command.spawn().into_diagnostic()?; + Ok(process.wait().into_diagnostic()?.code()) +} + +#[cfg(target_family = "windows")] +fn start_cmdexe(cmdexe: CmdExe, env: &HashMap<String, String>) -> miette::Result<Option<i32>> { + // create a tempfile for activation + let mut temp_file = tempfile::Builder::new() + .suffix(".cmd") + .tempfile() .into_diagnostic()?; - activator.activation_scripts.extend(activation_scripts); + // TODO: Should we just execute the activation scripts directly for cmd.exe? + let mut shell_script = ShellScript::new(cmdexe, Platform::current()); + for (key, value) in env { + shell_script.set_env_var(key, value); + } + temp_file + .write_all(shell_script.contents.as_bytes()) + .into_diagnostic()?; - let activator_result = activator - .activation(ActivationVariables { - // Get the current PATH variable - path: Default::default(), + let mut command = std::process::Command::new(cmdexe.executable()); + command.arg("/K"); + command.arg(temp_file.path()); - // Start from an empty prefix - conda_prefix: None, + let mut process = command.spawn().into_diagnostic()?; + Ok(process.wait().into_diagnostic()?.code()) +} - // Prepending environment paths so they get found first. - path_modification_behaviour: PathModificationBehaviour::Prepend, - }) +/// Starts a UNIX shell. +/// # Arguments +/// - `shell`: The type of shell to start. Must implement the `Shell` and `Copy` traits. +/// - `args`: A vector of arguments to pass to the shell. +/// - `env`: A HashMap containing environment variables to set in the shell. +#[cfg(target_family = "unix")] +async fn start_unix_shell<T: Shell + Copy>( + shell: T, + args: Vec<&str>, + env: &HashMap<String, String>, +) -> miette::Result<Option<i32>> { + // create a tempfile for activation + let mut temp_file = tempfile::Builder::new() + .prefix("pixi_env_") + .suffix(&format!(".{}", shell.extension())) + .rand_bytes(3) + .tempfile() .into_diagnostic()?; - // Generate a temporary file with the script to execute. This includes the activation of the - // environment. - let mut script = format!("{}\n", activator_result.script.trim()); + let mut shell_script = ShellScript::new(shell, Platform::current()); + for (key, value) in env { + shell_script.set_env_var(key, value); + } + + temp_file + .write_all(shell_script.contents.as_bytes()) + .into_diagnostic()?; - // Add meta data env variables to help user interact with there configuration. - add_metadata_as_env_vars(&mut script, &shell, &project)?; + let mut command = std::process::Command::new(shell.executable()); + command.args(args); - // Add the conda default env variable so that the tools that use this know it exists. - shell - .set_env_var(&mut script, "CONDA_DEFAULT_ENV", project.name()) + let mut process = PtySession::new(command).into_diagnostic()?; + process + // Space added before `source` to automatically ignore it in history. + .send_line(&format!(" source {}", temp_file.path().display())) .into_diagnostic()?; + process.interact().into_diagnostic() +} + +/// Determine the environment variables that need to be set in an interactive shell to make it +/// function as if the environment has been activated. This method runs the activation scripts from +/// the environment and stores the environment variables it added, finally it adds environment +/// variables from the project. +pub async fn get_shell_env(project: &Project) -> miette::Result<HashMap<String, String>> { + // Get the prefix which we can then activate. + let prefix = get_up_to_date_prefix(project).await?; + + // Get environment variables from the activation + let activation_env = run_activation_async(project, prefix).await?; + + // Get environment variables from the manifest + let manifest_env = get_metadata_env(project); + + // Add the conda default env variable so that the existing tools know about the env. + let mut shell_env = HashMap::new(); + shell_env.insert("CONDA_DEFAULT_ENV".to_string(), project.name().to_string()); + + // Construct command environment by concatenating the environments + Ok(activation_env + .into_iter() + .chain(manifest_env.into_iter()) + .chain(shell_env.into_iter()) + .collect()) +} + +pub async fn execute(args: Args) -> miette::Result<()> { + let project = Project::load_or_else_discover(args.manifest_path.as_deref())?; + + // Get the environment variables we need to set activate the project in the shell. + let env = get_shell_env(&project).await?; + // Start the shell as the last part of the activation script based on the default shell. let interactive_shell: ShellEnum = ShellEnum::from_parent_process() .or_else(ShellEnum::from_env) .unwrap_or_default(); - script.push_str(interactive_shell.executable()); - // Write the contents of the script to a temporary file that we can execute with the shell. - let mut temp_file = tempfile::Builder::new() - .suffix(&format!(".{}", shell.extension())) - .tempfile() - .into_diagnostic()?; - std::io::Write::write_all(&mut temp_file, script.as_bytes()).into_diagnostic()?; + #[cfg(target_family = "windows")] + let res = match interactive_shell { + ShellEnum::PowerShell(pwsh) => start_powershell(pwsh, &env), + ShellEnum::CmdExe(cmdexe) => start_cmdexe(cmdexe, &env), + _ => { + miette::bail!("Unsupported shell: {:?}", interactive_shell); + } + }; - // Execute the script with the shell - let mut command = shell - .create_run_script_command(temp_file.path()) - .spawn() - .expect("failed to execute process"); + #[cfg(target_family = "unix")] + let res = match interactive_shell { + ShellEnum::PowerShell(pwsh) => start_powershell(pwsh, &env), + ShellEnum::Bash(bash) => start_unix_shell(bash, vec!["-l", "-i"], &env).await, + ShellEnum::Zsh(zsh) => start_unix_shell(zsh, vec!["-l", "-i"], &env).await, + ShellEnum::Fish(fish) => start_unix_shell(fish, vec![], &env).await, + ShellEnum::Xonsh(xonsh) => start_unix_shell(xonsh, vec![], &env).await, + _ => { + miette::bail!("Unsupported shell: {:?}", interactive_shell) + } + }; - std::process::exit(command.wait().into_diagnostic()?.code().unwrap_or(1)); + match res { + Ok(Some(code)) => std::process::exit(code), + Ok(None) => std::process::exit(0), + Err(e) => { + eprintln!("Error starting shell: {}", e); + std::process::exit(1); + } + } } @@ -7,6 +7,8 @@ pub mod progress; pub mod project; pub mod repodata; pub mod task; +#[cfg(unix)] +pub mod unix; pub mod util; pub mod utils; pub mod virtual_packages; diff --git a/src/project/environment.rs b/src/project/environment.rs index f2101cc..0457ea9 100644 --- a/src/project/environment.rs +++ b/src/project/environment.rs @@ -1,26 +1,10 @@ use crate::Project; use itertools::Itertools; -use miette::IntoDiagnostic; -use rattler_shell::shell::{Shell, ShellEnum}; use std::collections::HashMap; -use std::fmt::Write; // Setting a base prefix for the pixi package const ENV_PREFIX: &str = "PIXI_PACKAGE_"; -// Add pixi meta data into the environment as environment variables. -pub fn add_metadata_as_env_vars( - script: &mut impl Write, - shell: &ShellEnum, - project: &Project, -) -> miette::Result<()> { - for (key, value) in get_metadata_env(project) { - shell.set_env_var(script, &key, &value).into_diagnostic()?; - } - - Ok(()) -} - /// Returns environment variables and their values that should be injected when running a command. pub fn get_metadata_env(project: &Project) -> HashMap<String, String> { HashMap::from_iter([ diff --git a/src/unix/mod.rs b/src/unix/mod.rs new file mode 100644 index 0000000..a40fab8 --- /dev/null +++ b/src/unix/mod.rs @@ -0,0 +1,5 @@ +mod pty_process;
+mod pty_session;
+
+pub use pty_process::PtyProcess;
+pub use pty_session::PtySession;
diff --git a/src/unix/pty_process.rs b/src/unix/pty_process.rs new file mode 100644 index 0000000..f149ff9 --- /dev/null +++ b/src/unix/pty_process.rs @@ -0,0 +1,303 @@ +pub use nix::sys::{signal, wait}; +use nix::{ + self, + fcntl::{open, OFlag}, + libc::{STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO}, + pty::{grantpt, posix_openpt, unlockpt, PtyMaster, Winsize}, + sys::termios::{InputFlags, Termios}, + sys::{stat, termios}, + unistd::{close, dup, dup2, fork, setsid, ForkResult, Pid}, +}; +use std::os::fd::AsFd; +use std::{ + self, + fs::File, + io, + os::unix::{ + io::{AsRawFd, FromRawFd}, + process::CommandExt, + }, + process::Command, + thread, time, +}; + +#[cfg(target_os = "linux")] +use nix::pty::ptsname_r; + +/// Start a process in a forked tty so you can interact with it the same as you would +/// within a terminal +/// +/// The process and pty session are killed upon dropping PtyProcess +pub struct PtyProcess { + pub pty: PtyMaster, + pub child_pid: Pid, + kill_timeout: Option<time::Duration>, +} + +#[cfg(target_os = "macos")] +/// ptsname_r is a linux extension but ptsname isn't thread-safe +/// instead of using a static mutex this calls ioctl with TIOCPTYGNAME directly +/// based on https://blog.tarq.io/ptsname-on-osx-with-rust/ +fn ptsname_r(fd: &PtyMaster) -> nix::Result<String> { + use nix::libc::{ioctl, TIOCPTYGNAME}; + use std::ffi::CStr; + + // the buffer size on OSX is 128, defined by sys/ttycom.h + let mut buf: [i8; 128] = [0; 128]; + + unsafe { + match ioctl(fd.as_raw_fd(), TIOCPTYGNAME as u64, &mut buf) { + 0 => { + let res = CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned(); + Ok(res) + } + _ => Err(nix::Error::last()), + } + } +} + +#[derive(Default)] +pub struct PtyProcessOptions { + pub echo: bool, + pub window_size: Option<Winsize>, +} + +impl PtyProcess { + /// Start a process in a forked pty + pub fn new(mut command: Command, opts: PtyProcessOptions) -> nix::Result<Self> { + // Open a new PTY master + let master_fd = posix_openpt(OFlag::O_RDWR)?; + + // Allow a slave to be generated for it + grantpt(&master_fd)?; + unlockpt(&master_fd)?; + + // on Linux this is the libc function, on OSX this is our implementation of ptsname_r + let slave_name = ptsname_r(&master_fd)?; + + // Get the current window size if it was not specified + let window_size = opts.window_size.unwrap_or_else(|| { + // find current window size with ioctl + let mut size: libc::winsize = unsafe { std::mem::zeroed() }; + // Query the terminal dimensions + unsafe { libc::ioctl(io::stdout().as_raw_fd(), libc::TIOCGWINSZ, &mut size) }; + size + }); + + match unsafe { fork()? } { + ForkResult::Child => { + // Avoid leaking master fd + close(master_fd.as_raw_fd())?; + + setsid()?; // create new session with child as session leader + let slave_fd = open( + std::path::Path::new(&slave_name), + OFlag::O_RDWR, + stat::Mode::empty(), + )?; + + // assign stdin, stdout, stderr to the tty, just like a terminal does + dup2(slave_fd, STDIN_FILENO)?; + dup2(slave_fd, STDOUT_FILENO)?; + dup2(slave_fd, STDERR_FILENO)?; + + // Avoid leaking slave fd + if slave_fd > STDERR_FILENO { + close(slave_fd)?; + } + + // set echo off + set_echo(io::stdin(), opts.echo)?; + set_window_size(io::stdout().as_raw_fd(), window_size)?; + + // let mut flags = termios::tcgetattr(io::stdin())?; + // flags.local_flags |= termios::LocalFlags::ECHO; + // termios::tcsetattr(io::stdin(), termios::SetArg::TCSANOW, &flags)?; + + command.exec(); + Err(nix::Error::last()) + } + ForkResult::Parent { child: child_pid } => Ok(PtyProcess { + pty: master_fd, + child_pid, + kill_timeout: None, + }), + } + } + + /// Get handle to pty fork for reading/writing + pub fn get_file_handle(&self) -> nix::Result<File> { + // needed because otherwise fd is closed both by dropping process and reader/writer + let fd = dup(self.pty.as_raw_fd())?; + unsafe { Ok(File::from_raw_fd(fd)) } + } + + /// At the drop of PtyProcess the running process is killed. This is blocking forever if + /// the process does not react to a normal kill. If kill_timeout is set the process is + /// `kill -9`ed after duration + pub fn set_kill_timeout(&mut self, timeout_ms: Option<u64>) { + self.kill_timeout = timeout_ms.map(time::Duration::from_millis); + } + + /// Get status of child process, non-blocking. + /// + /// This method runs waitpid on the process. + /// This means: If you ran `exit()` before or `status()` this method will + /// return `None` + pub fn status(&self) -> Option<wait::WaitStatus> { + if let Ok(status) = wait::waitpid(self.child_pid, Some(wait::WaitPidFlag::WNOHANG)) { + Some(status) + } else { + None + } + } + + /// Wait until process has exited. This is a blocking call. + /// If the process doesn't terminate this will block forever. + pub fn wait(&self) -> nix::Result<wait::WaitStatus> { + wait::waitpid(self.child_pid, None) + } + + /// Regularly exit the process, this method is blocking until the process is dead + pub fn exit(&mut self) -> nix::Result<wait::WaitStatus> { + self.kill(signal::SIGTERM) + } + + /// Non-blocking variant of `kill()` (doesn't wait for process to be killed) + pub fn signal(&mut self, sig: signal::Signal) -> nix::Result<()> { + signal::kill(self.child_pid, sig) + } + + /// Kill the process with a specific signal. This method blocks, until the process is dead + /// + /// repeatedly sends SIGTERM to the process until it died, + /// the pty session is closed upon dropping PtyMaster, + /// so we don't need to explicitly do that here. + /// + /// if `kill_timeout` is set and a repeated sending of signal does not result in the process + /// being killed, then `kill -9` is sent after the `kill_timeout` duration has elapsed. + pub fn kill(&mut self, sig: signal::Signal) -> nix::Result<wait::WaitStatus> { + let start = time::Instant::now(); + loop { + match signal::kill(self.child_pid, sig) { + Ok(_) => {} + // process was already killed before -> ignore + Err(nix::errno::Errno::ESRCH) => { + return Ok(wait::WaitStatus::Exited(Pid::from_raw(0), 0)); + } + Err(e) => return Err(e), + } + + match self.status() { + Some(status) if status != wait::WaitStatus::StillAlive => return Ok(status), + Some(_) | None => thread::sleep(time::Duration::from_millis(100)), + } + // kill -9 if timeout is reached + if let Some(timeout) = self.kill_timeout { + if start.elapsed() > timeout { + signal::kill(self.child_pid, signal::Signal::SIGKILL)? + } + } + } + } + + /// Set raw mode on stdin and return the original mode + pub fn set_raw(&self) -> nix::Result<Termios> { + let original_mode = termios::tcgetattr(io::stdin())?; + let mut raw_mode = original_mode.clone(); + raw_mode.input_flags.remove( + InputFlags::BRKINT + | InputFlags::ICRNL + | InputFlags::INPCK + | InputFlags::ISTRIP + | InputFlags::IXON, + ); + raw_mode.output_flags.remove(termios::OutputFlags::OPOST); + raw_mode + .control_flags + .remove(termios::ControlFlags::CSIZE | termios::ControlFlags::PARENB); + raw_mode.control_flags.insert(termios::ControlFlags::CS8); + raw_mode.local_flags.remove( + termios::LocalFlags::ECHO + | termios::LocalFlags::ICANON + | termios::LocalFlags::IEXTEN + | termios::LocalFlags::ISIG, + ); + + raw_mode.control_chars[termios::SpecialCharacterIndices::VMIN as usize] = 1; + raw_mode.control_chars[termios::SpecialCharacterIndices::VTIME as usize] = 0; + + termios::tcsetattr(io::stdin(), termios::SetArg::TCSAFLUSH, &raw_mode)?; + + Ok(original_mode) + } + + pub fn set_mode(&self, original_mode: Termios) -> nix::Result<()> { + termios::tcsetattr(io::stdin(), termios::SetArg::TCSAFLUSH, &original_mode)?; + Ok(()) + } + + pub fn set_window_size(&self, window_size: Winsize) -> nix::Result<()> { + set_window_size(self.pty.as_raw_fd(), window_size) + } +} + +pub fn set_window_size(raw_fd: i32, window_size: Winsize) -> nix::Result<()> { + unsafe { libc::ioctl(raw_fd, nix::libc::TIOCSWINSZ, &window_size) }; + Ok(()) +} + +pub fn set_echo<Fd: AsFd>(fd: Fd, echo: bool) -> nix::Result<()> { + let mut flags = termios::tcgetattr(&fd)?; + if echo { + flags.local_flags.insert(termios::LocalFlags::ECHO); + } else { + flags.local_flags.remove(termios::LocalFlags::ECHO); + } + termios::tcsetattr(&fd, termios::SetArg::TCSANOW, &flags)?; + Ok(()) +} + +impl Drop for PtyProcess { + fn drop(&mut self) { + if let Some(wait::WaitStatus::StillAlive) = self.status() { + self.exit().expect("cannot exit"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nix::sys::{signal, wait}; + use std::io::{BufRead, BufReader, LineWriter, Write}; + + #[test] + /// Open cat, write string, read back string twice, send Ctrl^C and check that cat exited + fn test_cat() -> std::io::Result<()> { + let process = PtyProcess::new( + Command::new("cat"), + PtyProcessOptions { + echo: false, + window_size: Default::default(), + }, + ) + .expect("could not execute cat"); + let f = process.get_file_handle().unwrap(); + let mut writer = LineWriter::new(&f); + let mut reader = BufReader::new(&f); + let _ = writer.write(b"hello cat\n")?; + let mut buf = String::new(); + reader.read_line(&mut buf)?; + assert_eq!(buf, "hello cat\r\n"); + + // this sleep solves an edge case of some cases when cat is somehow not "ready" + // to take the ^C (occasional test hangs) + thread::sleep(time::Duration::from_millis(100)); + writer.write_all(&[3])?; // send ^C + writer.flush()?; + let should = wait::WaitStatus::Signaled(process.child_pid, signal::Signal::SIGINT, false); + assert_eq!(should, wait::waitpid(process.child_pid, None).unwrap()); + Ok(()) + } +} diff --git a/src/unix/pty_session.rs b/src/unix/pty_session.rs new file mode 100644 index 0000000..feee797 --- /dev/null +++ b/src/unix/pty_session.rs @@ -0,0 +1,167 @@ +use super::PtyProcess; +use crate::unix::pty_process::PtyProcessOptions; +use libc::SIGWINCH; +use nix::sys::select::FdSet; +use nix::{ + errno::Errno, + sys::{select, time::TimeVal, wait::WaitStatus}, +}; +use signal_hook::iterator::Signals; +use std::{ + fs::File, + io::{self, Read, Write}, + os::fd::AsFd, + process::Command, +}; + +pub struct PtySession { + pub process: PtyProcess, + + /// A file handle of the stdout of the pty process + pub process_stdout: File, + + /// A file handle of the stdin of the pty process + pub process_stdin: File, +} + +/// ``` +/// use std::process::Command; +/// use pixi::unix::PtySession; +/// +/// let process = PtySession::new(Command::new("bash")).unwrap(); +/// ``` +impl PtySession { + /// Constructs a new session + pub fn new(command: Command) -> io::Result<Self> { + let process = PtyProcess::new( + command, + PtyProcessOptions { + echo: true, + ..Default::default() + }, + )?; + + let process_stdin = process.get_file_handle()?; + let process_stdout = process.get_file_handle()?; + + Ok(Self { + process, + process_stdout, + process_stdin, + }) + } + + /// Send string to process. As stdin of the process is most likely buffered, you'd + /// need to call `flush()` after `send()` to make the process actually see your input. + /// + /// Returns number of written bytes + pub fn send<B: AsRef<[u8]>>(&mut self, s: B) -> io::Result<usize> { + self.process_stdin.write(s.as_ref()) + } + + /// Sends string and a newline to process. This is guaranteed to be flushed to the process. + /// Returns number of written bytes. + pub fn send_line(&mut self, line: &str) -> io::Result<usize> { + let mut len = self.send(line)?; + len += self.process_stdin.write(&[b'\n'])?; + Ok(len) + } + + /// Make sure all bytes written via `send()` are sent to the process + pub fn flush(&mut self) -> io::Result<()> { + self.process_stdin.flush() + } + + /// Interact with the process. This will put the current process into raw mode and + /// forward all input from stdin to the process and all output from the process to stdout. + /// This will block until the process exits. + pub fn interact(&mut self) -> io::Result<Option<i32>> { + // Make sure anything we have written so far has been flushed. + self.flush()?; + + // P |