Skip to content

Commit 53fbec9

Browse files
miss-islingtonharjothkharaclaude
authored
[3.15] gh-152907: Restore cooked output flags around the input hook in the new REPL (GH-153389) (#158112)
gh-152907: Restore cooked output flags around the input hook in the new REPL (GH-153389) * gh-152907: Restore cooked output flags around the input hook in the new REPL pyrepl clears OPOST for its own cursor rendering but calls PyOS_InputHook from inside the raw-mode read loop, so output written by an input hook (GUI toolkit event loops, and any warning/traceback/print they emit) is emitted with bare '\n' and no '\r'. Restore the terminal's saved output flags around the hook call and re-enter raw mode afterwards; only oflag is toggled so ECHO/ICANON stay off at the prompt. * Skip the input-hook test on platforms without pty devices The Emscripten buildbot has the pty module but no pty devices, so pty.openpty() raises OSError("out of pty devices"). Guard the test class the same way Lib/test/test_pty.py does. * Propagate the input hook's return value and drop the sleep from the test * Trim comments * Observe the hook's output synchronously instead of via the reader thread The reader thread no longer feeds any assertion: the hook drains the pty master itself, so the check is an exact comparison. A drainer is still needed for restore(), which writes before switching modes. --------- (cherry picked from commit 46ee358) Co-authored-by: Harjoth Khara <harjoth.khara@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d32160a commit 53fbec9

3 files changed

Lines changed: 110 additions & 1 deletion

File tree

‎Lib/_pyrepl/unix_console.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ def prepare(self) -> None:
491491
raw.cc[termios.VMIN] = b"\x01"
492492
raw.cc[termios.VTIME] = b"\x00"
493493
self.__input_fd_set(raw)
494+
self.__rawtermstate = raw
494495

495496
# Apple Terminal will re-wrap lines for us unless we preempt the
496497
# damage.
@@ -731,7 +732,19 @@ def input_hook(self):
731732
# avoid inline imports here so the repl doesn't get flooded
732733
# with import logging from -X importtime=2
733734
if posix is not None and posix._is_inputhook_installed():
734-
return posix._inputhook
735+
return self.__run_input_hook
736+
737+
def __run_input_hook(self):
738+
# gh-152907: input hooks expect cooked output, but pyrepl runs with
739+
# OPOST disabled. Restore the saved output flags around the hook
740+
# (only oflag; input must stay raw at the prompt).
741+
cooked = self.__rawtermstate.copy()
742+
cooked.oflag = self.__svtermstate.oflag
743+
self.__input_fd_set(cooked)
744+
try:
745+
return posix._inputhook()
746+
finally:
747+
self.__input_fd_set(self.__rawtermstate)
735748

736749
def __enable_bracketed_paste(self) -> None:
737750
os.write(self.output_fd, b"\x1b[?2004h")

‎Lib/test/test_pyrepl/test_unix_console.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import errno
22
import itertools
33
import os
4+
import select
45
import signal
56
import sys
67
import threading
78
import unittest
89
from functools import partial
910
from _colorize import ANSIColors
1011
from test.support import force_color, os_helper, force_not_colorized_test_class
12+
from test.support import is_android, is_apple_mobile, is_wasm32
1113
from test.support import threading_helper
1214

1315
from unittest import TestCase
@@ -424,3 +426,93 @@ def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr):
424426

425427
# EIO error should be handled gracefully in restore()
426428
console.restore()
429+
430+
431+
try:
432+
import pty
433+
import termios as _termios
434+
except ImportError:
435+
pty = None
436+
437+
438+
@unittest.skipIf(sys.platform == "win32", "No Unix console on Windows")
439+
@unittest.skipUnless(pty, "requires pty")
440+
@unittest.skipIf(is_android or is_apple_mobile or is_wasm32,
441+
"pty is not available on this platform")
442+
class TestUnixConsoleInputHook(TestCase):
443+
# gh-152907: the console must restore cooked output (OPOST) around
444+
# input-hook calls, then re-enter raw mode.
445+
446+
def test_input_hook_output_is_cooked(self):
447+
master_fd, slave_fd = pty.openpty()
448+
self.addCleanup(os.close, master_fd)
449+
450+
# tcsetattr(TCSADRAIN) blocks on some platforms (e.g. macOS) while the
451+
# master still holds unread output, so empty it before each mode switch.
452+
def drain():
453+
out = b""
454+
while select.select([master_fd], [], [], 0)[0]:
455+
try:
456+
data = os.read(master_fd, 4096)
457+
except OSError:
458+
break
459+
if not data:
460+
break
461+
out += data
462+
return out
463+
464+
# Start from a cooked terminal so there are saved flags to restore.
465+
attr = _termios.tcgetattr(slave_fd)
466+
attr[1] |= _termios.OPOST | _termios.ONLCR
467+
_termios.tcsetattr(slave_fd, _termios.TCSANOW, attr)
468+
469+
console = UnixConsole(slave_fd, slave_fd, term="xterm")
470+
console.prepare()
471+
try:
472+
drain() # discard prepare()'s own setup sequences
473+
# pyrepl's own rendering runs with OPOST cleared.
474+
self.assertFalse(_termios.tcgetattr(slave_fd)[1] & _termios.OPOST)
475+
476+
observed = {}
477+
478+
def fake_hook():
479+
observed["oflag"] = _termios.tcgetattr(slave_fd)[1]
480+
os.write(slave_fd, b"line1\nline2\n")
481+
observed["output"] = drain()
482+
return 0
483+
484+
with patch("_pyrepl.unix_console.posix") as mock_posix:
485+
mock_posix._is_inputhook_installed.return_value = True
486+
mock_posix._inputhook.side_effect = fake_hook
487+
hook = console.input_hook
488+
self.assertIsNotNone(hook)
489+
self.assertEqual(hook(), 0)
490+
491+
# The hook ran with cooked output (OPOST on)...
492+
self.assertTrue(observed["oflag"] & _termios.OPOST)
493+
# ...and raw mode was restored afterwards.
494+
self.assertFalse(_termios.tcgetattr(slave_fd)[1] & _termios.OPOST)
495+
# The tty translated the hook's bare '\n' into '\r\n'.
496+
self.assertEqual(observed["output"], b"line1\r\nline2\r\n")
497+
finally:
498+
# restore() writes and only then switches modes, so there is no
499+
# point left to drain from here; keep the master empty elsewhere.
500+
stop = threading.Event()
501+
502+
def pump():
503+
while not stop.is_set():
504+
if select.select([master_fd], [], [], 0.05)[0]:
505+
try:
506+
if not os.read(master_fd, 4096):
507+
break
508+
except OSError:
509+
break
510+
511+
pump_thread = threading.Thread(target=pump)
512+
pump_thread.start()
513+
try:
514+
console.restore()
515+
finally:
516+
stop.set()
517+
pump_thread.join()
518+
os.close(slave_fd)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Restore cooked-mode terminal output flags around :c:data:`PyOS_InputHook`
2+
callbacks in the new :term:`REPL` (:mod:`!_pyrepl`), so that output written
3+
by an input hook (for example a GUI toolkit event loop) is no longer emitted
4+
with ``OPOST`` disabled and keeps its carriage returns.

0 commit comments

Comments
 (0)