@@ -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