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
Lend the terminal to a pipeline while it starts up
A pager such as less puts the terminal in raw mode as it starts, before
cmd2 has written to the pipe and lent it the terminal. Its background
tcsetattr() was stopped with SIGTTOU, and on macOS that call fails with
EINTR once the process is continued instead of being restarted. less
ignores the failure, so it ran on a cooked terminal: q needed Enter and
arrow and paging keys were echoed as escape sequences.
  • Loading branch information
tleonhardt committed Sep 20, 2026
commit 72ae6e59489a800cae64a2b7825f0009b5eb7d5f
8 changes: 7 additions & 1 deletion cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3613,7 +3613,13 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
if cmd_pipe_proc_reader is None:
proc.wait(0.2)
else:
cmd_pipe_proc_reader.wait_for_exit(0.2)
# A pager such as less sets its terminal modes as it starts, before it
# reads the pipe. It must own the terminal by then: a background
# tcsetattr() stops it with SIGTTOU, and on macOS that call fails with
# EINTR when the process is continued instead of being restarted. less
# ignores the failure and runs on a cooked terminal.
with cmd_pipe_proc_reader.lend_terminal():
cmd_pipe_proc_reader.wait_for_exit(0.2)

# Check if the pipe process already exited
if proc.returncode is not None:
Expand Down
90 changes: 90 additions & 0 deletions tests/test_pipeline_job_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,3 +547,93 @@ def pager_shown():
os.close(master)
process.kill()
process.wait(timeout=5)


@pytest.mark.parametrize("mode", ["off", "reserved"])
def test_pipeline_pager_can_set_terminal_modes_at_startup(tmp_path, mode) -> None:
"""A pager such as less puts the terminal in raw mode as it starts, before reading its pipe.

It has to own the terminal by then. A background tcsetattr() stops it with SIGTTOU, and
on macOS the call then fails with EINTR once it is continued rather than being restarted.
less ignores that failure, leaving a cooked terminal: q needs Enter and keys are echoed.
"""
import pty
import termios

shell = shutil.which("bash")
if shell is None:
pytest.skip("requires an interactive bash shell")
pager = tmp_path / "pager.py"
outcome = tmp_path / "outcome"
pager.write_text(
"import os, pathlib, sys, termios, tty\n"
"with os.fdopen(os.dup(sys.stderr.fileno()), 'rb', buffering=0) as terminal:\n"
" saved = termios.tcgetattr(terminal)\n"
" try:\n"
# Like less, make a single attempt and carry on whatever comes of it.
" try:\n"
" tty.setcbreak(terminal)\n"
" result = 'ok'\n"
" except termios.error as error:\n"
" result = repr(error)\n"
f" pathlib.Path({str(outcome)!r}).write_text(result)\n"
" while os.read(terminal.fileno(), 1) != b'q': pass\n"
" finally:\n"
" termios.tcsetattr(terminal, termios.TCSANOW, saved)\n",
encoding="utf-8",
)
application = tmp_path / "application.py"
application.write_text(
"from cmd2 import Cmd, ToolbarMode\n"
f"app = Cmd(bottom_toolbar_mode=ToolbarMode.{mode.upper()})\n"
"app.prompt = 'TEST> '\n"
"app.main_session.bottom_toolbar = 'STATUS'\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)}")

try:
wait_until(lambda: "OUTER> " in transcript)
os.write(master, f"{shlex.quote(sys.executable)} {shlex.quote(str(application))}\n".encode())
wait_until(lambda: "TEST>" in transcript)
os.write(master, f"help -v | {shlex.quote(sys.executable)} {shlex.quote(str(pager))}\n".encode())
wait_until(outcome.exists)
wait_until(lambda: outcome.read_text() != "")
assert outcome.read_text() == "ok"
assert not termios.tcgetattr(master)[3] & termios.ICANON
# A cooked terminal would hold the key back until Enter.
start = len(transcript)
os.write(master, b"q")
wait_until(lambda: "TEST>" 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)
Loading