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
Let a shell producer keep the terminal after its consumer exits
When a pipeline's consumer exited, its watcher returned the terminal to
cmd2 even while do_shell() was still lending it to a producer running in
the same group. The producer's next terminal read stopped it with
SIGTTIN, and nothing watches an ordinary shell command for stops, so
do_shell() waited forever. The watcher now leaves the terminal alone
while a lend is active; the lender returns it when it finishes.
  • Loading branch information
tleonhardt committed Sep 20, 2026
commit 7f574f5203bd7589b26a63490d7280c71b278a57
7 changes: 5 additions & 2 deletions cmd2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,8 +747,11 @@ def _wait_for_job(self, terminal_fd: int) -> None:
os.killpg(self._proc.pid, signal.SIGCONT)
finally:
try:
if os.tcgetpgrp(terminal_fd) == self._proc.pid:
self._set_foreground_group(terminal_fd, self._original_group)
with self._terminal_lock:
# A shell producer in this group may outlive the consumer and still read
# the terminal. While a lend is active, its holder returns the terminal.
if not self._terminal_available.is_set() and os.tcgetpgrp(terminal_fd) == self._proc.pid:
self._set_foreground_group(terminal_fd, self._original_group)
finally:
self._process_done.set()

Expand Down
82 changes: 82 additions & 0 deletions tests/test_pipeline_job_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,3 +637,85 @@ def wait_until(predicate):
os.close(master)
process.kill()
process.wait(timeout=5)


def test_shell_producer_keeps_the_terminal_after_its_consumer_exits(tmp_path) -> None:
"""A shell producer that outlives its consumer still reads the terminal.

do_shell() lends the terminal to the pipeline's group for as long as the producer runs.
The consumer's exit must not take it back early: the producer would stop with SIGTTIN on
its next terminal read, and nothing watches an ordinary shell command for stops.
"""
import pty

shell = shutil.which("bash")
if shell is None:
pytest.skip("requires an interactive bash shell")
consumer = tmp_path / "consumer.py"
consumer.write_text("import os, time\ntime.sleep(0.5)\nos.write(2, b'CONSUMER_DONE\\n')\n", encoding="utf-8")
producer = tmp_path / "producer.py"
producer.write_text(
"import os, signal, time\n"
# Interactive bash leaves TTIN ignored in what it execs, which turns a background read into EIO.
"signal.signal(signal.SIGTTIN, signal.SIG_DFL)\n"
"time.sleep(1.5)\n"
"os.write(2, b'PRODUCER> ')\n"
"os.write(2, b'GOT ' + os.read(0, 7))\n",
encoding="utf-8",
)
application = tmp_path / "application.py"
application.write_text(
"from cmd2 import Cmd, ToolbarMode\n"
"app = Cmd(bottom_toolbar_mode=ToolbarMode.OFF)\n"
"app.prompt = 'TEST> '\n"
"app.cmdloop()\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]:
data = decoder.decode(os.read(master, 65536))
transcript += data
if "\x1b[6n" in data:
# Answer prompt-toolkit's cursor-position request as a terminal would.
os.write(master, b"\x1b[1;1R")
if predicate():
return
pytest.fail(f"terminal condition timed out:\n{transcript}\n{describe_processes(process.pid, master)}")

python = shlex.quote(sys.executable)
try:
wait_until(lambda: "OUTER> " in transcript)
os.write(master, f"{python} {shlex.quote(str(application))}\n".encode())
wait_until(lambda: "TEST>" in transcript)
os.write(master, f"shell {python} {shlex.quote(str(producer))} | {python} {shlex.quote(str(consumer))}\n".encode())
wait_until(lambda: "CONSUMER_DONE\r\n" in transcript)
wait_until(lambda: "PRODUCER> " in transcript)
os.write(master, b"answer\n")
wait_until(lambda: "GOT answer" in transcript)
# cmd2 owns the terminal again once the producer is done.
start = len(transcript)
wait_until(lambda: "TEST>" in transcript[start:])
os.write(master, b"help quit\n")
wait_until(lambda: "Exit this application" in transcript[start:])
os.write(master, b"quit\n")
wait_until(lambda: os.tcgetpgrp(master) == process.pid)
finally:
os.close(master)
process.kill()
process.wait(timeout=5)
26 changes: 25 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,11 +392,35 @@ def handoff(timeout):
assert available.call_count == (2 if expired_handoff else 1)
killpg.assert_called_once_with(proc.pid, signal.SIGCONT)
stop.assert_not_called()
foreground.assert_called_once_with(10, reader._original_group)
# The lend is still active: its holder returns the terminal, not the watcher.
foreground.assert_not_called()
assert proc.returncode == 0
assert reader._process_done.is_set()


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX terminal job control")
@pytest.mark.parametrize("lent", [False, True])
def test_proc_reader_exit_returns_terminal_unless_lent(lent) -> None:
"""A shell producer in a lent pipeline group may outlive the consumer and still need the terminal."""
proc = mock.Mock(pid=123, stdout=None, stderr=None, returncode=None)
reader = cu.ProcReader(proc, sys.stdout, sys.stderr)
reader._terminal_fd = 10
reader._original_group = 456
if lent:
reader._terminal_available.set()
with (
mock.patch("os.waitpid", return_value=(proc.pid, 0)),
mock.patch("os.tcgetpgrp", return_value=proc.pid),
mock.patch.object(reader, "_set_foreground_group") as foreground,
):
reader._wait_for_job(10)
if lent:
foreground.assert_not_called()
else:
foreground.assert_called_once_with(10, reader._original_group)
assert reader._process_done.is_set()


def test_proc_reader_wait_for_exit_without_terminal() -> None:
proc = mock.Mock(stdout=None, stderr=None)
reader = cu.ProcReader(proc, sys.stdout, sys.stderr)
Expand Down
Loading