summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorWolf Vollprecht <w.vollprecht@gmail.com>2023-09-27 15:48:00 +0200
committerGitHub <noreply@github.com>2023-09-27 15:48:00 +0200
commitff3600540a23d8719e5ade6ceb23d0a8eb9a87ba (patch)
treeb0d7cf6a754e46eab1f6b95765f00ee77b6a3ee0
parent96809ceee7b6f086d8b7dcac6680616b0c68b73e (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.lock48
-rw-r--r--Cargo.toml20
-rw-r--r--src/cli/run.rs22
-rw-r--r--src/cli/shell.rs202
-rw-r--r--src/lib.rs2
-rw-r--r--src/project/environment.rs16
-rw-r--r--src/unix/mod.rs5
-rw-r--r--src/unix/pty_process.rs303
-rw-r--r--src/unix/pty_session.rs167
-rw-r--r--tests/common/mod.rs4
10 files changed, 710 insertions, 79 deletions
diff --git a/Cargo.lock b/Cargo.lock
index a7658d8..e136aa0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
diff --git a/Cargo.toml b/Cargo.toml
index 64e1cc7..446d898 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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);
+ }
+ }
}
diff --git a/src/lib.rs b/src/lib.rs
index 0a0e2f7..9e566db 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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