Skip to content

Commit 6b63ca9

Browse files
committed
Fix a writer race, the Android shell, code pages, and worker-thread shells
Fixes from another review of the pipeline changes: - A cmd2 write could hang when a subprocess wrote through the descriptor relay at the same time. The write fast path checked the consumer's pipe for room, then wrote without a terminal lend. The relay thread writes to the same pipe, and could fill it in between. The write then blocked with no lend, while the pager, waiting for a key, stayed stopped. Once a relay exists, every write is now made under a lend. Without one, writes the pipe has room for still need none. DescriptorRelay.idle() had no other caller, and is gone. - The start gate ran /bin/sh, which Android does not have, so every terminal pipe there failed to start. It now uses the POSIX shell that Popen(shell=True) itself uses: /system/bin/sh on Android. - On Windows before Python 3.14, a console code page Python has no cpNNNNN codec for fell back to UTF-8, although Python knows many of them by other names. Pipes now try those names too: the ISO-8859 family, KOI8-R and KOI8-U, US-ASCII, GB18030, EUC-JP and EUC-KR. Python 3.14 has a codec for every code page Windows supports. - A shell command run from a worker thread joined the main thread's terminal pipeline. Joining relays job-control stops to the main thread, and may change signal handlers, which only the main thread may do: as a session leader, it raised ValueError. Only the main thread's shell commands join now. Tests cover each, and each fails without its fix: every write is lent once a relay exists; the gate uses Android's shell; code pages map to their codecs on any platform; a worker thread's shell command stays out of the pipeline. The relay tests that used idle() now use flush().
1 parent f5fe7cb commit 6b63ca9

5 files changed

Lines changed: 177 additions & 27 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
the pipeline's job, so both processes receive Ctrl-C and Ctrl-Z
1313
- On Windows, fixed piped output appearing garbled in console programs such as `more`
1414
(`help -v | more`), a regression in 4.2.4. Pipes were written as UTF-8, but console programs
15-
decode their input with the console's code page. Pipes now use that code page, and characters
16-
it cannot represent are replaced rather than failing the command. Without a console, and on
17-
other platforms, pipes still use UTF-8
15+
decode their input with the console's code page. Pipes now use that code page, including ones
16+
Python names otherwise, such as 20866 (KOI8-R) or 28591 (ISO-8859-1), and characters it cannot
17+
represent are replaced rather than failing the command. Without a console, with a code page
18+
Python has no codec for, and on other platforms, pipes still use UTF-8
1819

1920
## 4.2.4 (September 8, 2026)
2021

‎cmd2/cmd2.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3375,9 +3375,11 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
33753375
# user's shell as before.
33763376
import shlex
33773377

3378-
user_shell = shlex.quote(kwargs.get("executable", "/bin/sh"))
3378+
# The POSIX shell Popen() itself runs with shell=True
3379+
posix_shell = "/system/bin/sh" if hasattr(sys, "getandroidapilevel") else "/bin/sh"
3380+
user_shell = shlex.quote(kwargs.get("executable", posix_shell))
33793381
popen_command = f"read -r _ || exit 1; exec {user_shell} -c {shlex.quote(statement.redirect_to)}"
3380-
kwargs["executable"] = "/bin/sh"
3382+
kwargs["executable"] = posix_shell
33813383

33823384
with contextlib.ExitStack() as terminal_stack, contextlib.ExitStack() as gate_stack:
33833385
if terminal_fd is not None:
@@ -5007,9 +5009,15 @@ def do_shell(self, args: argparse.Namespace) -> None:
50075009
# the terminal per write. Run the command inside the pipeline's job instead, for as
50085010
# long as it runs: the consumer keeps the terminal, and Ctrl-C and Ctrl-Z reach both
50095011
# processes, as they would in a shell pipeline.
5012+
# A worker thread's command stays out of it: job control relays stops to the main
5013+
# thread, and only the main thread may change signal handlers.
50105014
pipeline = self._cur_pipe_proc_reader
50115015
pipeline_group = None
5012-
if pipeline is not None and not isinstance(self.stdout, utils.StdSim): # type: ignore[unreachable]
5016+
if (
5017+
pipeline is not None
5018+
and threading.current_thread() is threading.main_thread()
5019+
and not isinstance(self.stdout, utils.StdSim) # type: ignore[unreachable]
5020+
):
50135021
pipeline_group = pipeline._terminal_group
50145022

50155023
# Prevent KeyboardInterrupts while in the shell process. The shell process still

‎cmd2/utils.py‎

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -536,22 +536,57 @@ def write(self, b: bytes) -> None:
536536
self.std_sim_instance.flush()
537537

538538

539+
# Windows code pages Python names other than cpNNNNN. Before Python 3.14, which covers every
540+
# code page Windows supports, a console set to one of these would otherwise get UTF-8.
541+
_CODE_PAGE_CODECS = {
542+
20127: "ascii",
543+
20866: "koi8_r",
544+
21866: "koi8_u",
545+
28591: "latin_1",
546+
28592: "iso8859_2",
547+
28593: "iso8859_3",
548+
28594: "iso8859_4",
549+
28595: "iso8859_5",
550+
28596: "iso8859_6",
551+
28597: "iso8859_7",
552+
28598: "iso8859_8",
553+
28599: "iso8859_9",
554+
28603: "iso8859_13",
555+
28605: "iso8859_15",
556+
51932: "euc_jp",
557+
51949: "euc_kr",
558+
54936: "gb18030",
559+
}
560+
561+
562+
def _code_page_encoding(code_page: int) -> str | None:
563+
"""Return the name of the Python codec for a Windows code page, or None if there is none.
564+
565+
:param code_page: a Windows code page identifier, such as 437
566+
"""
567+
import codecs
568+
569+
for name in (f"cp{code_page}", _CODE_PAGE_CODECS.get(code_page)):
570+
if name is not None:
571+
with contextlib.suppress(LookupError):
572+
# Normalized, so that code page 65001 is reported as utf-8
573+
return codecs.lookup(name).name
574+
return None
575+
576+
539577
def _pipe_encoding() -> str:
540578
"""Return the encoding for output cmd2 pipes to a shell command.
541579
542580
Windows console programs such as more, sort, and findstr decode piped input with the
543581
console's output code page, and would show UTF-8 as mojibake. Elsewhere, and on Windows
544-
without a console, use UTF-8.
582+
without a console or with a code page Python cannot encode, use UTF-8.
545583
"""
546584
if sys.platform == "win32":
547-
import codecs
548585
import ctypes
549586

550587
code_page = ctypes.windll.kernel32.GetConsoleOutputCP()
551588
if code_page:
552-
with contextlib.suppress(LookupError):
553-
# Normalized, so that code page 65001 is reported as utf-8
554-
return codecs.lookup(f"cp{code_page}").name
589+
return _code_page_encoding(code_page) or "utf-8"
555590
return "utf-8"
556591

557592

@@ -1108,11 +1143,6 @@ def _producer_blocked(self) -> bool:
11081143
poller.register(self.write_fd, select.POLLOUT)
11091144
return not poller.poll(0)
11101145

1111-
def idle(self) -> bool:
1112-
"""Whether all output written to the relay has reached the consumer's pipe."""
1113-
with self._lock:
1114-
return self._done or self._sent >= self._received + self._unread()
1115-
11161146
def flush(self) -> None:
11171147
"""Wait until output already written to the relay has reached the consumer's pipe.
11181148
@@ -1248,8 +1278,10 @@ def write(self, b: Any) -> int:
12481278
fd = super().fileno()
12491279
written = 0
12501280
try:
1251-
# Output a producer wrote to the relay comes first, which may need a lend.
1252-
if self._relay is None or self._relay.idle():
1281+
# Once a relay exists, its thread writes to the consumer's pipe too, and can fill it
1282+
# between a check for room and the write. So only without one is room checked
1283+
# without a lend. Output a producer wrote to the relay comes first in any case.
1284+
if self._relay is None:
12531285
# Once there is room, a write of at most PIPE_BUF bytes does not block.
12541286
while written < len(view) and self._poller.poll(0):
12551287
written += os.write(fd, view[written : written + select.PIPE_BUF])

‎tests/test_cmd2.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Cmd2 unit/functional testing"""
22

3+
import contextlib
34
import io
45
import os
56
import signal
7+
import subprocess
68
import sys
79
import tempfile
810
import threading
@@ -971,6 +973,62 @@ def start_pipe(*args, **kwargs):
971973
assert popen.call_args.kwargs["stdin"].closed
972974

973975

976+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX terminal job control")
977+
@pytest.mark.parametrize("android", [False, True])
978+
def test_terminal_pipe_start_gate_uses_the_platform_shell(redirection_app, mocker, monkeypatch, android) -> None:
979+
"""The start gate runs in the POSIX shell Popen() itself would use: Android has no /bin/sh."""
980+
if android:
981+
monkeypatch.setattr(sys, "getandroidapilevel", lambda: 30, raising=False)
982+
else:
983+
monkeypatch.delattr(sys, "getandroidapilevel", raising=False)
984+
monkeypatch.delenv("SHELL", raising=False)
985+
popen = mocker.patch("subprocess.Popen", autospec=True)
986+
popen.return_value.returncode = 127
987+
terminal_stream = mocker.Mock()
988+
terminal_stream.isatty.return_value = True
989+
terminal_stream.fileno.return_value = 10
990+
redirection_app.stdout = terminal_stream
991+
mocker.patch("os.tcgetpgrp", return_value=os.getpgrp())
992+
mocker.patch("cmd2.utils.ProcReader")
993+
redirection_app.onecmd_plus_hooks("print_output | less")
994+
shell = "/system/bin/sh" if android else "/bin/sh"
995+
assert popen.call_args.kwargs["executable"] == shell
996+
# With SHELL unset, the gate hands the command to that shell, too.
997+
assert f"exec {shell} -c less" in popen.call_args.args[0]
998+
999+
1000+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX terminal job control")
1001+
def test_shell_from_a_worker_thread_stays_out_of_the_pipeline(base_app, tmp_path) -> None:
1002+
"""Joining a pipeline's job means relaying stops to the main thread, and may change signal handlers.
1003+
1004+
Only the main thread may do that, so a shell command run from another thread does not join.
1005+
"""
1006+
lent = []
1007+
1008+
@contextlib.contextmanager
1009+
def _lend_terminal():
1010+
lent.append(True)
1011+
yield
1012+
1013+
spawned = []
1014+
real_popen = subprocess.Popen
1015+
1016+
def popen(*args, **kwargs):
1017+
spawned.append(kwargs)
1018+
return real_popen(*args, **kwargs)
1019+
1020+
base_app._cur_pipe_proc_reader = mock.Mock(_terminal_group=os.getpgrp(), _lend_terminal=_lend_terminal)
1021+
with (tmp_path / "output").open("w+") as output, mock.patch("subprocess.Popen", popen):
1022+
base_app.stdout = output
1023+
worker = threading.Thread(target=base_app.do_shell, args=("echo worker",))
1024+
worker.start()
1025+
worker.join(10)
1026+
output.seek(0)
1027+
assert output.read() == "worker\n"
1028+
assert "process_group" not in spawned[0]
1029+
assert not lent
1030+
1031+
9741032
def test_restore_output_resets_pipe_state_when_the_wait_fails(base_app) -> None:
9751033
"""A failed handback while waiting for the pipe process must not leave it current.
9761034

‎tests/test_utils.py‎

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,31 @@ def test_pipe_encoding_follows_the_console_code_page(monkeypatch, code_page, enc
212212
assert cu._pipe_encoding() == encoding
213213

214214

215+
@pytest.mark.parametrize(
216+
("code_page", "sample", "codec"),
217+
[
218+
(437, "─", "cp437"),
219+
# chcp 65001
220+
(65001, "─", "utf-8"),
221+
# Code pages Python names otherwise. Python 3.14 on Windows has a cpNNNNN codec for them too.
222+
(20866, "Ж", "koi8_r"),
223+
(28591, "é", "latin_1"),
224+
(20127, "a", "ascii"),
225+
],
226+
)
227+
def test_code_page_encoding(code_page, sample, codec) -> None:
228+
encoding = cu._code_page_encoding(code_page)
229+
assert encoding is not None
230+
assert sample.encode(encoding) == sample.encode(codec)
231+
232+
233+
@pytest.mark.parametrize("code_page", [50220, 1])
234+
def test_code_page_encoding_without_a_codec(code_page) -> None:
235+
if sys.platform == "win32" and sys.version_info >= (3, 14) and code_page == 50220:
236+
pytest.skip("Python 3.14 on Windows may support every code page Windows does")
237+
assert cu._code_page_encoding(code_page) is None
238+
239+
215240
@pytest.fixture
216241
def pr_none():
217242
import subprocess
@@ -498,12 +523,13 @@ def slow_read(fd, size):
498523
release.wait(5)
499524
return data
500525

501-
answers = []
526+
flushed = threading.Event()
502527
asking = threading.Event()
503528

504529
def ask() -> None:
505530
asking.set()
506-
answers.append(relay.idle())
531+
relay.flush()
532+
flushed.set()
507533

508534
# The relay thread must start inside the patch, or it is already in the real read.
509535
with mock.patch("os.read", side_effect=slow_read):
@@ -515,14 +541,12 @@ def ask() -> None:
515541
asker = threading.Thread(target=ask)
516542
asker.start()
517543
assert asking.wait(5)
518-
asker.join(0.3)
519-
# While the output is in the relay's hands, idle() waits for the relay or says the
520-
# output is still pending. Once released, the relay passes it on, so a later answer
521-
# may rightly be that nothing is left.
522-
answered_during_read = list(answers)
544+
# While the output is in the relay's hands, flush() must wait for it.
545+
assert not flushed.wait(0.3)
523546
release.set()
547+
# Once released, the relay passes the output on, and flush() returns.
548+
assert flushed.wait(5)
524549
asker.join(5)
525-
assert answered_during_read in ([], [False])
526550
finally:
527551
release.set()
528552
relay.close_write_fd()
@@ -544,11 +568,38 @@ def test_descriptor_relay_that_finished_has_nothing_pending() -> None:
544568
time.sleep(0.01)
545569
assert relay._done
546570
relay.flush()
547-
assert relay.idle()
548571
finally:
549572
os.close(read_fd)
550573

551574

575+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX pipes")
576+
@pytest.mark.parametrize("relaying", [False, True])
577+
def test_pipeline_writer_lends_every_write_once_a_relay_exists(relaying) -> None:
578+
"""The relay's thread writes to the consumer's pipe too. It could fill the pipe between a check for room and a write.
579+
580+
So once a relay exists, even a write the pipe has room for is made with the terminal lent,
581+
in case it blocks. Without one, such a write needs no lend.
582+
"""
583+
read_fd, write_fd = os.pipe()
584+
lends = []
585+
586+
@contextlib.contextmanager
587+
def lend_terminal():
588+
lends.append(1)
589+
yield
590+
591+
writer = cu._PipelineWriter(write_fd, mock.Mock(_lend_terminal=lend_terminal))
592+
try:
593+
if relaying:
594+
writer.fileno()
595+
assert writer.write(b"fits") == 4
596+
assert lends == ([1] if relaying else [])
597+
finally:
598+
writer.close()
599+
assert os.read(read_fd, 100) == b"fits"
600+
os.close(read_fd)
601+
602+
552603
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX pipes")
553604
def test_pipeline_writer_relay_passes_consumer_exit_to_the_producer() -> None:
554605
import subprocess

0 commit comments

Comments
 (0)