Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ jobs:
echo "SCCACHE_CACHE_SIZE=$SCCACHE_CACHE_SIZE"
} >> "$GITHUB_ENV"
sccache --zero-stats
- name: Install fd
run: brew install fd
- name: Install test tools
run: brew install fd tmux
- run: |
cargo build --all-features
echo "$PWD/target/debug" >> "$GITHUB_PATH"
Expand All @@ -173,6 +173,7 @@ jobs:
- name: cargo test (workspace members)
run: cargo test --workspace --exclude mise
- run: mise run test:e2e e2e/cli/test_system_install_brew_macos_slow
- run: mise run test:e2e e2e/tasks/test_task_zsh_process_substitution_tmux_macos
- run: sccache --show-stats
if: always()

Expand Down
65 changes: 65 additions & 0 deletions e2e/tasks/test_task_zsh_process_substitution_tmux_macos
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Regression coverage for https://github.com/jdx/mise/discussions/9790.
# On macOS, moving a non-interactive zsh task into its own process group while
# mise remains attached to tmux's controlling terminal can stop zsh during
# process substitution. Linux does not exhibit the same job-control behavior.

set -euo pipefail

if [[ $(uname -s) != Darwin ]]; then
exit 0
fi

# Unix-domain socket paths are short on macOS, while the e2e TMPDIR is deeply
# nested. Use an explicit short path instead of tmux's default /private/tmp dir.
socket="/tmp/mise-zps-${RANDOM}-$$.sock"
logfile="$TMPDIR/mise-zsh-procsub-log-${RANDOM}-$$"
statusfile="$TMPDIR/mise-zsh-procsub-status-${RANDOM}-$$"

cleanup() {
tmux -S "$socket" kill-server 2>/dev/null || true
}
trap cleanup EXIT

cat <<'EOF' >mise.toml
[tasks.procsub]
shell = "zsh -c"
run = "while IFS= read -r -d ':' e; do echo \"got: $e\"; done < <(printf 'a:b:c:')"

[tasks.dummy]
run = "echo dummy"

[tasks.all]
depends = ["procsub", "dummy"]
EOF

printf -v tmux_command \
'cd %q && mise run --jobs 2 all >%q 2>&1; printf "%%s\\n" "$?" >%q' \
"$PWD" "$logfile" "$statusfile"
tmux -S "$socket" new-session -d -s repro "$tmux_command"

for _ in {1..100}; do
if [[ -f $statusfile ]]; then
break
fi
sleep 0.1
done

if [[ ! -f $statusfile ]]; then
tmux -S "$socket" capture-pane -p -t repro 2>/dev/null || true
cat "$logfile"
fail "parallel zsh task did not finish under tmux"
fi

status=$(cat "$statusfile")
if [[ $status -ne 0 ]]; then
cat "$logfile"
fail "parallel zsh task exited with status $status"
fi

for expected in "got: a" "got: b" "got: c" "dummy"; do
if ! grep -Fq "$expected" "$logfile"; then
cat "$logfile"
fail "missing expected output: $expected"
fi
done
149 changes: 102 additions & 47 deletions src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,14 +315,89 @@ impl Drop for RunningPidGuard {
}

/// Env var set on every spawned child when this mise process is managing
/// process groups (calling setpgid + killpg). A nested mise that sees this
/// process groups (calling setpgid/setsid + killpg). A nested mise that sees this
/// var skips its own setpgid so descendants stay in the outer pgid — that
/// way the outer mise's killpg actually reaches the leaves.
#[cfg(unix)]
const TASK_PGID_MANAGED_ENV: &str = "MISE_TASK_PGID_MANAGED";

/// True when this mise should `setpgid` spawned children and `killpg` them
/// for cleanup.
#[cfg(unix)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChildProcessIsolation {
Inherit,
ProcessGroup,
Session,
}

#[cfg(unix)]
fn child_process_isolation(
child_stdin_is_terminal: bool,
parent_has_terminal: bool,
is_macos: bool,
) -> ChildProcessIsolation {
if child_stdin_is_terminal {
ChildProcessIsolation::Inherit
} else if is_macos && parent_has_terminal {
ChildProcessIsolation::Session
} else {
ChildProcessIsolation::ProcessGroup
}
}

#[cfg(unix)]
fn parent_has_terminal() -> bool {
use std::io::IsTerminal;

std::io::stdin().is_terminal()
|| std::io::stdout().is_terminal()
|| std::io::stderr().is_terminal()
}

/// Put an ordinary non-raw command in the process tree managed by mise.
///
/// On macOS, a non-interactive zsh child in its own process group can be
/// stopped by job control when it uses process substitution under a
/// controlling terminal (for example, inside tmux). A separate session keeps
/// the child detached from that terminal while preserving the invariant that
/// its PID is also its process group ID for killpg-based cleanup.
#[cfg(unix)]
fn prepare_execute_child(cmd: &mut std::process::Command) {
if !should_use_pgroup() {
return;
}

cmd.env(TASK_PGID_MANAGED_ENV, "1");
let parent_has_terminal = parent_has_terminal();
unsafe {
cmd.pre_exec(move || {
// Use BorrowedFd::borrow_raw rather than std::io::stdin() —
// pre_exec runs post-fork where OnceLock/malloc are not
// async-signal-safe.
let stdin = std::os::fd::BorrowedFd::borrow_raw(0);
let child_stdin_is_terminal = std::io::IsTerminal::is_terminal(&stdin);
match child_process_isolation(
child_stdin_is_terminal,
parent_has_terminal,
cfg!(target_os = "macos"),
) {
ChildProcessIsolation::Inherit => Ok(()),
ChildProcessIsolation::ProcessGroup => {
let _ = nix::unistd::setpgid(
nix::unistd::Pid::from_raw(0),
nix::unistd::Pid::from_raw(0),
);
Ok(())
}
ChildProcessIsolation::Session => {
nix::unistd::setsid().map(|_| ()).map_err(Into::into)
}
}
});
}
}

/// True when this mise should isolate spawned children into process groups and
/// `killpg` them for cleanup.
///
/// We skip pgroup management in two cases:
///
Expand All @@ -340,12 +415,12 @@ const TASK_PGID_MANAGED_ENV: &str = "MISE_TASK_PGID_MANAGED";
/// In both cases we share whatever pgid we landed in, so the ancestor
/// that owns it can clean us up.
///
/// Cached on first access: `execute()` decides whether to `setpgid` at
/// spawn time, and `kill_all()` decides whether to `killpg` at signal
/// time. They must agree — a child placed in its own pgid by `execute()`
/// must be killed via `killpg`, or only the direct PID gets the signal
/// and grandchildren leak. Computing this once removes any chance of the
/// two callers disagreeing if the env later mutates.
/// Cached on first access: `execute()` decides whether to create a managed
/// process group or session at spawn time, and `kill_all()` decides whether to
/// `killpg` at signal time. They must agree — a child placed in its own pgid by
/// `execute()` must be killed via `killpg`, or only the direct PID gets the
/// signal and grandchildren leak. Computing this once removes any chance of
/// the two callers disagreeing if the env later mutates.
#[cfg(unix)]
fn should_use_pgroup() -> bool {
static CACHED: Lazy<bool> = Lazy::new(|| {
Expand Down Expand Up @@ -681,29 +756,7 @@ impl<'a> CmdLineRunner<'a> {
return self.execute_raw();
}
#[cfg(unix)]
if should_use_pgroup() {
// Mark descendants so a nested mise inherits this var and skips
// its own setpgid — keeping the whole tree in our pgid.
self.cmd.env(TASK_PGID_MANAGED_ENV, "1");
unsafe {
self.cmd.as_std_mut().pre_exec(|| {
// Skip setpgid when stdin is a TTY: interactive tools
// (e.g. Tilt) need to stay in the terminal's foreground
// pgid or they hang on SIGTTIN when reading stdin.
// Use BorrowedFd::borrow_raw rather than std::io::stdin()
// — pre_exec runs post-fork where OnceLock/malloc are
// not async-signal-safe.
let stdin = std::os::fd::BorrowedFd::borrow_raw(0);
if !std::io::IsTerminal::is_terminal(&stdin) {
let _ = nix::unistd::setpgid(
nix::unistd::Pid::from_raw(0),
nix::unistd::Pid::from_raw(0),
);
}
Ok(())
});
}
}
prepare_execute_child(self.cmd.as_std_mut());
let mut cp = self
.spawn_with_etxtbsy_retry()
.wrap_err_with(|| format!("failed to execute command: {self}"))?;
Expand Down Expand Up @@ -880,21 +933,7 @@ impl<'a> CmdLineRunner<'a> {
return self.execute_raw_async_with_cancel_check(is_cancelled).await;
}
#[cfg(unix)]
if should_use_pgroup() {
self.cmd.env(TASK_PGID_MANAGED_ENV, "1");
unsafe {
self.cmd.as_std_mut().pre_exec(|| {
let stdin = std::os::fd::BorrowedFd::borrow_raw(0);
if !std::io::IsTerminal::is_terminal(&stdin) {
let _ = nix::unistd::setpgid(
nix::unistd::Pid::from_raw(0),
nix::unistd::Pid::from_raw(0),
);
}
Ok(())
});
}
}
prepare_execute_child(self.cmd.as_std_mut());
let mut cp = self
.spawn_async_with_etxtbsy_retry()
.await
Expand Down Expand Up @@ -1791,6 +1830,22 @@ mod tests {

use crate::config::Config;

#[test]
fn test_child_process_isolation() {
use super::ChildProcessIsolation::{Inherit, ProcessGroup, Session};

assert_eq!(super::child_process_isolation(true, true, true), Inherit);
assert_eq!(super::child_process_isolation(false, true, true), Session);
assert_eq!(
super::child_process_isolation(false, false, true),
ProcessGroup
);
assert_eq!(
super::child_process_isolation(false, true, false),
ProcessGroup
);
}

#[tokio::test]
async fn test_cmd() {
let _config = Config::get().await.unwrap();
Expand Down
Loading