@@ -622,7 +622,9 @@ def _session_leader_job_stops(pipeline: "ProcReader | None" = None) -> Iterator[
622622 """
623623 import signal
624624
625- if os .getpgrp () != os .getsid (0 ):
625+ # A handler installed outside Python is reported as None, and Python could not reinstall it after the spawn. So it stays,
626+ # and the pipeline's watcher continues any stop in the job, having no handler to relay it to.
627+ if os .getpgrp () != os .getsid (0 ) or signal .getsignal (signal .SIGTSTP ) is None :
626628 yield
627629 return
628630 # A stop the pipeline's watcher relayed to cmd2 meanwhile would be discarded, and the watcher would wait for cmd2 to resume
@@ -700,6 +702,10 @@ def __init__(
700702 if self ._proc .stderr is not None :
701703 self ._err_thread .start ()
702704
705+ # A terminal pipeline is reaped only by its watcher, which relays its stops once _manage_terminal() sets up job control
706+ if terminal_fd is not None :
707+ threading .Thread (name = "pipe_job" , target = self ._wait_for_job , args = (terminal_fd ,), daemon = True ).start ()
708+
703709 def send_sigint (self ) -> None :
704710 """Send a SIGINT to the process similar to if <Ctrl>+C were pressed."""
705711 import signal
@@ -710,19 +716,8 @@ def send_sigint(self) -> None:
710716 self ._proc .send_signal (signal .CTRL_BREAK_EVENT )
711717 else :
712718 # Since cmd2 uses shell=True in its Popen calls, we need to send the SIGINT to the whole process group to make sure
713- # it propagates further than the shell. Once reaped, the process's ID may already belong to another process.
714- group_id = None
715- if self ._proc .returncode is None :
716- with contextlib .suppress (ProcessLookupError ):
717- group_id = os .getpgid (self ._proc .pid )
718- if group_id is None :
719- group_id = self ._joined_group ()
720- if group_id is None :
721- return
722- # Never re-signal our own group: other ProcReader callers may share it and already received Ctrl-C.
723- if group_id != os .getpgrp ():
724- with contextlib .suppress (ProcessLookupError , PermissionError ):
725- os .killpg (group_id , signal .SIGINT )
719+ # it propagates further than the shell.
720+ self ._signal_pipeline (signal .SIGINT )
726721
727722 def terminate (self ) -> None :
728723 """Terminate the process."""
@@ -764,8 +759,16 @@ def _signal_pipeline(self, signum: int) -> bool:
764759 The group may be gone, or hold only processes cmd2 may not signal: a zombie on
765760 macOS, or a program running as another user, such as sudo.
766761 """
767- group_id = self ._proc .pid if self ._proc .returncode is None else self ._joined_group ()
762+ group_id = None
763+ # Once reaped, the process's ID may already belong to another process.
764+ if self ._proc .returncode is None :
765+ with contextlib .suppress (ProcessLookupError ):
766+ group_id = os .getpgid (self ._proc .pid )
768767 if group_id is None :
768+ group_id = self ._joined_group ()
769+ # Never signal our own group: that would stop or continue cmd2 itself, and other ProcReader callers may share it and
770+ # already received Ctrl-C.
771+ if group_id is None or group_id == os .getpgrp ():
769772 return False
770773 try :
771774 os .killpg (group_id , signum )
@@ -781,7 +784,7 @@ def _set_foreground_group(terminal_fd: int, group_id: int) -> None:
781784
782785 @contextlib .contextmanager
783786 def _manage_terminal (self ) -> Iterator [None ]:
784- """Watch the pipeline and suspend the shell's whole job on the main thread."""
787+ """Suspend the shell's whole job on the main thread, and relay the pipeline's stops to it ."""
785788 import signal
786789
787790 terminal_fd = self ._terminal_fd
@@ -830,18 +833,22 @@ def suspend_job(signum: int, frame: Any) -> None:
830833 self ._suspension_lock .release ()
831834 self ._job_resumed .set ()
832835
833- self ._stop_handler = suspend_job
834- signal .signal (signal .SIGTSTP , suspend_job )
836+ # A handler installed outside Python, such as by an application embedding it, is reported as None, and Python could not
837+ # reinstall it. So it stays: Ctrl-Z does only what it does, and the watcher continues any stop in the pipeline's job,
838+ # having no handler to relay it to.
839+ if previous_handler is not None :
840+ self ._stop_handler = suspend_job
841+ signal .signal (signal .SIGTSTP , suspend_job )
835842 try :
836- threading .Thread (name = "pipe_job" , target = self ._wait_for_job , args = (terminal_fd ,), daemon = True ).start ()
837843 yield
838844 finally :
839845 # The watcher may outlive job control, if waiting for the pipeline failed. Without suspend_job(), a stop it relayed
840846 # would stop cmd2 with nothing to resume either. So detach it first, under the lock a suspension holds, and it
841847 # relays no more.
842848 with self ._suspension ():
843849 self ._detached = True
844- signal .signal (signal .SIGTSTP , previous_handler )
850+ if previous_handler is not None :
851+ signal .signal (signal .SIGTSTP , previous_handler )
845852
846853 @contextlib .contextmanager
847854 def _suspension (self ) -> Iterator [None ]:
@@ -875,7 +882,11 @@ def _lend_terminal(self) -> Iterator[None]:
875882 with _sigttou_mask (block = True ):
876883 with self ._terminal_lock :
877884 try :
878- self ._set_foreground_group (terminal_fd , self ._proc .pid )
885+ # Lend only a terminal cmd2's job owns. Once the shell's bg has continued cmd2 in the background, the shell
886+ # owns it, and taking it would leave the shell unable to read its terminal. A consumer that needs it then
887+ # stops, and the watcher relays that stop to cmd2's job, as a shell's own background pipeline would stop.
888+ if os .tcgetpgrp (terminal_fd ) in (self ._original_group , self ._proc .pid ):
889+ self ._set_foreground_group (terminal_fd , self ._proc .pid )
879890 except OSError as error :
880891 # The group can disappear before the watcher has recorded its exit. Linux reports a group that no longer
881892 # exists as EPERM.
@@ -996,10 +1007,11 @@ def _suspend_with_cmd2(self, terminal_fd: int, seen: int) -> None:
9961007 with self ._suspension ():
9971008 if self ._suspensions != seen or self ._detached :
9981009 return
999- # Only suspend_job() resumes a pipeline stopped for a relay. Should command code have replaced it as SIGTSTP's
1000- # handler, or ignored the signal, the pipeline would stay stopped for good. So it goes on, as when cmd2 ignores
1001- # Ctrl-Z. Like a suspension, that deals with every stop reported before it.
1002- if signal .getsignal (signal .SIGTSTP ) is not self ._stop_handler :
1010+ # Only suspend_job() resumes a pipeline stopped for a relay. Should it not be SIGTSTP's handler, because command
1011+ # code replaced it or ignored the signal, or job control left a handler installed outside Python, the pipeline
1012+ # would stay stopped for good. So it goes on, as when cmd2 ignores Ctrl-Z. Like a suspension, that deals with
1013+ # every stop reported before it.
1014+ if self ._stop_handler is None or signal .getsignal (signal .SIGTSTP ) is not self ._stop_handler :
10031015 self ._signal_pipeline (signal .SIGCONT )
10041016 self ._suspensions += 1
10051017 return
@@ -1078,6 +1090,9 @@ def wait(self) -> None:
10781090 if self ._terminal_fd is not None :
10791091 with self ._lend_terminal ():
10801092 self ._wait_for_exit ()
1093+ elif self ._pipeline is not None :
1094+ # Only this wait reaps a producer in a pipeline's job. Until it does, the reader threads wait for its return code.
1095+ self ._wait_for_exit ()
10811096 if self ._out_thread .is_alive ():
10821097 self ._out_thread .join ()
10831098 if self ._err_thread .is_alive ():
@@ -1281,6 +1296,8 @@ def __init__(self, fd: int, reader: ProcReader, interruptible: Callable[[], bool
12811296 self ._relay_lock = threading .Lock ()
12821297 self ._poller = select .poll ()
12831298 self ._poller .register (fd , select .POLLOUT )
1299+ # Set when Ctrl-C interrupted a write that had sent part of its data: see write()
1300+ self ._interrupted = False
12841301
12851302 def fileno (self ) -> int :
12861303 """Return a descriptor for subprocesses, such as a shell command's stdout.
@@ -1334,10 +1351,18 @@ def write(self, b: Any) -> int:
13341351 on its own for the handler to run. The descriptor itself stays blocking: a shell
13351352 command inherits it, and a producer that found it non-blocking would fail with
13361353 EAGAIN once the pipe filled.
1354+
1355+ Ctrl-C can raise KeyboardInterrupt between two chunks, once some are in the pipe.
1356+ BufferedWriter takes an exception to mean that nothing was written, and would send
1357+ those chunks again. So such a write reports what it sent, and the next one, which
1358+ BufferedWriter makes straight away for the rest, raises the interrupt instead.
13371359 """
13381360 import select
13391361 import signal
13401362
1363+ if self ._interrupted :
1364+ self ._interrupted = False
1365+ raise KeyboardInterrupt
13411366 view = memoryview (b ).cast ("B" )
13421367 fd = super ().fileno ()
13431368 written = 0
@@ -1367,6 +1392,10 @@ def write(self, b: Any) -> int:
13671392 if self ._reader ._proc .returncode in (- signal .SIGINT , 128 + signal .SIGINT ):
13681393 raise KeyboardInterrupt from None
13691394 raise
1395+ except KeyboardInterrupt :
1396+ if not written :
1397+ raise
1398+ self ._interrupted = True
13701399 return written
13711400
13721401
0 commit comments