Skip to content

Commit 98a07de

Browse files
ambvclaude
andcommitted
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) <noreply@anthropic.com>
1 parent d1174a4 commit 98a07de

3 files changed

Lines changed: 83 additions & 0 deletions

File tree

Lib/pdb.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3158,6 +3158,15 @@ def postloop(self):
31583158
if self.quitting:
31593159
self.detach()
31603160

3161+
@contextmanager
3162+
def _maybe_use_pyrepl_as_stdin(self):
3163+
# The server reads every command from the client over the socket, never
3164+
# from a local pyrepl. The base implementation swaps in pyrepl as stdin
3165+
# and blanks `self.prompt` to '' (pyrepl would draw the prompt itself),
3166+
# which here would transmit an empty prompt to the client whenever the
3167+
# target process happens to be pyrepl-capable. Keep the real prompt.
3168+
yield
3169+
31613170
def detach(self):
31623171
# Detach the debugger and close the socket without raising BdbQuit
31633172
self.quitting = False

Lib/test/test_remote_pdb.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import subprocess
99
import sys
1010
import textwrap
11+
import threading
1112
import unittest
1213
import unittest.mock
1314
from contextlib import closing, contextmanager, redirect_stdout, redirect_stderr, ExitStack
@@ -18,6 +19,11 @@
1819
import pdb
1920
from pdb import _PdbServer, _PdbClient
2021

22+
try:
23+
import pty
24+
except ImportError:
25+
pty = None
26+
2127

2228
if not sys.is_remote_debug_enabled():
2329
raise unittest.SkipTest('remote debugging is disabled')
@@ -1090,6 +1096,45 @@ def _connect_and_get_client_file(self):
10901096

10911097
return process, client_file
10921098

1099+
def _connect_and_get_client_file_via_pty(self):
1100+
"""Like _connect_and_get_client_file, but run the target under a pty.
1101+
1102+
With a real terminal on stdin/stdout, PyREPL is available *inside the
1103+
target process*, which is the condition that exercises pdb's PyREPL
1104+
input handling in the remote server.
1105+
"""
1106+
controller, worker = pty.openpty()
1107+
self.addCleanup(os.close, controller)
1108+
env = dict(os.environ, TERM="xterm-256color")
1109+
env.pop("PYTHON_BASIC_REPL", None) # don't opt out of PyREPL
1110+
process = subprocess.Popen(
1111+
[sys.executable, self.script_path],
1112+
stdin=worker, stdout=worker, stderr=worker, env=env, close_fds=True,
1113+
)
1114+
os.close(worker) # only the child keeps the worker end open
1115+
1116+
# Continuously drain the terminal so the child never blocks on a write.
1117+
drainer = threading.Thread(
1118+
target=self._drain_until_eof, args=(controller,), daemon=True
1119+
)
1120+
drainer.start()
1121+
self.addCleanup(drainer.join, SHORT_TIMEOUT)
1122+
1123+
client_sock, _ = self.server_sock.accept()
1124+
client_file = client_sock.makefile('rwb')
1125+
self.addCleanup(client_file.close)
1126+
self.addCleanup(client_sock.close)
1127+
1128+
return process, client_file
1129+
1130+
@staticmethod
1131+
def _drain_until_eof(fd):
1132+
try:
1133+
while os.read(fd, 1024):
1134+
pass
1135+
except OSError:
1136+
pass # controller closed, or the pty went away with the child
1137+
10931138
def _read_until_prompt(self, client_file):
10941139
"""Helper to read messages until a prompt is received."""
10951140
messages = []
@@ -1292,6 +1337,32 @@ def test_handle_eof(self):
12921337
self.assertEqual(process.returncode, 0)
12931338
self.assertEqual(stderr, "")
12941339

1340+
@unittest.skipUnless(pty, "requires pty")
1341+
def test_prompt_with_interactive_terminal(self):
1342+
"""The server must send "(Pdb) " even when the target owns a terminal.
1343+
1344+
The remote server transmits its prompt string to the client, which
1345+
displays it. When the target process has an interactive terminal,
1346+
PyREPL is enabled inside it; the base pdb machinery then blanks
1347+
``self.prompt`` (a local PyREPL would draw the prompt itself). The
1348+
remote server has no local PyREPL -- it reads input from the socket --
1349+
so it must keep the real prompt rather than transmit an empty one.
1350+
Regression test for gh-154467, where attaching with ``pdb -p`` to a
1351+
process running at an interactive prompt showed a blank prompt.
1352+
"""
1353+
self._create_script()
1354+
process, client_file = self._connect_and_get_client_file_via_pty()
1355+
1356+
with kill_on_error(process):
1357+
messages = self._read_until_prompt(client_file)
1358+
# The message that ended the read is the prompt request.
1359+
self.assertEqual(messages[-1], {"prompt": "(Pdb) ", "state": "pdb"})
1360+
1361+
# Let the target run to completion so nothing is left attached.
1362+
self._send_command(client_file, "c")
1363+
process.wait(timeout=SHORT_TIMEOUT)
1364+
self.assertEqual(process.returncode, 0)
1365+
12951366
def test_protocol_version(self):
12961367
"""Test that incompatible protocol versions are properly detected."""
12971368
# Create a script using an incompatible protocol version
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixed :mod:`pdb` remote attaching (``python -m pdb -p PID``) sending an
2+
empty prompt to the client instead of ``(Pdb)`` when the target process has
3+
an interactive terminal.

0 commit comments

Comments
 (0)