Skip to content

Commit 46ee358

Browse files
harjothkharaclaude
andauthored
gh-152907: Restore cooked output flags around the input hook in the new REPL (#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 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Trim comments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3439df9 commit 46ee358

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
@@ -486,6 +486,7 @@ def prepare(self) -> None:
486486
raw.cc[termios.VMIN] = b"\x01"
487487
raw.cc[termios.VTIME] = b"\x00"
488488
self.__input_fd_set(raw)
489+
self.__rawtermstate = raw
489490

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

731744
def __enable_bracketed_paste(self) -> None:
732745
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)