Skip to content

Commit a645ace

Browse files
committed
Fix terminal handoffs and signaling found in review
- A lend takes the terminal only while cmd2's job owns it. After the shell's bg, a write that filled the pipe gave the terminal to the consumer and then to cmd2, and the shell could no longer read its terminal. Now a consumer that needs it stops, and cmd2's job stops with it. - _restore_output() lends the terminal before closing the pipe, so a consumer that sets its terminal modes on EOF, such as vim -, is not stopped with SIGTTOU. - ProcReader's terminal_fd and pipeline arguments work on their own: the watcher starts with the reader, _manage_terminal() only installs the Ctrl-Z handler, and wait() reaps a producer that joined a pipeline. - A write interrupted by Ctrl-C after sending some chunks reports them and raises from the next write, so BufferedWriter does not send them again. - do_shell reads the pipeline's group once, and send_sigint() and _signal_pipeline() share one group lookup that never signals cmd2's own group. - A SIGTSTP handler installed outside Python, which signal.getsignal() reports as None, is left in place rather than failing to be restored. - The do_shell stdout choice no longer needs a type: ignore, which made its line too long.
1 parent 880c518 commit a645ace

5 files changed

Lines changed: 445 additions & 62 deletions

File tree

‎cmd2/cmd2.py‎

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3506,24 +3506,27 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec
35063506
# Restore self.stdout first, so that a failure to end the redirection cannot leave it in place
35073507
redirected_stdout = self.stdout
35083508
self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout)
3509-
3510-
try:
3511-
# If we redirected output to the clipboard
3512-
if (
3513-
statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND)
3514-
and not statement.redirect_to
3515-
):
3516-
redirected_stdout.seek(0)
3517-
write_to_paste_buffer(redirected_stdout.read())
3518-
finally:
3519-
with contextlib.suppress(BrokenPipeError):
3520-
# Close the file or pipe that stdout was redirected to
3521-
redirected_stdout.close()
3522-
3523-
# Check if we need to wait for the process being piped to. Handing the terminal back as it finishes can
3524-
# fail, for example after a hangup.
3525-
if self._cur_pipe_proc_reader is not None:
3526-
self._cur_pipe_proc_reader.wait()
3509+
pipe_proc_reader = self._cur_pipe_proc_reader
3510+
3511+
# A terminal pipeline has the terminal before its consumer reads EOF, which may set its terminal modes as
3512+
# it does, and keeps it until it exits. Handing the terminal back then can fail, as after a hangup.
3513+
with pipe_proc_reader._lend_terminal() if pipe_proc_reader is not None else contextlib.nullcontext():
3514+
try:
3515+
# If we redirected output to the clipboard
3516+
if (
3517+
statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND)
3518+
and not statement.redirect_to
3519+
):
3520+
redirected_stdout.seek(0)
3521+
write_to_paste_buffer(redirected_stdout.read())
3522+
finally:
3523+
with contextlib.suppress(BrokenPipeError):
3524+
# Close the file or pipe that stdout was redirected to
3525+
redirected_stdout.close()
3526+
3527+
# Check if we need to wait for the process being piped to
3528+
if pipe_proc_reader is not None:
3529+
pipe_proc_reader.wait()
35273530
finally:
35283531
# These are restored regardless of whether the command redirected, or whether restoring it failed: a pipeline
35293532
# left current would keep ppaged() from paging and send Ctrl-C to a process group that is gone.
@@ -5004,32 +5007,34 @@ def do_shell(self, args: argparse.Namespace) -> None:
50045007
# handlers.
50055008
pipeline = self._cur_pipe_proc_reader
50065009
writer = utils._pipeline_writer_of(self.stdout)
5007-
joined_writer = None
5010+
job_group = None
50085011
if (
50095012
pipeline is not None
50105013
and writer is not None
50115014
and writer.feeds(pipeline)
50125015
and threading.current_thread() is threading.main_thread()
5013-
and pipeline._terminal_group is not None
50145016
):
5015-
joined_writer = writer
5017+
# Read the group once: the pipeline's watcher may reap the consumer at any moment, which leaves none to join.
5018+
job_group = pipeline._terminal_group
5019+
joined_writer = writer if job_group is not None else None
50165020

50175021
# Prevent KeyboardInterrupts while in the shell process. The shell process still receives the SIGINT: it is in our
50185022
# process group or in the foreground pipeline's.
50195023
with self.sigint_protection, contextlib.ExitStack() as terminal_stack:
50205024
if pipeline is not None and joined_writer is not None:
5021-
kwargs["process_group"] = pipeline._terminal_group
5025+
kwargs["process_group"] = job_group
50225026
terminal_stack.enter_context(pipeline._lend_terminal())
50235027
while True:
50245028
try:
50255029
# For any stream that is a StdSim, we will use a pipe so we can capture its output. A command joining the
50265030
# pipeline writes to its consumer's pipe directly, after what cmd2 wrote before it. It is spawned inside
50275031
# the lend, which blocks SIGTTOU, and with the job's Ctrl-Z behavior.
5032+
stdout: Any = self.stdout
50285033
if joined_writer is not None:
50295034
self.stdout.flush()
5030-
stdout: Any = joined_writer.producer_fileno()
5031-
else:
5032-
stdout = subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout # type: ignore[unreachable]
5035+
stdout = joined_writer.producer_fileno()
5036+
elif isinstance(stdout, utils.StdSim):
5037+
stdout = subprocess.PIPE
50335038
with (
50345039
utils._sigttou_mask(block=False) if joined_writer is not None else contextlib.nullcontext(),
50355040
utils._session_leader_job_stops(pipeline) if joined_writer is not None else contextlib.nullcontext(),
@@ -5058,8 +5063,6 @@ def do_shell(self, args: argparse.Namespace) -> None:
50585063
# its watcher are gone, the same wait relays the command's own stops, such as Ctrl-Z.
50595064
joined_pipeline = pipeline if joined_writer is not None else None
50605065
proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr, pipeline=joined_pipeline)
5061-
if joined_pipeline is not None:
5062-
proc_reader._wait_for_exit()
50635066
proc_reader.wait()
50645067

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

‎cmd2/utils.py‎

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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

‎tests/test_cmd2.py‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,41 @@ def popen(*args, **kwargs):
485485
assert spawned_while_lent == [True, False]
486486

487487

488+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups")
489+
def test_shell_reads_the_pipelines_group_once(base_app) -> None:
490+
"""The pipeline's watcher can reap the consumer at any moment, after which it has no group to join.
491+
492+
Deciding to join from one reading and joining with another could spawn the command in
493+
cmd2's own group while treating it as the pipeline's: Ctrl-Z would then stop cmd2 itself.
494+
"""
495+
import contextlib
496+
import subprocess
497+
from unittest import mock
498+
499+
leader = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], process_group=0)
500+
spawned = []
501+
real_popen = subprocess.Popen
502+
503+
def popen(*args, **kwargs):
504+
spawned.append(kwargs)
505+
return real_popen(*args, **kwargs)
506+
507+
pipeline = mock.Mock(_lend_terminal=contextlib.nullcontext, _suspension=contextlib.nullcontext)
508+
# The consumer is reaped right after the first reading.
509+
type(pipeline)._terminal_group = mock.PropertyMock(side_effect=[leader.pid, None, None])
510+
base_app._cur_pipe_proc_reader = pipeline
511+
base_app.stdout, read_fd = pipeline_stdout(pipeline)
512+
try:
513+
with mock.patch("subprocess.Popen", popen):
514+
base_app.do_shell("echo joined")
515+
assert spawned[0]["process_group"] == leader.pid
516+
base_app.stdout.close()
517+
assert read_all(read_fd) == b"joined\n"
518+
finally:
519+
leader.kill()
520+
leader.wait()
521+
522+
488523
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell executable")
489524
def test_shell_permission_error_unrelated_to_pipeline(base_app, tmp_path, monkeypatch) -> None:
490525
unusable_shell = tmp_path / "shell"
@@ -1114,7 +1149,7 @@ def test_restore_output_resets_pipe_state_when_the_wait_fails(base_app) -> None:
11141149
saved_stdout = base_app.stdout
11151150
saved = cmd2.utils.RedirectionSavedState(saved_stdout, None, False)
11161151
saved.redirecting = True
1117-
reader = mock.Mock()
1152+
reader = mock.Mock(_lend_terminal=contextlib.nullcontext)
11181153
reader.wait.side_effect = OSError(errno.EIO, "terminal hung up")
11191154
base_app._cur_pipe_proc_reader = reader
11201155
base_app._redirecting = True
@@ -1128,6 +1163,42 @@ def test_restore_output_resets_pipe_state_when_the_wait_fails(base_app) -> None:
11281163
assert base_app._redirecting is False
11291164

11301165

1166+
def test_restore_output_lends_the_terminal_before_the_consumer_reads_eof(base_app) -> None:
1167+
"""A consumer may set its terminal modes once it reads EOF, as `vim -` does after reading its input.
1168+
1169+
From the background, that stops it with SIGTTOU, and on macOS the call then fails with
1170+
EINTR as it is continued. So the pipeline has the terminal before its pipe closes.
1171+
"""
1172+
import contextlib
1173+
1174+
statement = base_app.statement_parser.parse("help | vim -")
1175+
saved = cmd2.utils.RedirectionSavedState(base_app.stdout, None, False)
1176+
saved.redirecting = True
1177+
lent = []
1178+
events = []
1179+
1180+
@contextlib.contextmanager
1181+
def lend_terminal():
1182+
lent.append(True)
1183+
try:
1184+
yield
1185+
finally:
1186+
lent.pop()
1187+
1188+
reader = mock.Mock(_lend_terminal=lend_terminal)
1189+
reader.wait.side_effect = lambda: events.append(("wait", bool(lent)))
1190+
redirected = mock.Mock()
1191+
redirected.close.side_effect = lambda: events.append(("close", bool(lent)))
1192+
base_app._cur_pipe_proc_reader = reader
1193+
base_app._redirecting = True
1194+
base_app.stdout = redirected
1195+
1196+
base_app._restore_output(statement, saved)
1197+
1198+
assert events == [("close", True), ("wait", True)]
1199+
assert not lent
1200+
1201+
11311202
@pytest.mark.parametrize("failure", ["clipboard", "close"])
11321203
def test_restore_output_restores_stdout_when_ending_the_redirection_fails(base_app, mocker, failure) -> None:
11331204
"""A failed clipboard write, or a pipe that fails to close, must not leave stdout redirected.

0 commit comments

Comments
 (0)