Skip to content

Commit 1ec5607

Browse files
ambvclaude
andauthored
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>
1 parent 0284ce8 commit 1ec5607

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
ifself.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
defdetach(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
importsubprocess
99
importsys
1010
importtextwrap
11+
importthreading
1112
importunittest
1213
importunittest.mock
1314
fromcontextlibimportclosing, contextmanager, redirect_stdout, redirect_stderr, ExitStack
@@ -18,6 +19,11 @@
1819
importpdb
1920
frompdbimport_PdbServer, _PdbClient
2021

22+
try:
23+
importpty
24+
exceptImportError:
25+
pty=None
26+
2127

2228
ifnotsys.is_remote_debug_enabled():
2329
raiseunittest.SkipTest('remote debugging is disabled')
@@ -1090,6 +1096,45 @@ def _connect_and_get_client_file(self):
10901096

10911097
returnprocess, 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+
returnprocess, client_file
1129+
1130+
@staticmethod
1131+
def_drain_until_eof(fd):
1132+
try:
1133+
whileos.read(fd, 1024):
1134+
pass
1135+
exceptOSError:
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+
deftest_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+
withkill_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
deftest_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)