fix(task): skip setpgid for TTY stdin to fix interactive tasks#8301
fix(task): skip setpgid for TTY stdin to fix interactive tasks#8301
Conversation
Processes with inherited TTY stdin (e.g. Tilt, interactive CLIs) must stay in the terminal's foreground process group. Moving them to a new group via setpgid causes SIGTTIN, stopping them silently. Only call setpgid when stdin is not a TTY; non-interactive tasks (stdin null/piped) still get their own process group for proper grandchild cleanup on Ctrl-C. Fixes #8300 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary of ChangesHello @jdx, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request resolves a critical issue where interactive tasks would hang due to incorrect process group management introduced in a previous change. By intelligently applying process group isolation only to non-interactive tasks, it restores full functionality for interactive command-line tools while preserving the benefits of robust process termination for background operations. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
The PR correctly addresses the issue where interactive tasks hang by skipping setpgid when stdin is a TTY. This ensures the process remains in the terminal's foreground process group. However, the current implementation uses std::io::stdin() inside the pre_exec closure, which is not async-signal-safe and could lead to deadlocks in a multi-threaded environment like mise. I recommend using nix::unistd::isatty(0) instead, as it is safe to call in a post-fork environment.
src/cmd.rs
Outdated
| // Interactive tools (e.g. Tilt) need to stay in the terminal's | ||
| // foreground process group; moving them to a new one triggers | ||
| // SIGTTIN and hangs them. | ||
| if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { |
There was a problem hiding this comment.
Calling std::io::stdin() inside a pre_exec closure is not async-signal-safe. In a multi-threaded process, fork() only duplicates the calling thread. If another thread in the parent process held the internal lock for stdin at the moment of the fork, the child process will deadlock when it attempts to acquire that same lock.
Since this code is already within a #[cfg(unix)] block and uses the nix crate, it is safer to use nix::unistd::isatty(0) which directly performs the system call without high-level synchronization and is async-signal-safe.
| if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { | |
| if !nix::unistd::isatty(0).unwrap_or(false) { |
There was a problem hiding this comment.
Pull request overview
This PR fixes a regression introduced in #8279 where interactive tasks that read from stdin (like Tilt) would hang when executed. The fix conditionally skips process group creation via setpgid when the child process has a TTY stdin, allowing interactive tools to remain in the terminal's foreground process group and receive keyboard input properly.
Changes:
- Add conditional check in
pre_execclosure to skipsetpgidwhen stdin is a terminal - Preserves grandchild cleanup benefit from #8279 for non-interactive tasks
- Restores interactive task functionality
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/cmd.rs
Outdated
| // Interactive tools (e.g. Tilt) need to stay in the terminal's | ||
| // foreground process group; moving them to a new one triggers | ||
| // SIGTTIN and hangs them. | ||
| if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { |
There was a problem hiding this comment.
Calling std::io::stdin() in pre_exec may not be async-signal-safe as it could involve allocation. The pre_exec closure runs after fork but before exec and must only use async-signal-safe functions. Consider using nix::unistd::isatty(0) instead, which directly calls the isatty syscall without allocating. This would make the code more robust and properly follow pre_exec safety requirements.
| if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { | |
| if !matches!(nix::unistd::isatty(0), Ok(true)) { |
Hyperfine Performance
|
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.2.18 x -- echo |
23.1 ± 0.3 | 22.5 | 25.5 | 1.00 |
mise x -- echo |
23.3 ± 0.3 | 22.6 | 25.0 | 1.01 ± 0.02 |
mise env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.2.18 env |
22.0 ± 0.6 | 21.4 | 27.8 | 1.00 |
mise env |
22.1 ± 0.3 | 21.5 | 23.8 | 1.00 ± 0.03 |
mise hook-env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.2.18 hook-env |
22.7 ± 0.4 | 22.1 | 26.5 | 1.00 |
mise hook-env |
22.8 ± 0.3 | 22.2 | 24.2 | 1.00 ± 0.02 |
mise ls
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.2.18 ls |
20.2 ± 0.3 | 19.6 | 22.1 | 1.00 |
mise ls |
20.3 ± 0.9 | 19.7 | 32.9 | 1.01 ± 0.04 |
xtasks/test/perf
| Command | mise-2026.2.18 | mise | Variance |
|---|---|---|---|
| install (cached) | 125ms | 124ms | +0% |
| ls (cached) | 75ms | 75ms | +0% |
| bin-paths (cached) | 80ms | 80ms | +0% |
| task-ls (cached) | 805ms | 817ms | -1% |
std::io::stdin() accesses a static OnceLock post-fork, which can deadlock if a thread held the lock at fork time. Replace with BorrowedFd::borrow_raw(0).is_terminal() which calls libc::isatty(0) directly — no allocation, no locks, async-signal-safe. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
### 🐛 Bug Fixes - **(docs)** correct ripgrep command by @nguyenvulong in [#8299](#8299) - **(task)** skip setpgid for TTY stdin to fix interactive tasks by @jdx in [#8301](#8301) - clean up empty parent install dir on failed install by @jdx in [#8302](#8302) ### Chore - **(release)** run communique via mise x for PATH resolution by @jdx in [#8294](#8294) ## 📦 Aqua Registry Updates #### New Packages (2) - [`kubie-org/kubie`](https://github.com/kubie-org/kubie) - [`steipete/gogcli`](https://github.com/steipete/gogcli)
…mise tasks Reverts the setpgid/killpg changes from #8279 and #8301. Placing each child process in its own process group via setpgid(0,0) breaks external tools (e.g. Playwright) that rely on process-group-based kills to tear down process trees. When Playwright SIGKILLs the webServer process group, grandchild processes in a separate group (created by inner mise's setpgid) survive, holding pipes open and causing the parent to hang indefinitely. Fixes #8345 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…mise tasks (#8347) ## Summary - Reverts the `setpgid`/`killpg` changes from #8279 and #8301 - Placing each child process in its own process group via `setpgid(0,0)` breaks external tools (e.g. Playwright) that rely on process-group-based kills to tear down process trees - When Playwright `SIGKILL`s the webServer process group, grandchild processes in a separate group (created by inner mise's `setpgid`) survive, holding pipes open and causing the parent to hang indefinitely ## Root Cause The process group change created a hierarchy where nested `mise run` calls each placed their children in separate process groups: ``` mise run test (pgid=terminal) └── sh -c "npm test" (pgid=A, set by outer mise's setpgid) └── playwright └── mise run serve (pgid=C, set by Playwright's detached:true) └── sh -c "npm run serve" (pgid=D, set by inner mise's setpgid) └── node main.js (inherits pgid=D) ``` When Playwright kills pgid=C (`kill(-pid, SIGKILL)`), only processes in that group die. The actual server in pgid=D survives because `SIGKILL` cannot be caught/forwarded, and the surviving process holds pipe file descriptors open, preventing EOF and causing the parent mise to hang indefinitely. Fixes #8345 ## Test plan - [x] `cargo build` compiles cleanly - [x] `mise run lint` passes - [x] `mise run test:unit` — all 491 tests pass - [x] `mise run test:e2e test_task_timeout` — timeout tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes how signals/timeouts propagate to subprocess trees on Unix, which can affect cleanup and Ctrl-C/termination semantics for running tasks. > > **Overview** > Stops creating per-command process groups on Unix by removing the `pre_exec` `setpgid` hook, restoring the prior behavior where child commands stay in the caller’s process group. > > Replaces all `killpg` usage with direct `kill` calls: timeout termination (`SIGTERM` then `SIGKILL`), global shutdown via `CmdLineRunner::kill_all`, and signal forwarding during `execute` now target only the child PID (and it no longer forwards `SIGINT`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e6ed47d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
) ## Summary PR jdx#8279 placed every task child process in its own process group via `setpgid(0, 0)`. This broke interactive tools like Tilt that read from stdin. **Root cause:** The terminal driver only delivers keyboard input to the *foreground process group*. When a child is moved to a new process group, it's no longer the terminal's foreground group, so any attempt to `read()` from stdin causes the kernel to send `SIGTTIN`, which silently stops the process. Result: the process hangs, Ctrl-C has no effect, and the UI never loads. **Fix:** In `pre_exec`, check `std::io::stdin().is_terminal()`. If stdin is a TTY (inherited from the terminal), skip `setpgid` — the process stays in the terminal's foreground group and gets full keyboard I/O. If stdin is not a TTY (null/piped, i.e. non-interactive tasks), `setpgid` fires as before, preserving grandchild cleanup on Ctrl-C. This keeps the grandchild-kill benefit of jdx#8279 for all normal (non-interactive) tasks while restoring correct behavior for interactive ones. Closes jdx#8300 ## Test plan - [x] `cargo build` compiles cleanly - [x] Verified `mise run tilt:up` (or equivalent interactive task) no longer hangs - [x] Non-interactive tasks still get their own process group for clean teardown 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Small, Unix-only change gated on an `is_terminal` check; primary risk is behavior change in process-group/signal handling for commands with TTY stdin. > > **Overview** > Fixes Unix task execution for interactive commands by **only calling `setpgid(0,0)` when stdin is not a TTY**. > > In `CmdLineRunner::execute`’s `pre_exec` hook, stdin is checked via `BorrowedFd::borrow_raw(0)` + `IsTerminal`; interactive children stay in the terminal’s foreground process group to avoid `SIGTTIN` hangs, while non-interactive tasks keep the separate process group behavior for signal/cleanup handling. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4bf5aef. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
### 🐛 Bug Fixes - **(docs)** correct ripgrep command by @nguyenvulong in [jdx#8299](jdx#8299) - **(task)** skip setpgid for TTY stdin to fix interactive tasks by @jdx in [jdx#8301](jdx#8301) - clean up empty parent install dir on failed install by @jdx in [jdx#8302](jdx#8302) ### Chore - **(release)** run communique via mise x for PATH resolution by @jdx in [jdx#8294](jdx#8294) ## 📦 Aqua Registry Updates #### New Packages (2) - [`kubie-org/kubie`](https://github.com/kubie-org/kubie) - [`steipete/gogcli`](https://github.com/steipete/gogcli)
…mise tasks (jdx#8347) ## Summary - Reverts the `setpgid`/`killpg` changes from jdx#8279 and jdx#8301 - Placing each child process in its own process group via `setpgid(0,0)` breaks external tools (e.g. Playwright) that rely on process-group-based kills to tear down process trees - When Playwright `SIGKILL`s the webServer process group, grandchild processes in a separate group (created by inner mise's `setpgid`) survive, holding pipes open and causing the parent to hang indefinitely ## Root Cause The process group change created a hierarchy where nested `mise run` calls each placed their children in separate process groups: ``` mise run test (pgid=terminal) └── sh -c "npm test" (pgid=A, set by outer mise's setpgid) └── playwright └── mise run serve (pgid=C, set by Playwright's detached:true) └── sh -c "npm run serve" (pgid=D, set by inner mise's setpgid) └── node main.js (inherits pgid=D) ``` When Playwright kills pgid=C (`kill(-pid, SIGKILL)`), only processes in that group die. The actual server in pgid=D survives because `SIGKILL` cannot be caught/forwarded, and the surviving process holds pipe file descriptors open, preventing EOF and causing the parent mise to hang indefinitely. Fixes jdx#8345 ## Test plan - [x] `cargo build` compiles cleanly - [x] `mise run lint` passes - [x] `mise run test:unit` — all 491 tests pass - [x] `mise run test:e2e test_task_timeout` — timeout tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes how signals/timeouts propagate to subprocess trees on Unix, which can affect cleanup and Ctrl-C/termination semantics for running tasks. > > **Overview** > Stops creating per-command process groups on Unix by removing the `pre_exec` `setpgid` hook, restoring the prior behavior where child commands stay in the caller’s process group. > > Replaces all `killpg` usage with direct `kill` calls: timeout termination (`SIGTERM` then `SIGKILL`), global shutdown via `CmdLineRunner::kill_all`, and signal forwarding during `execute` now target only the child PID (and it no longer forwards `SIGINT`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e6ed47d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Summary
PR #8279 placed every task child process in its own process group via
setpgid(0, 0). This broke interactive tools like Tilt that read from stdin.Root cause: The terminal driver only delivers keyboard input to the foreground process group. When a child is moved to a new process group, it's no longer the terminal's foreground group, so any attempt to
read()from stdin causes the kernel to sendSIGTTIN, which silently stops the process. Result: the process hangs, Ctrl-C has no effect, and the UI never loads.Fix: In
pre_exec, checkstd::io::stdin().is_terminal(). If stdin is a TTY (inherited from the terminal), skipsetpgid— the process stays in the terminal's foreground group and gets full keyboard I/O. If stdin is not a TTY (null/piped, i.e. non-interactive tasks),setpgidfires as before, preserving grandchild cleanup on Ctrl-C.This keeps the grandchild-kill benefit of #8279 for all normal (non-interactive) tasks while restoring correct behavior for interactive ones.
Closes #8300
Test plan
cargo buildcompiles cleanlymise run tilt:up(or equivalent interactive task) no longer hangs🤖 Generated with Claude Code
Note
Low Risk
Small, Unix-only change gated on an
is_terminalcheck; primary risk is behavior change in process-group/signal handling for commands with TTY stdin.Overview
Fixes Unix task execution for interactive commands by only calling
setpgid(0,0)when stdin is not a TTY.In
CmdLineRunner::execute’spre_exechook, stdin is checked viaBorrowedFd::borrow_raw(0)+IsTerminal; interactive children stay in the terminal’s foreground process group to avoidSIGTTINhangs, while non-interactive tasks keep the separate process group behavior for signal/cleanup handling.Written by Cursor Bugbot for commit 4bf5aef. This will update automatically on new commits. Configure here.