From 98a07dee737dcd1c658569ff6c70378bae5a3333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 22 Jul 2026 12:57:53 +0200 Subject: [PATCH] gh-154467: Fix empty (Pdb) prompt when attaching to a process with pdb -p _PdbServer inherits _cmdloop, which wraps cmdloop() in _maybe_use_pyrepl_as_stdin(). That context manager blanks self.prompt to '' so that a local pyrepl draws the prompt itself. The remote server, however, never reads from a local pyrepl -- it transmits self.prompt to the client over the socket -- so the blanking made it send an empty prompt whenever the target process had a pyrepl-capable terminal (pyrepl_input is set). Override _maybe_use_pyrepl_as_stdin() in _PdbServer to a no-op, keeping the real prompt. Add an integration test that attaches to a target running under a pty, so PyREPL is genuinely enabled inside it, and asserts the transmitted prompt is "(Pdb) ". Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/pdb.py | 9 +++ Lib/test/test_remote_pdb.py | 71 +++++++++++++++++++ ...-07-22-12-59-26.gh-issue-154467.AA9w1D.rst | 3 + 3 files changed, 83 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-07-22-12-59-26.gh-issue-154467.AA9w1D.rst diff --git a/Lib/pdb.py b/Lib/pdb.py index 01451f0229cacb2..458eb8352652366 100644 --- a/Lib/pdb.py +++ b/Lib/pdb.py @@ -3158,6 +3158,15 @@ def postloop(self): if self.quitting: self.detach() + @contextmanager + def _maybe_use_pyrepl_as_stdin(self): + # The server reads every command from the client over the socket, never + # from a local pyrepl. The base implementation swaps in pyrepl as stdin + # and blanks `self.prompt` to '' (pyrepl would draw the prompt itself), + # which here would transmit an empty prompt to the client whenever the + # target process happens to be pyrepl-capable. Keep the real prompt. + yield + def detach(self): # Detach the debugger and close the socket without raising BdbQuit self.quitting = False diff --git a/Lib/test/test_remote_pdb.py b/Lib/test/test_remote_pdb.py index d26d63faa61ddb6..5b23a194098b01c 100644 --- a/Lib/test/test_remote_pdb.py +++ b/Lib/test/test_remote_pdb.py @@ -8,6 +8,7 @@ import subprocess import sys import textwrap +import threading import unittest import unittest.mock from contextlib import closing, contextmanager, redirect_stdout, redirect_stderr, ExitStack @@ -18,6 +19,11 @@ import pdb from pdb import _PdbServer, _PdbClient +try: + import pty +except ImportError: + pty = None + if not sys.is_remote_debug_enabled(): raise unittest.SkipTest('remote debugging is disabled') @@ -1090,6 +1096,45 @@ def _connect_and_get_client_file(self): return process, client_file + def _connect_and_get_client_file_via_pty(self): + """Like _connect_and_get_client_file, but run the target under a pty. + + With a real terminal on stdin/stdout, PyREPL is available *inside the + target process*, which is the condition that exercises pdb's PyREPL + input handling in the remote server. + """ + controller, worker = pty.openpty() + self.addCleanup(os.close, controller) + env = dict(os.environ, TERM="xterm-256color") + env.pop("PYTHON_BASIC_REPL", None) # don't opt out of PyREPL + process = subprocess.Popen( + [sys.executable, self.script_path], + stdin=worker, stdout=worker, stderr=worker, env=env, close_fds=True, + ) + os.close(worker) # only the child keeps the worker end open + + # Continuously drain the terminal so the child never blocks on a write. + drainer = threading.Thread( + target=self._drain_until_eof, args=(controller,), daemon=True + ) + drainer.start() + self.addCleanup(drainer.join, SHORT_TIMEOUT) + + client_sock, _ = self.server_sock.accept() + client_file = client_sock.makefile('rwb') + self.addCleanup(client_file.close) + self.addCleanup(client_sock.close) + + return process, client_file + + @staticmethod + def _drain_until_eof(fd): + try: + while os.read(fd, 1024): + pass + except OSError: + pass # controller closed, or the pty went away with the child + def _read_until_prompt(self, client_file): """Helper to read messages until a prompt is received.""" messages = [] @@ -1292,6 +1337,32 @@ def test_handle_eof(self): self.assertEqual(process.returncode, 0) self.assertEqual(stderr, "") + @unittest.skipUnless(pty, "requires pty") + def test_prompt_with_interactive_terminal(self): + """The server must send "(Pdb) " even when the target owns a terminal. + + The remote server transmits its prompt string to the client, which + displays it. When the target process has an interactive terminal, + PyREPL is enabled inside it; the base pdb machinery then blanks + ``self.prompt`` (a local PyREPL would draw the prompt itself). The + remote server has no local PyREPL -- it reads input from the socket -- + so it must keep the real prompt rather than transmit an empty one. + Regression test for gh-154467, where attaching with ``pdb -p`` to a + process running at an interactive prompt showed a blank prompt. + """ + self._create_script() + process, client_file = self._connect_and_get_client_file_via_pty() + + with kill_on_error(process): + messages = self._read_until_prompt(client_file) + # The message that ended the read is the prompt request. + self.assertEqual(messages[-1], {"prompt": "(Pdb) ", "state": "pdb"}) + + # Let the target run to completion so nothing is left attached. + self._send_command(client_file, "c") + process.wait(timeout=SHORT_TIMEOUT) + self.assertEqual(process.returncode, 0) + def test_protocol_version(self): """Test that incompatible protocol versions are properly detected.""" # Create a script using an incompatible protocol version diff --git a/Misc/NEWS.d/next/Library/2026-07-22-12-59-26.gh-issue-154467.AA9w1D.rst b/Misc/NEWS.d/next/Library/2026-07-22-12-59-26.gh-issue-154467.AA9w1D.rst new file mode 100644 index 000000000000000..9d7ac18a5e9db12 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-22-12-59-26.gh-issue-154467.AA9w1D.rst @@ -0,0 +1,3 @@ +Fixed :mod:`pdb` remote attaching (``python -m pdb -p PID``) sending an +empty prompt to the client instead of ``(Pdb)`` when the target process has +an interactive terminal.