gh-154467: Fix empty (Pdb) prompt when attaching to a process with pdb -p (#154469)
_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>
diff --git a/Lib/pdb.py b/Lib/pdb.py
index 01451f0..458eb83 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 d26d63f..5b23a19 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 0000000..9d7ac18
--- /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.