|
1 | 1 | import errno |
2 | 2 | import itertools |
3 | 3 | import os |
| 4 | +import select |
4 | 5 | import signal |
5 | 6 | import sys |
6 | 7 | import threading |
7 | 8 | import unittest |
8 | 9 | from functools import partial |
9 | 10 | from test.support import os_helper |
| 11 | +from test.support import is_android, is_apple_mobile, is_emscripten, is_wasi |
10 | 12 | from test.support import threading_helper |
11 | 13 |
|
12 | 14 | from unittest import TestCase |
@@ -392,3 +394,92 @@ def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): |
392 | 394 |
|
393 | 395 | # EIO error should be handled gracefully in restore() |
394 | 396 | console.restore() |
| 397 | + |
| 398 | + |
| 399 | +try: |
| 400 | + import pty |
| 401 | + import termios as _termios |
| 402 | +except ImportError: |
| 403 | + pty = None |
| 404 | + |
| 405 | + |
| 406 | +@unittest.skipIf(sys.platform == "win32", "No Unix console on Windows") |
| 407 | +@unittest.skipUnless(pty, "requires pty") |
| 408 | +@unittest.skipIf(is_android or is_apple_mobile or is_emscripten or is_wasi, |
| 409 | + "pty is not available on this platform") |
| 410 | +class TestUnixConsoleInputHook(TestCase): |
| 411 | + # gh-152907: the console must restore cooked output (OPOST) around |
| 412 | + # input-hook calls, then re-enter raw mode. |
| 413 | + |
| 414 | + def test_input_hook_output_is_cooked(self): |
| 415 | + master_fd, slave_fd = pty.openpty() |
| 416 | + self.addCleanup(os.close, master_fd) |
| 417 | + |
| 418 | + # tcsetattr(TCSADRAIN) blocks on some platforms (e.g. macOS) while the |
| 419 | + # master still holds unread output, so empty it before each mode switch. |
| 420 | + def drain(): |
| 421 | + out = b"" |
| 422 | + while select.select([master_fd], [], [], 0)[0]: |
| 423 | + try: |
| 424 | + data = os.read(master_fd, 4096) |
| 425 | + except OSError: |
| 426 | + break |
| 427 | + if not data: |
| 428 | + break |
| 429 | + out += data |
| 430 | + return out |
| 431 | + |
| 432 | + # Start from a cooked terminal so there are saved flags to restore. |
| 433 | + attr = _termios.tcgetattr(slave_fd) |
| 434 | + attr[1] |= _termios.OPOST | _termios.ONLCR |
| 435 | + _termios.tcsetattr(slave_fd, _termios.TCSANOW, attr) |
| 436 | + |
| 437 | + console = UnixConsole(slave_fd, slave_fd, term="xterm") |
| 438 | + console.prepare() |
| 439 | + try: |
| 440 | + drain() # discard prepare()'s own setup sequences |
| 441 | + # pyrepl's own rendering runs with OPOST cleared. |
| 442 | + self.assertFalse(_termios.tcgetattr(slave_fd)[1] & _termios.OPOST) |
| 443 | + |
| 444 | + observed = {} |
| 445 | + |
| 446 | + def fake_hook(): |
| 447 | + observed["oflag"] = _termios.tcgetattr(slave_fd)[1] |
| 448 | + os.write(slave_fd, b"line1\nline2\n") |
| 449 | + observed["output"] = drain() |
| 450 | + return 0 |
| 451 | + |
| 452 | + with (patch("posix._is_inputhook_installed", return_value=True), |
| 453 | + patch("posix._inputhook", side_effect=fake_hook)): |
| 454 | + hook = console.input_hook |
| 455 | + self.assertIsNotNone(hook) |
| 456 | + self.assertEqual(hook(), 0) |
| 457 | + |
| 458 | + # The hook ran with cooked output (OPOST on)... |
| 459 | + self.assertTrue(observed["oflag"] & _termios.OPOST) |
| 460 | + # ...and raw mode was restored afterwards. |
| 461 | + self.assertFalse(_termios.tcgetattr(slave_fd)[1] & _termios.OPOST) |
| 462 | + # The tty translated the hook's bare '\n' into '\r\n'. |
| 463 | + self.assertEqual(observed["output"], b"line1\r\nline2\r\n") |
| 464 | + finally: |
| 465 | + # restore() writes and only then switches modes, so there is no |
| 466 | + # point left to drain from here; keep the master empty elsewhere. |
| 467 | + stop = threading.Event() |
| 468 | + |
| 469 | + def pump(): |
| 470 | + while not stop.is_set(): |
| 471 | + if select.select([master_fd], [], [], 0.05)[0]: |
| 472 | + try: |
| 473 | + if not os.read(master_fd, 4096): |
| 474 | + break |
| 475 | + except OSError: |
| 476 | + break |
| 477 | + |
| 478 | + pump_thread = threading.Thread(target=pump) |
| 479 | + pump_thread.start() |
| 480 | + try: |
| 481 | + console.restore() |
| 482 | + finally: |
| 483 | + stop.set() |
| 484 | + pump_thread.join() |
| 485 | + os.close(slave_fd) |
0 commit comments