Skip to content

Fix Ctrl-Z killing suspended foreground jobs in bash sessions#2663

Open
austinywang wants to merge 2 commits intomainfrom
issue-2105-ctrl-z-kills-process
Open

Fix Ctrl-Z killing suspended foreground jobs in bash sessions#2663
austinywang wants to merge 2 commits intomainfrom
issue-2105-ctrl-z-kills-process

Conversation

@austinywang
Copy link
Copy Markdown
Contributor

@austinywang austinywang commented Apr 7, 2026

Summary

  • Fixes Ctrl-Z killing suspended foreground jobs (vim, less, nano, etc.) in bash sessions inside cmux.
  • Wraps every { ... } & disown site in cmux-bash-integration.bash so the background work runs in its own process group, isolating it from bash's stopped-job SIGHUP cleanup.
  • Closes Using ctrl-z to background a process kills the process instead #2105

Root cause

When a foreground job is suspended with Ctrl-Z, bash sends SIGTSTP to the foreground process, reclaims the terminal, and runs PROMPT_COMMAND. cmux's _cmux_prompt_command then spawned background subshells with { ... } & disown.

disown only removes the job from bash's job table — it does not put the child in a separate process group. The disowned child stays in the shell's pgid. When that background subshell exits, bash notices a stopped job in the same pgid and runs its cleanup, which prints bash: warning: deleting stopped job 1 with process group <pgid> and SIGHUPs the whole pgid — killing the suspended foreground job.

Diagnosis credit goes to @anthhub in the issue thread; initial fix approach goes to @freshtonic.

The fix

Replace every { ... } >/dev/null 2>&1 & disown with one of:

  • Fire-and-forget: ( set -m; { ... } >/dev/null 2>&1 & ) — the outer subshell enables job control via set -m, so the inner & gets its own pgid, then exits. Bash's stopped-job cleanup can no longer reach it.
  • PID-tracked: var=$( set -m; { ... } & echo $! ) — same trick, but the inner PID is relayed via echo $! so existing kill -0 / kill cleanup paths still work.

setsid was rejected because it is not in macOS's default PATH (per the discussion in the issue thread).

Call sites updated

All in Resources/shell-integration/cmux-bash-integration.bash:

  • _cmux_relay_rpc_bg
  • _cmux_report_tty_once
  • _cmux_report_shell_activity_state
  • _cmux_ports_kick
  • _cmux_emit_pr_command_hint
  • _cmux_prompt_command (report_pwd block)
  • _cmux_run_pr_probe_with_timeout (PID-tracked)
  • _cmux_start_pr_poll_loop (PID-tracked)
  • _cmux_prompt_command git branch probe (PID-tracked)

After this patch, grep 'disown\| & *$' cmux-bash-integration.bash shows zero unwrapped disowns.

Test plan

  • bash -n cmux-bash-integration.bash passes (no syntax regressions).
  • ./scripts/reload.sh --tag issue-2105-ctrl-z builds successfully.
  • Open dev app, launch a bash terminal (bash -i), run vim, hit Ctrl-Z. Expected: [1]+ Stopped vim, prompt returns, no bash: warning: deleting stopped job line, fg resumes vim cleanly.
  • Repeat with less, nano, sleep 30, python3 -i.
  • Verify the sidebar git branch / PR badges still update on cd and git checkout (regression check for the PID-tracked rewrites).
  • Verify ports_kick still fires (open a workspace, run python3 -m http.server 8765, see the port appear in the sidebar).

🤖 Generated with Claude Code


Note

Medium Risk
Touches bash job-control/process management across multiple async paths; a mistake could break PR polling, git/TTY/ports reporting, or leave orphaned processes, but scope is limited to shell integration.

Overview
Fixes a bash job-control bug where cmux’s background subshells could trigger bash stopped-job cleanup and SIGHUP suspended foreground apps after Ctrl-Z.

All { ... } & disown call sites in cmux-bash-integration.bash are rewritten to run under an isolated process group via set -m, including TTY/shell-state/PWD/ports reporting and relay RPC.

Async workflows that need PID tracking (PR probe timeout handling, PR poll loop, git branch probe) are updated to launch in an isolated process group while still returning a usable PID; PR probe exit status is now captured via a temp status file instead of wait.

Reviewed by Cursor Bugbot for commit 9039a6a. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Fixes Ctrl-Z killing suspended foreground jobs in cmux bash sessions by running background tasks in their own process groups. Suspended apps like vim now survive, no warning, and fg resumes cleanly. Fixes #2105.

  • Bug Fixes
    • Replaced { ... } & disown with isolated wrappers:
      • Fire-and-forget: ( set -m; { ... } >/dev/null 2>&1 & )
      • PID-tracked: $( set -m; { ... } & echo $! )
    • Applied across cmux-bash-integration.bash for PR probes/polling, git branch probe, ports kick, TTY and shell-state reports, report_pwd, and RPC relay.
    • PR probe: fixed waiting on a non-child by writing the exit code to a temp file and reading it instead of wait, preserving timeouts and kill checks with isolated pgids.
    • Reason: set -m gives the inner & its own process group, avoiding bash’s stopped-job SIGHUP after PROMPT_COMMAND.
    • Avoided setsid to keep macOS default PATH compatibility.

Written for commit 9039a6a. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor
    • Improved background process isolation and lifecycle handling in shell integration for more reliable async tasks, reporting, prompts, and port/PR-related operations.
  • Bug Fixes
    • Prevented background job termination and incorrect status reporting (e.g., stopped-job SIGHUP and probe/poll PID issues), resulting in steadier polling and status updates.

When a foreground job is suspended with Ctrl-Z (e.g. vim), bash sends
SIGTSTP to the foreground process, reclaims the terminal, and runs
PROMPT_COMMAND. cmux's _cmux_prompt_command then spawned background
subshells with `{ ... } & disown`. The disowned children inherited
the shell's process group: when the background subshell exited, bash
noticed a stopped job in the same pgid and ran its cleanup, which
prints `bash: warning: deleting stopped job 1` and SIGHUPs the whole
process group — killing the suspended foreground job.

The fix is to put each background block in its own process group so
bash's stopped-job cleanup cannot reach it. This patch wraps every
`{ ... } & disown` site in `( set -m; { ... } & )` for fire-and-forget
work, and `$( set -m; { ... } & echo $! )` for PID-tracked work
(_cmux_start_pr_poll_loop, the git branch probe, and the PR probe
subshell). `set -m` enables job control inside the subshell so the
inner `&` gets its own pgid; `echo $!` relays the inner PID so existing
kill -0 / kill cleanup paths still work. setsid is not used because it
is not in macOS's default PATH.

All call sites in cmux-bash-integration.bash were updated:
_cmux_relay_rpc_bg, _cmux_report_tty_once, _cmux_report_shell_activity_state,
_cmux_ports_kick, _cmux_emit_pr_command_hint, the report_pwd block in
_cmux_prompt_command, _cmux_run_pr_probe_with_timeout,
_cmux_start_pr_poll_loop, and the git branch probe in _cmux_prompt_command.

Diagnosis credit: @anthhub. Initial fix approach: @freshtonic.

Closes #2105

Co-authored-by: anthhub <anthhub@users.noreply.github.com>
Co-authored-by: freshtonic <freshtonic@users.noreply.github.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 7, 2026 4:25am

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai bot commented Apr 7, 2026

This review could not be run because your cubic account has exceeded the monthly review limit. If you need help restoring access, please contact contact@cubic.dev.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

📝 Walkthrough

Walkthrough

Replaces & disown backgrounding in the bash shell integration with subshell-based process-group isolation (( set -m; ... & )) and updates async probe/poll PID and status capture to use command-substitution/status-file patterns to preserve kill-by-PID semantics and avoid stopped-job SIGHUP issues.

Changes

Cohort / File(s) Summary
Bash shell integration
Resources/shell-integration/cmux-bash-integration.bash
Replaced ... >/dev/null 2>&1 & disown patterns with ( set -m; { ... } >/dev/null 2>&1 & ) for relay RPC, TTY/reporting, ports kick, PR hints, prompt CWD, and async git branch reporting. _cmux_run_pr_probe_with_timeout now writes probe exit status to a temp file from the probe process; caller reads/validates that file instead of using wait. _cmux_start_pr_poll_loop launches the poll loop inside set -m-scoped command substitution and captures PID via echo $!, storing it in _CMUX_PR_POLL_PID to preserve existing kill-by-PID semantics while avoiding stopped-job SIGHUP behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped through shells and fixed the snare,
Set -m cradled jobs with tender care,
No more sudden deaths when Ctrl‑Z sings—
Backgrounds dance on, with safer wings. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly describes the main change: fixes Ctrl-Z killing suspended foreground jobs in bash, which is the primary bug being addressed.
Linked Issues check ✅ Passed Code changes directly address all requirements from issue #2105: isolate background operations from shell process group to prevent SIGHUP, use macOS-compatible approach, and cover all async call sites.
Out of Scope Changes check ✅ Passed All changes are focused on fixing the Ctrl-Z bug by wrapping background operations in isolated process groups; no unrelated modifications to other functionality detected.
Description check ✅ Passed The PR description is comprehensive and well-structured, providing clear summary, root cause analysis, fix explanation, call sites, and test plan.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2105-ctrl-z-kills-process

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 7, 2026

Greptile Summary

Fixes the Ctrl-Z / stopped-job SIGHUP bug (#2105) by replacing every { ... } & disown background spawn in cmux-bash-integration.bash with an isolated-pgid equivalent — ( set -m; { ... } & ) for fire-and-forget sites, $( set -m; { ... } &; echo $! ) for PID-tracked sites. All nine call sites are updated consistently and the approach is technically sound.

Confidence Score: 5/5

Safe to merge — all background spawn sites are correctly isolated; the only finding is a cosmetic P2 inconsistency in kill semantics for the git probe.

All 9 call sites are consistently updated with the correct set -m isolation pattern. The fire-and-forget and PID-tracked variants are both mechanically correct. The single finding (P2) is a kill-semantics inconsistency for _CMUX_GIT_JOB_PID — orphaned git children run to natural completion, which is benign. No correctness, data-integrity, or reliability issues were found.

No files require special attention; cmux-bash-integration.bash is the only changed file and has been thoroughly reviewed.

Important Files Changed

Filename Overview
Resources/shell-integration/cmux-bash-integration.bash Replaces all { ... } & disown background spawn sites with isolated-pgid equivalents using set -m; logic is correct across all 9 call sites

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["PROMPT_COMMAND fires\n(e.g. after Ctrl-Z + fg)"] --> B["_cmux_prompt_command"]
    B --> C["_cmux_report_shell_activity_state"]
    B --> D["_cmux_report_tty_once"]
    B --> E["report_pwd block"]
    B --> F["_cmux_start_pr_poll_loop"]
    B --> G["git branch probe"]
    B --> H["_cmux_ports_kick"]
    B --> I["_cmux_emit_pr_command_hint"]
    C --> C1["( set -m; \n{ _cmux_send } & )\nown pgid"]
    D --> D1["( set -m; \n{ _cmux_send } & )\nown pgid"]
    E --> E1["( set -m; \n{ _cmux_send } & )\nown pgid"]
    F --> F1["_CMUX_PR_POLL_PID=$(\n  set -m; { loop } &; echo $! )\nown pgid — kill via -pgid"]
    G --> G1["_CMUX_GIT_JOB_PID=$(\n  set -m; { git } &; echo $! )\nown pgid"]
    H --> H1["( set -m; \n{ _cmux_send } & )\nown pgid"]
    I --> I1["( set -m; \n{ _cmux_send } & )\nown pgid"]
    style C1 fill:#d4edda,stroke:#28a745
    style D1 fill:#d4edda,stroke:#28a745
    style E1 fill:#d4edda,stroke:#28a745
    style F1 fill:#d4edda,stroke:#28a745
    style G1 fill:#d4edda,stroke:#28a745
    style H1 fill:#d4edda,stroke:#28a745
    style I1 fill:#d4edda,stroke:#28a745
Loading

Comments Outside Diff (1)

  1. Resources/shell-integration/cmux-bash-integration.bash, line 1062-1064 (link)

    P2 Inconsistent process-group kill for git probe

    kill "$_CMUX_GIT_JOB_PID" sends SIGTERM only to the process-group leader. Because set -m now makes _CMUX_GIT_JOB_PID a process-group leader (pgid == pid), any in-flight git subprocess spawned inside the probe will be orphaned and continue running until it exits naturally. _cmux_halt_pr_poll_loop already uses kill -KILL -- -"$_CMUX_PR_POLL_PID" (whole-group kill) for the same reason; using the same pattern here would keep cleanup consistent.

Reviews (1): Last reviewed commit: "Fix Ctrl-Z killing suspended foreground ..." | Re-trigger Greptile

@austinywang
Copy link
Copy Markdown
Contributor Author

Cursor Bugbot caught a real bash bug in _cmux_run_pr_probe_with_timeout: the probe PID comes out of a command-substitution subshell, so the probe is not a direct child of the caller and bash always falls back to 127. I fixed that by keeping the isolated process-group launch, but having the probe write its exit status to a mktemp status file instead. The timeout path now removes that file and returns 1 explicitly, and the success path does a short post-exit sleep before reading the status file to avoid the flush race. I also re-checked the rest of cmux-bash-integration.bash for the same plus shape and didn’t find any other wait-on-non-child sites.

@austinywang
Copy link
Copy Markdown
Contributor Author

Cursor Bugbot caught a real bash bug in _cmux_run_pr_probe_with_timeout: the probe PID comes out of a command-substitution subshell, so the probe is not a direct child of the caller and bash wait "$probe_pid" always falls back to 127. I fixed that by keeping the isolated process-group launch, but having the probe write its exit status to a mktemp status file instead. The timeout path now removes that file and returns 1 explicitly, and the success path does a short post-exit sleep before reading the status file to avoid the flush race. I also re-checked the rest of cmux-bash-integration.bash for the same probe_pid=$( ... ) plus wait shape and didn’t find any other wait-on-non-child sites.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is kicking off a free cloud agent to fix this issue. This run is complimentary, but you can enable autofix for all future PRs in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9039a6a. Configure here.

echo $? > "$status_file"
) &
echo $!
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested set -m lets probes escape process-group kill

Medium Severity

_cmux_run_pr_probe_with_timeout uses set -m to give the probe its own process group, but it only runs inside the poll loop (which already has its own process group from _cmux_start_pr_poll_loop). When _cmux_halt_pr_poll_loop does kill -KILL -- -"$_CMUX_PR_POLL_PID", the probe is in a different process group and survives. The timeout-enforcement loop dies with the poll loop, leaving an unsupervised gh process. Temp status files also leak. Previously the probe inherited the poll loop's process group and was killed together with it.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9039a6a. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Using ctrl-z to background a process kills the process instead

1 participant