Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
1462c0f
Integrate reserved toolbar ownership across Stage 4 handoffs
tleonhardt Sep 12, 2026
c322cde
Keep the toolbar visible while the pager prepares its first frame
tleonhardt Sep 12, 2026
5b4acab
Guard POSIX suspend signal lookup on Windows
tleonhardt Sep 12, 2026
3354add
Add include_py to getting_started.py for ease of testing
tleonhardt Sep 12, 2026
1795cd9
Added raise_exception.py example pyscript for just testing a script t…
tleonhardt Sep 12, 2026
9649d4d
Keep terminal pipelines in the foreground job during suspend and resume
tleonhardt Sep 12, 2026
37645b5
Repaint reserved toolbar after minimum-height reacquisition
tleonhardt Sep 12, 2026
2b78110
Accept bash 5.1+ bracketed-paste output in the job-control test
tleonhardt Sep 12, 2026
c052401
Report from the job-control test's pager with os.write, not print
tleonhardt Sep 12, 2026
b1f80a6
Cover the reserved toolbar's remaining job-control and nested-prompt …
tleonhardt Sep 12, 2026
a785b96
Wait for the whole job to stop before typing at the shell in the job-…
tleonhardt Sep 12, 2026
c16e943
Fix forwarding of process-directed SIGINT to pipelines
tleonhardt Sep 12, 2026
ece4307
Avoid duplicate SIGINT delivery to terminal pipelines
tleonhardt Sep 12, 2026
4a79cb4
Fix POSIX pipeline signal races and speed up terminal tests
tleonhardt Sep 12, 2026
66365f5
Collect coverage from terminal test subprocesses
tleonhardt Sep 12, 2026
004f835
Cover terminal pipeline cleanup and resume paths
tleonhardt Sep 12, 2026
e327df6
Fix terminal input and wrapper job control in pipelines
tleonhardt Sep 14, 2026
b75e18e
Preserve ignored Ctrl-Z for session-led pipelines
tleonhardt Sep 14, 2026
a7a733f
Run shell producers inside terminal pipelines and isolate worker-thre…
tleonhardt Sep 14, 2026
d4566a0
Cover the shell command's fallback paths in terminal pipelines
tleonhardt Sep 14, 2026
0102c44
Keep the terminal lent across an interrupted pipeline write
tleonhardt Sep 14, 2026
7643b9a
Relay pipeline stops to the main thread with a thread-directed signal
tleonhardt Sep 14, 2026
d3b2630
Let the main thread relay a pipeline stop from a blocking write or wait
tleonhardt Sep 14, 2026
589b468
Keep the pipeline descriptor blocking for shell producers
tleonhardt Sep 14, 2026
f34262c
Added stage4_manual.py example for ease of testing
tleonhardt Sep 14, 2026
9f50afb
Stabilize orphaned-session pipeline terminal test
tleonhardt Sep 14, 2026
66513f6
Hold the display thread until a Ctrl-Z stop has taken it
tleonhardt Sep 15, 2026
b163a1e
Assert pipeline isolation without a platform branch in the toolbar test
tleonhardt Sep 15, 2026
416c6da
Cover both outcomes of the wait after sending SIGTSTP
tleonhardt Sep 15, 2026
72ae6e5
Lend the terminal to a pipeline while it starts up
tleonhardt Sep 20, 2026
7f574f5
Let a shell producer keep the terminal after its consumer exits
tleonhardt Sep 20, 2026
dc1a9df
Relay a shell producer's stop once its consumer is gone
tleonhardt Sep 20, 2026
11c3688
Send pager test keys from the application's event loop thread
tleonhardt Sep 20, 2026
4ce7c82
Merge branch 'main' into stage4-handoffs-integration
tleonhardt Sep 22, 2026
b20de83
Merge branch 'reserved_row_toolbar' into stage4-handoffs-integration
tleonhardt Sep 22, 2026
add0368
Start the startup-mode test's pager without site-packages
tleonhardt Sep 24, 2026
ea47295
Fall back from auto mode when prompt-toolkit's version is unknown
tleonhardt Sep 24, 2026
06608b0
Keep the command loop running when the toolbar display will not stop
tleonhardt Sep 24, 2026
a614316
Take the terminal back before retrying a shell command in cmd2's group
tleonhardt Sep 24, 2026
9be7a4d
Block SIGTTOU only while lending the terminal to a pipeline
tleonhardt Sep 24, 2026
2bee1d3
Hold cmd2's startup check open in the pager startup-mode test
tleonhardt Sep 24, 2026
de23939
Leave the application's Ctrl-Z handler as found when the reservation …
tleonhardt Sep 24, 2026
041785f
Take the terminal back only when the last overlapping lend ends
tleonhardt Sep 24, 2026
80ce6a2
Let Ctrl-C end the wait for a toolbar display that will not stop
tleonhardt Sep 24, 2026
d4b1fd3
Reset pipe state even when restoring redirected output fails
tleonhardt Sep 24, 2026
7f6b8dc
Catch Ctrl-C that lands while the stuck-toolbar notice is printing
tleonhardt Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Run shell producers inside terminal pipelines and isolate worker-thre…
…ad pipes

A shell command piped to an interactive consumer, such as `shell git log | less`,
hung: the child inherited the raw pipe descriptor, bypassing the writer that
lends the terminal per write, so the consumer stopped on its first terminal
access while the producer blocked on the full pipe. do_shell now spawns its
child in the pipeline's process group and lends the terminal for the child's
lifetime, so the consumer keeps the terminal and Ctrl-C and Ctrl-Z reach both
processes as in a shell pipeline. If the pipeline exits before the child can
join its group, the child runs in our own group as before.

Because such a producer can outlive the consumer that led the group,
ProcReader.send_sigint() falls back to the leader's pid as the group id once
the leader has been reaped, rather than doing nothing.

A pipe started off the main thread could not install job-control signal
handlers and failed after Popen, leaving the child unreaped. The terminal
pipeline path is now taken only on the main thread; elsewhere the pipeline
keeps running in its own session.
  • Loading branch information
tleonhardt committed Sep 14, 2026
commit a7a733f6314e9285b91bbe6d8b69c36a7e71b66f
57 changes: 40 additions & 17 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3554,12 +3554,15 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:

terminal_fd = None
if sys.platform != "win32":
for stream in (pipe_stdout, pipe_stderr):
if stream is not None and stream.isatty():
with contextlib.suppress(OSError, ValueError):
if os.tcgetpgrp(stream.fileno()) == os.getpgrp():
terminal_fd = stream.fileno()
break
# Job control installs signal handlers, which only the main thread may do.
# Elsewhere, keep the pipeline in its own session as before.
if threading.current_thread() is threading.main_thread():
for stream in (pipe_stdout, pipe_stderr):
if stream is not None and stream.isatty():
with contextlib.suppress(OSError, ValueError):
if os.tcgetpgrp(stream.fileno()) == os.getpgrp():
terminal_fd = stream.fileno()
break
if terminal_fd is None:
kwargs["start_new_session"] = True
else:
Expand Down Expand Up @@ -5248,17 +5251,37 @@ def do_shell(self, args: argparse.Namespace) -> None:
utils.expand_user_in_tokens(tokens)
expanded_command = " ".join(tokens)

# Prevent KeyboardInterrupts while in the shell process. The shell process will
# still receive the SIGINT since it is in the same process group as us.
with self.sigint_protection:
# For any stream that is a StdSim, we will use a pipe so we can capture its output
proc = subprocess.Popen( # noqa: S602
expanded_command,
stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable]
stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
shell=True,
**kwargs,
)
# A terminal pipeline's consumer needs the terminal to drain the pipe, but a shell
# command writes into that pipe itself rather than through self.stdout, which lends
# the terminal per write. Run the command inside the pipeline's job instead, for as
# long as it runs: the consumer keeps the terminal, and Ctrl-C and Ctrl-Z reach both
# processes, as they would in a shell pipeline.
pipeline = self._cur_pipe_proc_reader
pipeline_group = None
if pipeline is not None and not isinstance(self.stdout, utils.StdSim): # type: ignore[unreachable]
pipeline_group = pipeline.terminal_group

# Prevent KeyboardInterrupts while in the shell process. The shell process still
# receives the SIGINT: it is in our process group or in the foreground pipeline's.
with self.sigint_protection, contextlib.ExitStack() as terminal_stack:
if pipeline is not None and pipeline_group is not None:
kwargs["process_group"] = pipeline_group
terminal_stack.enter_context(pipeline.lend_terminal())
while True:
try:
# For any stream that is a StdSim, we will use a pipe so we can capture its output
proc = subprocess.Popen( # noqa: S602
expanded_command,
stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable]
stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
shell=True,
**kwargs,
)
break
except PermissionError:
# The pipeline exited before the command could join its group.
if kwargs.pop("process_group", None) is None:
raise

proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
proc_reader.wait()
Expand Down
20 changes: 15 additions & 5 deletions cmd2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,12 +587,15 @@ def send_sigint(self) -> None:
# the whole process group to make sure it propagates further than the shell
try:
group_id = os.getpgid(self._proc.pid)
# Pipelines have their own group. Never re-signal our own group:
# other ProcReader callers may share it and already received Ctrl-C.
if group_id != os.getpgrp():
os.killpg(group_id, signal.SIGINT)
except ProcessLookupError:
return
# Pipelines lead their own group. A shell command that joined it, such
# as `shell sleep 100 | head -1`, can outlive the reaped consumer.
group_id = self._proc.pid
# Never re-signal our own group: other ProcReader callers may share it
# and already received Ctrl-C.
if group_id != os.getpgrp():
with contextlib.suppress(ProcessLookupError):
os.killpg(group_id, signal.SIGINT)

def terminate(self) -> None:
"""Terminate the process."""
Expand All @@ -605,6 +608,13 @@ def terminate(self) -> None:
with contextlib.suppress(ProcessLookupError):
os.kill(self._proc.pid, signal.SIGTERM)

@property
def terminal_group(self) -> int | None:
"""Process group of a running terminal pipeline, which a producer may join, or None."""
if self._terminal_fd is None or self._proc.returncode is not None:
return None
return self._proc.pid

@staticmethod
def _set_foreground_group(terminal_fd: int, group_id: int) -> None:
"""Transfer the terminal without stopping this background thread with SIGTTOU."""
Expand Down
113 changes: 102 additions & 11 deletions tests/test_pipeline_job_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,22 @@


@pytest.mark.parametrize(
("finish", "stop_job", "shell_child", "launcher"),
("finish", "stop_job", "shell_child", "launcher", "producer"),
[
pytest.param("interrupts", True, False, "direct", id="direct-signals-and-job-control"),
pytest.param("interrupts", True, True, "sh", id="wrapper-signals-and-job-control"),
pytest.param("exit_sigint", False, False, "direct", id="interrupt-busy-producer"),
pytest.param("exit_sigint", True, True, "uv", id="uv-stop-and-interrupt-busy-producer"),
pytest.param("read_input", False, False, "direct", id="nested-prompt"),
pytest.param("shell_input", False, True, "direct", id="shell-input"),
pytest.param("direct_input", False, False, "sh", id="direct-input-with-toolbar-off"),
pytest.param("direct_input", False, False, "exec", id="direct-input-in-orphaned-session"),
pytest.param("interrupts", False, False, "exec", id="orphaned-job-control"),
pytest.param("interrupts", True, False, "direct", "command", id="direct-signals-and-job-control"),
pytest.param("interrupts", True, True, "sh", "command", id="wrapper-signals-and-job-control"),
pytest.param("exit_sigint", False, False, "direct", "command", id="interrupt-busy-producer"),
pytest.param("exit_sigint", True, True, "uv", "command", id="uv-stop-and-interrupt-busy-producer"),
pytest.param("exit_sigint", False, False, "direct", "shell", id="interrupt-busy-shell-producer"),
pytest.param("exit_sigint", True, True, "sh", "shell", id="wrapper-stop-and-interrupt-busy-shell-producer"),
pytest.param("read_input", False, False, "direct", "command", id="nested-prompt"),
pytest.param("shell_input", False, True, "direct", "command", id="shell-input"),
pytest.param("direct_input", False, False, "sh", "command", id="direct-input-with-toolbar-off"),
pytest.param("direct_input", False, False, "exec", "command", id="direct-input-in-orphaned-session"),
pytest.param("interrupts", False, False, "exec", "command", id="orphaned-job-control"),
],
)
def test_pipeline_stops_with_cmd2_and_returns_terminal(tmp_path, finish, stop_job, shell_child, launcher) -> None:
def test_pipeline_stops_with_cmd2_and_returns_terminal(tmp_path, finish, stop_job, shell_child, launcher, producer) -> None:
import fcntl
import pty
import struct
Expand Down Expand Up @@ -88,8 +90,10 @@ def test_pipeline_stops_with_cmd2_and_returns_terminal(tmp_path, finish, stop_jo
application = tmp_path / "application.py"
application_pid = tmp_path / "application.pid"
interrupt_request = tmp_path / "interrupt.request"
last_result = tmp_path / "last_result"
application.write_text(
"from cmd2 import Cmd, ToolbarMode\n"
"from cmd2.plugin import CommandFinalizationData\n"
"import getpass, os, pathlib, signal, threading, time\n"
"signal.signal(signal.SIGTSTP, signal.SIG_DFL)\n"
f"pathlib.Path({str(application_pid)!r}).write_text(str(os.getpid()))\n"
Expand All @@ -110,7 +114,11 @@ def test_pipeline_stops_with_cmd2_and_returns_terminal(tmp_path, finish, stop_jo
" os.write(2, b'RAW> ')\n"
" assert os.read(0, 7) == b'direct\\n'\n"
" self.poutput('INPUT_COMPLETE')\n"
" def record_result(self, data: CommandFinalizationData) -> CommandFinalizationData:\n"
f" pathlib.Path({str(last_result)!r}).write_text(repr(self.last_result))\n"
" return data\n"
f"app = App(bottom_toolbar_mode=ToolbarMode.{'OFF' if finish == 'direct_input' else 'RESERVED'})\n"
"app.register_cmdfinalization_hook(app.record_result)\n"
"app.prompt = 'TEST> '\n"
"app.debug = True\n"
"app.main_session.bottom_toolbar = 'STATUS'\n"
Expand Down Expand Up @@ -206,6 +214,21 @@ def stopped(*pids: int) -> bool:
assert job_group > 1
wait_until(lambda: os.tcgetpgrp(master) == job_group)
command = "busy" if finish == "exit_sigint" else "help -v"
if producer == "shell":
# A shell command writes into the pipe itself rather than through cmd2's
# stdout, so cmd2 cannot lend the terminal write by write. Like seq or git
# log, this producer dies from SIGINT rather than handling it.
busy_script = tmp_path / "busy.py"
busy_script.write_text(
"import os, signal, sys, time\n"
"signal.signal(signal.SIGINT, signal.SIG_DFL)\n"
"os.write(2, b'BUSY_READY\\n')\n"
"sys.stdout.write('x' * 262144)\n"
"sys.stdout.flush()\n"
"time.sleep(30)\n",
encoding="utf-8",
)
command = f"shell {shlex.quote(sys.executable)} {shlex.quote(str(busy_script))}"
if finish == "read_input":
command = "ask"
elif finish == "direct_input":
Expand Down Expand Up @@ -287,6 +310,11 @@ def stopped(*pids: int) -> bool:
if finish != "exit_sigint":
send("q")
wait_until(lambda: os.tcgetpgrp(master) == job_group and "TEST>" in "\n".join(screen.display))
if producer == "shell":
# Ctrl-C reached the producer directly, as in a shell pipeline. It did not
# merely die of a broken pipe once the pager was gone.
wait_until(last_result.exists)
assert last_result.read_text() == repr(-signal.SIGINT)
start = len(transcript)
send("help quit\n")
wait_until(lambda: "Exit this application" in transcript[start:])
Expand All @@ -309,3 +337,66 @@ def stopped(*pids: int) -> bool:
os.close(master)
process.kill()
process.wait(timeout=5)


def test_pipeline_from_worker_thread_stays_isolated(tmp_path) -> None:
"""A pipe started off the main thread cannot install job-control handlers.

It must fall back to running the pipeline in its own session, as before, rather
than failing after Popen and leaving the child unreaped.
"""
import pty

shell = shutil.which("bash")
if shell is None:
pytest.skip("requires an interactive bash shell")
application = tmp_path / "application.py"
application.write_text(
"from cmd2 import Cmd, ToolbarMode\n"
"import os, threading\n"
"app = Cmd(bottom_toolbar_mode=ToolbarMode.OFF)\n"
"outcome = []\n"
"worker = threading.Thread(target=lambda: outcome.append(app.onecmd_plus_hooks('help quit | cat')))\n"
"worker.start()\n"
"worker.join()\n"
"try:\n"
" reaped = os.waitpid(-1, os.WNOHANG)\n"
"except ChildProcessError:\n"
" reaped = None\n"
"os.write(1, f'WORKER_DONE {outcome} {reaped}\\n'.encode())\n",
encoding="utf-8",
)
master, slave = pty.openpty()
bootstrap = (
"import os, fcntl, termios; os.setsid(); "
"fcntl.ioctl(0, termios.TIOCSCTTY, 0); "
"os.execv(os.environ['TEST_SHELL'], ['bash', '--noprofile', '--norc', '-i'])"
)
env = dict(os.environ, TERM="xterm-256color", PS1="OUTER> ", TEST_SHELL=shell, SHELL=shell)
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1])
process = subprocess.Popen([sys.executable, "-c", bootstrap], stdin=slave, stdout=slave, stderr=slave, env=env)
os.close(slave)
decoder = codecs.getincrementaldecoder("utf-8")("replace")
transcript = ""

def wait_until(predicate):
nonlocal transcript
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if select.select([master], [], [], 0.05)[0]:
transcript += decoder.decode(os.read(master, 65536))
if predicate():
return
pytest.fail(f"terminal condition timed out:\n{transcript}")

try:
wait_until(lambda: "OUTER> " in transcript)
os.write(master, f"{shlex.quote(sys.executable)} {shlex.quote(str(application))}\n".encode())
# The whole line: a partial read must not satisfy the wait before the reap result arrives.
wait_until(lambda: re.search(r"WORKER_DONE .*\r\n", transcript) is not None)
assert "Exit this application" in transcript
assert "WORKER_DONE [False] None" in transcript
finally:
os.close(master)
process.kill()
process.wait(timeout=5)
39 changes: 34 additions & 5 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def test_proc_reader_send_sigint(pr_none) -> None:
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups")
def test_proc_reader_does_not_resignal_its_own_group(pr_none) -> None:
try:
with mock.patch("os.getpgid", return_value=os.getpgrp()), mock.patch("os.killpg") as killpg:
with mock.patch("os.getpgrp", return_value=pr_none._proc.pid), mock.patch("os.killpg") as killpg:
pr_none.send_sigint()
killpg.assert_not_called()
finally:
Expand All @@ -232,11 +232,40 @@ def test_proc_reader_does_not_resignal_its_own_group(pr_none) -> None:


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups")
def test_proc_reader_sigint_after_consumer_exit() -> None:
reader = cu.ProcReader(mock.Mock(stdout=None, stderr=None), sys.stdout, sys.stderr)
with mock.patch("os.getpgid", side_effect=ProcessLookupError), mock.patch("os.killpg") as killpg:
def test_proc_reader_sigint_after_pipeline_exit() -> None:
reader = cu.ProcReader(mock.Mock(pid=os.getpid() + 1, stdout=None, stderr=None), sys.stdout, sys.stderr)
with (
mock.patch("os.getpgid", side_effect=ProcessLookupError),
mock.patch("os.killpg", side_effect=ProcessLookupError) as killpg,
):
reader.send_sigint()
killpg.assert_not_called()
killpg.assert_called_once_with(reader._proc.pid, signal.SIGINT)


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups")
def test_proc_reader_sigint_reaches_group_after_leader_exit() -> None:
"""A shell producer joins the pipeline's group and can outlive the consumer that led it."""
import subprocess

# A terminal pipeline leads its own group within our session, so a producer may join it.
leader = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], process_group=0)
reader = cu.ProcReader(leader, sys.stdout, sys.stderr)
member_code = (
"import signal, time; signal.signal(signal.SIGINT, signal.SIG_DFL); print('ready', flush=True); time.sleep(30)"
)
member = subprocess.Popen([sys.executable, "-c", member_code], stdout=subprocess.PIPE, process_group=leader.pid)
try:
assert member.stdout is not None
assert member.stdout.readline().strip() == b"ready"
reader.terminate()
reader.wait()
assert leader.returncode == -signal.SIGTERM

reader.send_sigint()
assert member.wait(timeout=5) == -signal.SIGINT
finally:
member.kill()
member.wait()


def test_proc_reader_terminate(pr_none) -> None:
Expand Down