From e36cb0efe56addac759aa21de583e76867d15459 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sun, 26 Jul 2026 18:50:07 +0800 Subject: [PATCH 1/8] Add pending() and flush() to BaseEventQueue --- Lib/_pyrepl/base_eventqueue.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Lib/_pyrepl/base_eventqueue.py b/Lib/_pyrepl/base_eventqueue.py index f520ffd7d8ad11d..22ff8a9f4ffb01d 100644 --- a/Lib/_pyrepl/base_eventqueue.py +++ b/Lib/_pyrepl/base_eventqueue.py @@ -54,6 +54,39 @@ def empty(self) -> bool: """ return not self.events + def pending(self) -> bool: + """ + True when we have received the start of an escape/key sequence but + are still waiting for further bytes to disambiguate it. + + This is the case right after a byte (such as ESC) that our keymap + knows only as a prefix of a longer sequence has been pushed, but + before the rest of that sequence arrives. Consoles use this to + decide whether the next read should block indefinitely or only for + the escape timeout. + """ + return self.keymap is not self.compiled_keymap + + def flush(self) -> None: + """ + Finalize any pending, incomplete input as discrete events. + """ + if self.keymap is self.compiled_keymap: + # Nothing pending: either idle, or a complete key was already + # emitted by ``push``. + return + buf = self.buf.take_bytes() + self.keymap = self.compiled_keymap + if buf and buf[0] == 27: # escape + self.insert(Event('key', '\033', b'\033')) + for _c in buf[1:]: + self.push(_c) + else: + # Defensive: a pending prefix that does not start with ESC + # (e.g. a partial multi-byte sequence). Emit it as text. + data = bytes(buf).decode(self.encoding, 'replace') + self.insert(Event('key', data, buf)) + def insert(self, event: Event) -> None: """ Inserts an event into the queue. From 420863b89cb8b73e09fac832eb52a628811d6a3e Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sun, 26 Jul 2026 20:07:34 +0800 Subject: [PATCH 2/8] Add esc_timeout parameter to BaseEventQueue --- Lib/_pyrepl/base_eventqueue.py | 8 +++++++- Lib/_pyrepl/unix_eventqueue.py | 5 +++-- Lib/_pyrepl/windows_eventqueue.py | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Lib/_pyrepl/base_eventqueue.py b/Lib/_pyrepl/base_eventqueue.py index 22ff8a9f4ffb01d..8ad83cc7446c338 100644 --- a/Lib/_pyrepl/base_eventqueue.py +++ b/Lib/_pyrepl/base_eventqueue.py @@ -24,20 +24,26 @@ See unix_eventqueue and windows_eventqueue for subclasses. """ +import os from collections import deque from . import keymap from .console import Event from .trace import trace +ESC_TIMEOUT_DEFAULT = 0.1 + class BaseEventQueue: - def __init__(self, encoding: str, keymap_dict: dict[bytes, str]) -> None: + def __init__(self, encoding: str, keymap_dict: dict[bytes, str], + esc_timeout: float | None = None) -> None: self.compiled_keymap = keymap.compile_keymap(keymap_dict) self.keymap = self.compiled_keymap trace("keymap {k!r}", k=self.keymap) self.encoding = encoding self.events: deque[Event] = deque() self.buf = bytearray() + default = float(os.environ.get("PYREPL_ESC_TIMEOUT", ESC_TIMEOUT_DEFAULT)) + self.esc_timeout = esc_timeout if esc_timeout is not None else default def get(self) -> Event | None: """ diff --git a/Lib/_pyrepl/unix_eventqueue.py b/Lib/_pyrepl/unix_eventqueue.py index 2a9cca59e7477f7..cd0364746680c84 100644 --- a/Lib/_pyrepl/unix_eventqueue.py +++ b/Lib/_pyrepl/unix_eventqueue.py @@ -69,9 +69,10 @@ def get_terminal_keycodes(ti: TermInfo) -> dict[bytes, str]: class EventQueue(BaseEventQueue): - def __init__(self, fd: int, encoding: str, ti: TermInfo) -> None: + def __init__(self, fd: int, encoding: str, ti: TermInfo, + esc_timeout: float | None = None) -> None: keycodes = get_terminal_keycodes(ti) if os.isatty(fd): backspace = tcgetattr(fd)[6][VERASE] keycodes[backspace] = "backspace" - BaseEventQueue.__init__(self, encoding, keycodes) + BaseEventQueue.__init__(self, encoding, keycodes, esc_timeout) diff --git a/Lib/_pyrepl/windows_eventqueue.py b/Lib/_pyrepl/windows_eventqueue.py index d99722f9a16a93a..19496c2bb02280b 100644 --- a/Lib/_pyrepl/windows_eventqueue.py +++ b/Lib/_pyrepl/windows_eventqueue.py @@ -38,5 +38,5 @@ } class EventQueue(BaseEventQueue): - def __init__(self, encoding: str) -> None: - BaseEventQueue.__init__(self, encoding, VT_MAP) + def __init__(self, encoding: str, esc_timeout: float | None = None) -> None: + BaseEventQueue.__init__(self, encoding, VT_MAP, esc_timeout) From 47e8195e6c85eda03adcac9b91bb67b9ce7c4bb3 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sun, 26 Jul 2026 18:52:16 +0800 Subject: [PATCH 3/8] Wait only for `esc_timeout` seconds in `unix_console.get_event()` and `windows_console.get_event()` --- Lib/_pyrepl/unix_console.py | 6 ++++++ Lib/_pyrepl/windows_console.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index 8edd1c36a7232d7..23cfe27718d6f44 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -558,6 +558,12 @@ def get_event(self, block: bool = True) -> Event | None: return None while self.event_queue.empty(): + if self.event_queue.pending(): + if not self.wait(timeout=self.event_queue.esc_timeout): + # Timed out waiting for the rest of a sequence: flush the + # pending prefix as a complete key (e.g. a lone ESC). + self.event_queue.flush() + continue while True: try: self.push_char(self.__read(1)) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index 3768a22ad16f7bb..2747b736bcd9413 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -572,6 +572,12 @@ def get_event(self, block: bool = True) -> Event | None: return None while self.event_queue.empty(): + if self.event_queue.pending(): + # Timed out waiting for the rest of a sequence: flush the + # pending prefix as a complete key (e.g. a lone ESC). + if not self.wait_for_event(self.event_queue.esc_timeout * 1000): + self.event_queue.flush() + continue rec = self._read_input() if rec is None: return None From 9c70007952a405d69b7c7e6cbb22b018879bcc67 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sun, 26 Jul 2026 19:43:44 +0800 Subject: [PATCH 4/8] blurb --- .../next/Library/2026-07-26-19-43-35.gh-issue-154730.m5zUk_.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-07-26-19-43-35.gh-issue-154730.m5zUk_.rst diff --git a/Misc/NEWS.d/next/Library/2026-07-26-19-43-35.gh-issue-154730.m5zUk_.rst b/Misc/NEWS.d/next/Library/2026-07-26-19-43-35.gh-issue-154730.m5zUk_.rst new file mode 100644 index 000000000000000..75780ef8879dfdc --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-26-19-43-35.gh-issue-154730.m5zUk_.rst @@ -0,0 +1,2 @@ +Add timeout in :mod:`!_pyrepl` to distinguish between bare ``Esc`` and +multibyte escape sequence. From 277ada7c57e8ec14e77fd46d864322f1b0a0a9ba Mon Sep 17 00:00:00 2001 From: Tan Long Date: Tue, 28 Jul 2026 23:23:22 +0800 Subject: [PATCH 5/8] Ignore mypy error: "bytearray" has no attribute "take_bytes" --- Lib/_pyrepl/base_eventqueue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/base_eventqueue.py b/Lib/_pyrepl/base_eventqueue.py index 8ad83cc7446c338..a3353cb07e4e906 100644 --- a/Lib/_pyrepl/base_eventqueue.py +++ b/Lib/_pyrepl/base_eventqueue.py @@ -81,7 +81,7 @@ def flush(self) -> None: # Nothing pending: either idle, or a complete key was already # emitted by ``push``. return - buf = self.buf.take_bytes() + buf = self.buf.take_bytes() # type: ignore[attr-defined] self.keymap = self.compiled_keymap if buf and buf[0] == 27: # escape self.insert(Event('key', '\033', b'\033')) From b7466cffaf5aaa55d649f60e7904ba3e9f77ad82 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Tue, 28 Jul 2026 23:57:45 +0800 Subject: [PATCH 6/8] Test BaseEventQueue.pending() and .flush() --- Lib/test/test_pyrepl/test_eventqueue.py | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Lib/test/test_pyrepl/test_eventqueue.py b/Lib/test/test_pyrepl/test_eventqueue.py index 56ab43211847a49..dbabb83d2aa06b2 100644 --- a/Lib/test/test_pyrepl/test_eventqueue.py +++ b/Lib/test/test_pyrepl/test_eventqueue.py @@ -1,3 +1,4 @@ +import os import tempfile import unittest from unittest.mock import patch @@ -167,6 +168,37 @@ def _push(keys): self.assertEqual(eq.get(), _event("key", "b")) self.assertEqual(eq.get(), _event("key", "a")) + def test_pending(self): + eq = base_eventqueue.BaseEventQueue("utf-8", {}) + eq.compiled_keymap = eq.keymap = {b"\x1b": {b"[": "down"}} + self.assertFalse(eq.pending()) + eq.push(b"\x1b") + self.assertTrue(eq.pending()) + eq.push(b"[") + self.assertFalse(eq.pending()) + self.assertEqual(eq.get().data, "down") + + def test_flush(self): + eq = base_eventqueue.BaseEventQueue("utf-8", {}) + eq.compiled_keymap = eq.keymap = {b"\x1b": {b"[": "down"}} + eq.push(b"\x1b") + self.assertTrue(eq.pending()) + eq.flush() + self.assertFalse(eq.pending()) + event = eq.get() + self.assertIsNotNone(event) + self.assertEqual(event.evt, "key") + self.assertEqual(event.data, "\x1b") + self.assertEqual(event.raw, b"\x1b") + + def test_esc_timeout_override(self): + eq = base_eventqueue.BaseEventQueue("utf-8", {}, esc_timeout=0.3) + self.assertAlmostEqual(eq.esc_timeout, 0.3) + + with patch.dict(os.environ, {"PYREPL_ESC_TIMEOUT": "0.25"}): + eq = base_eventqueue.BaseEventQueue("utf-8", {}) + self.assertAlmostEqual(eq.esc_timeout, 0.25) + class EmptyTermInfo(terminfo.TermInfo): def get(self, cap: str) -> bytes: From 5fd90f24baf3e55bd9ed29ac1699ca72ab12d2bd Mon Sep 17 00:00:00 2001 From: Tan Long Date: Wed, 29 Jul 2026 00:41:06 +0800 Subject: [PATCH 7/8] Test flushing bare Esc when timeout expires --- Lib/test/test_pyrepl/test_unix_console.py | 14 ++++++++++++++ Lib/test/test_pyrepl/test_windows_console.py | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 2fc8398923cbf38..03be6a27fdec5b2 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -399,6 +399,20 @@ def test_restore_in_thread(self, _os_write): thread.start() thread.join() # this should not raise + def test_escape_timeout(self, _os_write): + console = UnixConsole(term="xterm") + console.prepare() + console.event_queue.push(b"\x1b") + self.assertTrue(console.event_queue.pending()) + # Simulate the read timing out before any follow-up bytes arrive. + console.wait = Mock(return_value=False) + event = console.get_event() + self.assertIsNotNone(event) + self.assertEqual(event.evt, "key") + self.assertEqual(event.data, "\x0b") + self.assertEqual(event.raw, b"\x1b") + console.restore() + @unittest.skipIf(sys.platform == "win32", "No Unix console on Windows") class TestUnixConsoleEIOHandling(TestCase): diff --git a/Lib/test/test_pyrepl/test_windows_console.py b/Lib/test/test_pyrepl/test_windows_console.py index 32c4255aa6b1904..ea8211f20ec67ab 100644 --- a/Lib/test/test_pyrepl/test_windows_console.py +++ b/Lib/test/test_pyrepl/test_windows_console.py @@ -648,6 +648,19 @@ def test_wait_not_empty(self): self.assertTrue(console.wait(0.0)) self.assertEqual(console.wait_for_event.call_count, 0) + def test_escape_timeout(self): + console = WindowsConsole() + # Simulate the read timing out before any follow-up bytes arrive. + console.wait_for_event = MagicMock(return_value=False) + console.event_queue.push(b"\x1b") + self.assertTrue(console.event_queue.pending()) + event = console.get_event() + console.restore() + self.assertIsNotNone(event) + self.assertEqual(event.evt, "key") + self.assertEqual(event.data, "\x1b") + self.assertEqual(event.raw, b"\x1b") + if __name__ == "__main__": unittest.main() From 0364127b4cc8fb66123c3e5d195bea62a49d6364 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Wed, 29 Jul 2026 01:01:15 +0800 Subject: [PATCH 8/8] Fix typo --- Lib/test/test_pyrepl/test_unix_console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 03be6a27fdec5b2..49416d86d919c30 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -409,7 +409,7 @@ def test_escape_timeout(self, _os_write): event = console.get_event() self.assertIsNotNone(event) self.assertEqual(event.evt, "key") - self.assertEqual(event.data, "\x0b") + self.assertEqual(event.data, "\x1b") self.assertEqual(event.raw, b"\x1b") console.restore()