Skip to content

Commit aad3d01

Browse files
committed
Run POSIX pipes to interactive programs as the terminal's foreground job
A pipe process always ran in its own session, so it never received the terminal. Piped to an interactive program such as less, Ctrl-Z and fg did not suspend and resume it together with cmd2, and the program could not take the keyboard as a shell's foreground job would. When a pipe's output goes to cmd2's controlling terminal and the pipe is started on the main thread, run the pipe process in a new process group and lend it the terminal as it starts, while cmd2 writes to it, and while cmd2 waits for it. Between writes the terminal returns to cmd2, so command code can still read the keyboard. ProcReader watches the group on a thread, relays its job-control stops to cmd2's job, and forwards Ctrl-C without signalling cmd2's own group again. A shell command piped to such a program joins the pipeline's job for as long as it runs. Pipes started off the main thread, or whose output cmd2 captures, still run in their own session. The terminal tests drive cmd2 under an interactive bash on a pseudo-terminal, using pyte to read the screen, and coverage now follows the applications those tests start. Extracted from the reserved-row toolbar work (PR #1761) without the toolbar.
1 parent 579d47c commit aad3d01

8 files changed

Lines changed: 1772 additions & 70 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
## 4.2.5 (TBD)
2+
3+
- Bug Fixes
4+
- On POSIX, piping a command's output to an interactive program such as `less`
5+
(`help -v | less`) now runs that program as the terminal's foreground job, as a shell pipeline
6+
does. It had run in a separate session that never received the terminal, so Ctrl-Z and `fg`
7+
did not suspend and resume it together with cmd2. The program now owns the terminal as it
8+
starts, so a pager can set its terminal modes, and Ctrl-C and Ctrl-Z reach the whole pipeline.
9+
Pipes started from a worker thread, or whose output cmd2 captures, still run in their own
10+
session
11+
- A `shell` command piped to an interactive program, such as `shell git log | less`, now joins
12+
the pipeline's job, so both processes receive Ctrl-C and Ctrl-Z
13+
114
## 4.2.4 (September 8, 2026)
215

316
- Bug Fixes

‎cmd2/cmd2.py‎

Lines changed: 162 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3330,46 +3330,102 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
33303330
subproc_stdin = open(read_fd, encoding="utf-8") # noqa: SIM115
33313331
new_stdout: TextIO = cast(TextIO, open(write_fd, "w", encoding="utf-8")) # noqa: SIM115
33323332

3333-
# Create pipe process in a separate group to isolate our signals from it. If a Ctrl-C event occurs,
3334-
# our sigint handler will forward it only to the most recent pipe process. This makes sure pipe
3335-
# processes close in the right order (most recent first).
3333+
# Isolate pipeline signals from cmd2. Terminal pipelines receive the
3334+
# foreground terminal; ProcReader relays their job-control stops.
33363335
kwargs: dict[str, Any] = {}
33373336
if sys.platform == "win32":
33383337
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
33393338
else:
3340-
kwargs["start_new_session"] = True
3341-
33423339
# Attempt to run the pipe process in the user's preferred shell instead of the default behavior of using sh.
33433340
shell = os.environ.get("SHELL")
33443341
if shell:
33453342
kwargs["executable"] = shell
33463343

33473344
# For any stream that is a StdSim, we will use a pipe so we can capture its output
3348-
proc = subprocess.Popen( # noqa: S602
3349-
statement.redirect_to,
3350-
stdin=subproc_stdin,
3351-
stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable]
3352-
stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
3353-
shell=True,
3354-
**kwargs,
3355-
)
3345+
pipe_stdout = None if isinstance(self.stdout, utils.StdSim) else self.stdout # type: ignore[unreachable]
3346+
pipe_stderr = None if isinstance(sys.stderr, utils.StdSim) else sys.stderr
3347+
3348+
terminal_fd = None
3349+
if sys.platform != "win32":
3350+
# Job control installs signal handlers, which only the main thread may do.
3351+
# Elsewhere, keep the pipeline in its own session as before.
3352+
if threading.current_thread() is threading.main_thread():
3353+
for stream in (pipe_stdout, pipe_stderr):
3354+
if stream is not None and stream.isatty():
3355+
with contextlib.suppress(OSError, ValueError):
3356+
if os.tcgetpgrp(stream.fileno()) == os.getpgrp():
3357+
terminal_fd = stream.fileno()
3358+
break
3359+
if terminal_fd is None:
3360+
kwargs["start_new_session"] = True
3361+
else:
3362+
kwargs["process_group"] = 0
33563363

3357-
# Popen was called with shell=True so the user can chain pipe commands and redirect their output
3358-
# like: !ls -l | grep user | wc -l > out.txt. But this makes it difficult to know if the pipe process
3359-
# started OK, since the shell itself always starts. Therefore, we will wait a short time and check
3360-
# if the pipe process is still running.
3361-
with contextlib.suppress(subprocess.TimeoutExpired):
3362-
proc.wait(0.2)
3364+
with contextlib.ExitStack() as terminal_stack:
3365+
with contextlib.ExitStack() as spawn_stack:
3366+
if terminal_fd is not None and os.getpgrp() == os.getsid(0):
3367+
import signal
33633368

3364-
# Check if the pipe process already exited
3365-
if proc.returncode is not None:
3369+
# A session leader's job has no outer shell to resume it.
3370+
# Its pipeline must inherit the same Ctrl-Z behavior: the
3371+
# new group would otherwise make SIGTSTP actionable again.
3372+
previous_tstp = signal.signal(signal.SIGTSTP, signal.SIG_IGN)
3373+
spawn_stack.callback(signal.signal, signal.SIGTSTP, previous_tstp)
3374+
proc = subprocess.Popen( # noqa: S602
3375+
statement.redirect_to,
3376+
stdin=subproc_stdin,
3377+
stdout=subprocess.PIPE if pipe_stdout is None else pipe_stdout,
3378+
stderr=subprocess.PIPE if pipe_stderr is None else pipe_stderr,
3379+
shell=True,
3380+
**kwargs,
3381+
)
3382+
# Only the child should own a read end. In particular, a consumer
3383+
# exit must unblock a producer writing to a full pipe immediately.
33663384
subproc_stdin.close()
3367-
new_stdout.close()
3368-
raise RedirectionError(f"Pipe process exited with code {proc.returncode} before command could run")
3369-
redir_saved_state.redirecting = True
3370-
cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
3385+
if terminal_fd is not None:
3386+
cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr, terminal_fd=terminal_fd)
3387+
terminal_stack.enter_context(cmd_pipe_proc_reader.manage_terminal())
3388+
3389+
# Popen was called with shell=True so the user can chain pipe commands and redirect their output
3390+
# like: !ls -l | grep user | wc -l > out.txt. But this makes it difficult to know if the pipe process
3391+
# started OK, since the shell itself always starts. Therefore, we will wait a short time and check
3392+
# if the pipe process is still running.
3393+
with contextlib.suppress(subprocess.TimeoutExpired):
3394+
if cmd_pipe_proc_reader is None:
3395+
proc.wait(0.2)
3396+
else:
3397+
# A pager such as less sets its terminal modes as it starts, before it
3398+
# reads the pipe. It must own the terminal by then: a background
3399+
# tcsetattr() stops it with SIGTTOU, and on macOS that call fails with
3400+
# EINTR when the process is continued instead of being restarted. less
3401+
# ignores the failure and runs on a cooked terminal.
3402+
with cmd_pipe_proc_reader.lend_terminal():
3403+
cmd_pipe_proc_reader.wait_for_exit(0.2)
3404+
3405+
# Check if the pipe process already exited
3406+
if proc.returncode is not None:
3407+
if cmd_pipe_proc_reader is not None:
3408+
cmd_pipe_proc_reader.wait()
3409+
subproc_stdin.close()
3410+
new_stdout.close()
3411+
raise RedirectionError(f"Pipe process exited with code {proc.returncode} before command could run")
3412+
redir_saved_state.redirecting = True
3413+
if cmd_pipe_proc_reader is None:
3414+
cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
33713415

3372-
self.stdout = new_stdout
3416+
if terminal_fd is not None:
3417+
import io
3418+
3419+
pipe_fd = os.dup(new_stdout.fileno())
3420+
new_stdout.close()
3421+
new_stdout = io.TextIOWrapper(
3422+
io.BufferedWriter(utils.PipelineWriter(pipe_fd, cmd_pipe_proc_reader)), encoding="utf-8"
3423+
)
3424+
3425+
self.stdout = new_stdout
3426+
3427+
# Keep the pipeline's job control until _restore_output() reaps the pipe process.
3428+
redir_saved_state.pipeline_job = terminal_stack.pop_all()
33733429

33743430
elif statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND):
33753431
if statement.redirect_to:
@@ -3428,29 +3484,41 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec
34283484
:param statement: Statement object which contains the parsed input from the user
34293485
:param saved_redir_state: contains information needed to restore state data
34303486
"""
3431-
if saved_redir_state.redirecting:
3432-
# If we redirected output to the clipboard
3433-
if (
3434-
statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND)
3435-
and not statement.redirect_to
3436-
):
3437-
self.stdout.seek(0)
3438-
write_to_paste_buffer(self.stdout.read())
3487+
# The pipeline's job control ends once its pipe process has been reaped.
3488+
with contextlib.ExitStack() as terminal_stack:
3489+
if saved_redir_state.pipeline_job is not None:
3490+
terminal_stack.callback(saved_redir_state.pipeline_job.close)
3491+
saved_redir_state.pipeline_job = None
34393492

3440-
with contextlib.suppress(BrokenPipeError):
3441-
# Close the file or pipe that stdout was redirected to
3442-
self.stdout.close()
3443-
3444-
# Restore self.stdout
3445-
self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout)
3446-
3447-
# Check if we need to wait for the process being piped to
3448-
if self._cur_pipe_proc_reader is not None:
3449-
self._cur_pipe_proc_reader.wait()
3450-
3451-
# These are restored regardless of whether the command redirected
3452-
self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader
3453-
self._redirecting = saved_redir_state.saved_redirecting
3493+
try:
3494+
if saved_redir_state.redirecting:
3495+
# If we redirected output to the clipboard
3496+
if (
3497+
statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND)
3498+
and not statement.redirect_to
3499+
):
3500+
self.stdout.seek(0)
3501+
write_to_paste_buffer(self.stdout.read())
3502+
3503+
with contextlib.suppress(BrokenPipeError):
3504+
# Close the file or pipe that stdout was redirected to
3505+
if self._cur_pipe_proc_reader is not None:
3506+
self._cur_pipe_proc_reader.finish_producer()
3507+
self.stdout.close()
3508+
3509+
# Restore self.stdout
3510+
self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout)
3511+
3512+
# Check if we need to wait for the process being piped to. Handing the
3513+
# terminal back as it finishes can fail, for example after a hangup.
3514+
if self._cur_pipe_proc_reader is not None:
3515+
self._cur_pipe_proc_reader.wait()
3516+
finally:
3517+
# These are restored regardless of whether the command redirected, or whether
3518+
# restoring it failed: a pipeline left current would keep ppaged() from paging
3519+
# and send Ctrl-C to a process group that is gone.
3520+
self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader
3521+
self._redirecting = saved_redir_state.saved_redirecting
34543522

34553523
def get_command_func(self, command: str) -> BoundCommandFunc[...] | None:
34563524
"""Get the bound command function for a command.
@@ -4918,19 +4986,52 @@ def do_shell(self, args: argparse.Namespace) -> None:
49184986
utils.expand_user_in_tokens(tokens)
49194987
expanded_command = " ".join(tokens)
49204988

4921-
# Prevent KeyboardInterrupts while in the shell process. The shell process will
4922-
# still receive the SIGINT since it is in the same process group as us.
4923-
with self.sigint_protection:
4924-
# For any stream that is a StdSim, we will use a pipe so we can capture its output
4925-
proc = subprocess.Popen( # noqa: S602
4926-
expanded_command,
4927-
stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable]
4928-
stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
4929-
shell=True,
4930-
**kwargs,
4931-
)
4932-
4933-
proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
4989+
# A terminal pipeline's consumer needs the terminal to drain the pipe, but a shell
4990+
# command writes into that pipe itself rather than through self.stdout, which lends
4991+
# the terminal per write. Run the command inside the pipeline's job instead, for as
4992+
# long as it runs: the consumer keeps the terminal, and Ctrl-C and Ctrl-Z reach both
4993+
# processes, as they would in a shell pipeline.
4994+
pipeline = self._cur_pipe_proc_reader
4995+
pipeline_group = None
4996+
if pipeline is not None and not isinstance(self.stdout, utils.StdSim): # type: ignore[unreachable]
4997+
pipeline_group = pipeline.terminal_group
4998+
4999+
# Prevent KeyboardInterrupts while in the shell process. The shell process still
5000+
# receives the SIGINT: it is in our process group or in the foreground pipeline's.
5001+
with self.sigint_protection, contextlib.ExitStack() as terminal_stack:
5002+
if pipeline is not None and pipeline_group is not None:
5003+
kwargs["process_group"] = pipeline_group
5004+
terminal_stack.enter_context(pipeline.lend_terminal())
5005+
while True:
5006+
try:
5007+
# For any stream that is a StdSim, we will use a pipe so we can capture its output.
5008+
# A command joining the pipeline is spawned inside the lend, which blocks SIGTTOU.
5009+
with utils.unblocked_sigttou() if "process_group" in kwargs else contextlib.nullcontext():
5010+
proc = subprocess.Popen( # noqa: S602
5011+
expanded_command,
5012+
stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable]
5013+
stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
5014+
shell=True,
5015+
**kwargs,
5016+
)
5017+
break
5018+
except PermissionError:
5019+
# The pipeline exited before the command could join its group.
5020+
if kwargs.pop("process_group", None) is None:
5021+
raise
5022+
# The retry runs in our own group, so take the terminal back from the dead
5023+
# pipeline first. Its watcher left it lent, and the command would otherwise
5024+
# stop with SIGTTIN on its first terminal read, with nothing to resume it.
5025+
terminal_stack.close()
5026+
5027+
# A command that joined the pipeline's job is waited for in short polls. Only the
5028+
# main thread runs Python signal handlers, and the job-control stop the pipeline's
5029+
# watcher relays may wake another thread. Once the consumer and its watcher are
5030+
# gone, the same wait relays the command's own stops, such as Ctrl-Z.
5031+
joined_pipeline = pipeline if "process_group" in kwargs else None
5032+
proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr, pipeline=joined_pipeline)
5033+
if joined_pipeline is not None:
5034+
proc_reader.wait_for_exit()
49345035
proc_reader.wait()
49355036

49365037
# Save the return code of the application for use in a pyscript

0 commit comments

Comments
 (0)