Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Lib/pdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions Lib/test/test_remote_pdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading