]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-154467: Fix empty (Pdb) prompt when attaching to a process with pdb -p (#154469)
authorŁukasz Langa <lukasz@langa.pl>
Mon, 27 Jul 2026 20:45:40 +0000 (13:45 -0700)
committerGitHub <noreply@github.com>
Mon, 27 Jul 2026 20:45:40 +0000 (13:45 -0700)
_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) <noreply@anthropic.com>
Lib/pdb.py
Lib/test/test_remote_pdb.py
Misc/NEWS.d/next/Library/2026-07-22-12-59-26.gh-issue-154467.AA9w1D.rst [new file with mode: 0644]

index 01451f0229cacb2d941018ee926bbaece7170e2d..458eb83526523664512d5ce9d9832068ec3f465c 100644 (file)
@@ -3158,6 +3158,15 @@ class _PdbServer(Pdb):
         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
index d26d63faa61ddb6cb3bb214c9633db520b2572bd..5b23a194098b01cd0c840ca6d68a6dfc47f677f4 100644 (file)
@@ -8,6 +8,7 @@ import socket
 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 @@ from typing import List
 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 @@ class PdbConnectTestCase(unittest.TestCase):
 
         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 @@ class PdbConnectTestCase(unittest.TestCase):
             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 (file)
index 0000000..9d7ac18
--- /dev/null
@@ -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.