-
-
Notifications
You must be signed in to change notification settings - Fork 34.5k
gh-131591: Allow pdb to attach to a running process #132451
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
40 commits
Select commit
Hold shift + click to select a range
5225333
Allow pdb to attach to a running process
godlygeek 78a3085
Remove 2 unused _RemotePdb instance attributes
godlygeek 90e0a81
Reduce duplication for 'debug' command
godlygeek e44a670
End commands entry on 'end' and ^C and ^D
godlygeek e837246
Set the frame for remote pdb to stop in explicitly
godlygeek 27efa97
Fix an unbound local in an error message
godlygeek 557a725
Clean up remote PDB detaching
godlygeek 7f7584a
Allow ctrl-c to interrupt a running process
godlygeek 5666ffb
Automatically detach if the client dies unexpectedly
godlygeek 325f166
Clear _last_pdb_instance on detach
godlygeek 72830e2
Refuse to attach if another PDB instance is installed
godlygeek 27c6780
Handle the confirmation prompt issued by 'clear'
godlygeek baaf28a
Make message and error handle non-string args
godlygeek e61cc31
Add some basic tests
pablogsal 5d59ce1
Don't use deprecated method
pablogsal 09adb2b
Try to prevent a PermissionError on Windows
godlygeek 600aa05
Address review comments
godlygeek 0601f10
Add protocol versioning and support -c commands
godlygeek 5a1755b
Fix tests to match new _connect signature for protocol versioning/com…
godlygeek f184e4e
Add some comments describing our protocol
godlygeek 82b71f8
Use the 'commands' state for '(com)' prompts
godlygeek 3986c17
Remove choices parameter from _prompt_for_confirmation
godlygeek 2e69667
Rename _RemotePdb to _PdbServer
godlygeek 55adbcc
Avoid fallthrough in signal handling
godlygeek 0bda5c2
Fix handling of a SystemExit raised in normal pdb mode
godlygeek 5e93247
Address nit
godlygeek f799e83
Use textwrap.dedent for test readability
godlygeek 46fb219
Drop dataclasses dependency
godlygeek 1ec9475
Combine the two blocks for handling -p PID into one
godlygeek 662c7eb
Add a news entry
godlygeek ac36d7d
Skip remote PDB integration test on WASI
godlygeek f06d9c2
Two small things missed in the previous fixes
godlygeek 715af27
Remove call to set_step in interrupt handler
godlygeek c654fdf
More tests
pablogsal 205bc55
More tests
pablogsal bbe784b
More tests
pablogsal 30cb537
Add what's new entry
pablogsal 659556f
use dedent
pablogsal 6c2d970
Add synchronization to test_keyboard_interrupt
godlygeek 100be44
Stop sending a "signal" message in test_keyboard_interrupt"
godlygeek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Add some basic tests
- Loading branch information
commit e61cc31fde7c7c82044388942747872f1d88cfc7
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,346 @@ | ||
| import io | ||
| import json | ||
| import os | ||
| import signal | ||
| import socket | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import threading | ||
| import unittest | ||
| import unittest.mock | ||
| from contextlib import contextmanager | ||
| from pathlib import Path | ||
| from test.support import os_helper | ||
| from test.support.os_helper import temp_dir, TESTFN, unlink | ||
| from typing import Dict, List, Optional, Tuple, Union, Any | ||
|
|
||
| import pdb | ||
| from pdb import _RemotePdb, _PdbClient, _InteractState | ||
|
|
||
|
|
||
| class MockSocketFile: | ||
| """Mock socket file for testing _RemotePdb without actual socket connections.""" | ||
|
|
||
| def __init__(self): | ||
| self.input_queue = [] | ||
| self.output_buffer = [] | ||
|
|
||
| def write(self, data: bytes) -> None: | ||
| """Simulate write to socket.""" | ||
| self.output_buffer.append(data) | ||
|
|
||
| def flush(self) -> None: | ||
| """No-op flush implementation.""" | ||
| pass | ||
|
|
||
| def readline(self) -> bytes: | ||
| """Read a line from the prepared input queue.""" | ||
| if not self.input_queue: | ||
| return b"" | ||
| return self.input_queue.pop(0) | ||
|
|
||
| def close(self) -> None: | ||
| """Close the mock socket file.""" | ||
| pass | ||
|
|
||
| def add_input(self, data: dict) -> None: | ||
| """Add input that will be returned by readline.""" | ||
| self.input_queue.append(json.dumps(data).encode() + b"\n") | ||
|
|
||
| def get_output(self) -> List[dict]: | ||
| """Get the output that was written by the object being tested.""" | ||
| results = [] | ||
| for data in self.output_buffer: | ||
| if isinstance(data, bytes) and data.endswith(b"\n"): | ||
| try: | ||
| results.append(json.loads(data.decode().strip())) | ||
| except json.JSONDecodeError: | ||
| pass # Ignore non-JSON output | ||
| self.output_buffer = [] | ||
| return results | ||
|
|
||
|
|
||
| class RemotePdbTestCase(unittest.TestCase): | ||
| """Tests for the _RemotePdb class.""" | ||
|
|
||
| def setUp(self): | ||
| self.sockfile = MockSocketFile() | ||
| self.pdb = _RemotePdb(self.sockfile) | ||
|
|
||
| # Create a frame for testing | ||
| self.test_globals = {'a': 1, 'b': 2, '__pdb_convenience_variables': {'x': 100}} | ||
| self.test_locals = {'c': 3, 'd': 4} | ||
|
|
||
| # Create a simple test frame | ||
| frame_info = unittest.mock.Mock() | ||
| frame_info.f_globals = self.test_globals | ||
| frame_info.f_locals = self.test_locals | ||
| frame_info.f_lineno = 42 | ||
| frame_info.f_code = unittest.mock.Mock() | ||
| frame_info.f_code.co_filename = "test_file.py" | ||
| frame_info.f_code.co_name = "test_function" | ||
|
|
||
| self.pdb.curframe = frame_info | ||
|
|
||
| def test_message_and_error(self): | ||
| """Test message and error methods send correct JSON.""" | ||
| self.pdb.message("Test message") | ||
| self.pdb.error("Test error") | ||
|
|
||
| outputs = self.sockfile.get_output() | ||
| self.assertEqual(len(outputs), 2) | ||
| self.assertEqual(outputs[0], {"message": "Test message\n"}) | ||
| self.assertEqual(outputs[1], {"error": "Test error"}) | ||
|
|
||
| def test_read_command(self): | ||
| """Test reading commands from the socket.""" | ||
| # Add test input | ||
| self.sockfile.add_input({"command": "help"}) | ||
|
|
||
| # Read the command | ||
| cmd = self.pdb._read_command() | ||
| self.assertEqual(cmd, "help") | ||
|
|
||
| def test_read_command_EOF(self): | ||
| """Test reading EOF command.""" | ||
| # Simulate socket closure | ||
| self.pdb._write_failed = True | ||
| cmd = self.pdb._read_command() | ||
| self.assertEqual(cmd, "EOF") | ||
|
|
||
| def test_completion(self): | ||
| """Test handling completion requests.""" | ||
| # Mock completenames to return specific values | ||
| with unittest.mock.patch.object(self.pdb, 'completenames', | ||
| return_value=["continue", "clear"]): | ||
|
|
||
| # Add a completion request | ||
| self.sockfile.add_input({ | ||
| "completion": { | ||
| "text": "c", | ||
| "line": "c", | ||
| "begidx": 0, | ||
| "endidx": 1 | ||
| } | ||
| }) | ||
|
|
||
| # Add a regular command to break the loop | ||
| self.sockfile.add_input({"command": "help"}) | ||
|
|
||
| # Read command - this should process the completion request first | ||
| cmd = self.pdb._read_command() | ||
|
|
||
| # Verify completion response was sent | ||
| outputs = self.sockfile.get_output() | ||
| self.assertEqual(len(outputs), 1) | ||
| self.assertEqual(outputs[0], {"completion": ["continue", "clear"]}) | ||
|
|
||
| # The actual command should be returned | ||
| self.assertEqual(cmd, "help") | ||
|
|
||
| def test_do_help(self): | ||
| """Test that do_help sends the help message.""" | ||
| self.pdb.do_help("break") | ||
|
|
||
| outputs = self.sockfile.get_output() | ||
| self.assertEqual(len(outputs), 1) | ||
| self.assertEqual(outputs[0], {"help": "break"}) | ||
|
|
||
| def test_interact_mode(self): | ||
| """Test interaction mode setup and execution.""" | ||
| # First set up interact mode | ||
| self.pdb.do_interact("") | ||
|
|
||
| # Verify _interact_state is properly initialized | ||
| self.assertIsNotNone(self.pdb._interact_state) | ||
| self.assertIsInstance(self.pdb._interact_state, _InteractState) | ||
|
|
||
| # Test running code in interact mode | ||
| with unittest.mock.patch.object(self.pdb, '_error_exc') as mock_error: | ||
| self.pdb._run_in_python_repl("print('test')") | ||
| mock_error.assert_not_called() | ||
|
|
||
| # Test with syntax error | ||
| self.pdb._run_in_python_repl("if:") | ||
| mock_error.assert_called_once() | ||
|
|
||
| def test_do_commands(self): | ||
| """Test handling breakpoint commands.""" | ||
| # Mock get_bpbynumber | ||
| with unittest.mock.patch.object(self.pdb, 'get_bpbynumber'): | ||
| # Test command entry mode initiation | ||
| self.pdb.do_commands("1") | ||
|
|
||
| outputs = self.sockfile.get_output() | ||
| self.assertEqual(len(outputs), 1) | ||
| self.assertIn("commands_entry", outputs[0]) | ||
| self.assertEqual(outputs[0]["commands_entry"]["bpnum"], 1) | ||
|
|
||
| # Test with commands | ||
| self.pdb.do_commands("1\nsilent\nprint('hi')\nend") | ||
|
|
||
| # Should have set up the commands for bpnum 1 | ||
| self.assertEqual(self.pdb.commands_bnum, 1) | ||
| self.assertIn(1, self.pdb.commands) | ||
| self.assertEqual(len(self.pdb.commands[1]), 2) # silent and print | ||
|
|
||
| def test_detach(self): | ||
| """Test the detach method.""" | ||
| with unittest.mock.patch.object(self.sockfile, 'close') as mock_close: | ||
| self.pdb.detach() | ||
| mock_close.assert_called_once() | ||
| self.assertFalse(self.pdb.quitting) | ||
|
|
||
| def test_cmdloop(self): | ||
| """Test the command loop with various commands.""" | ||
| # Mock onecmd to track command execution | ||
| with unittest.mock.patch.object(self.pdb, 'onecmd', return_value=False) as mock_onecmd: | ||
| # Add commands to the queue | ||
| self.pdb.cmdqueue = ['help', 'list'] | ||
|
|
||
| # Add a command from the socket for when cmdqueue is empty | ||
| self.sockfile.add_input({"command": "next"}) | ||
|
|
||
| # Add a second command to break the loop | ||
| self.sockfile.add_input({"command": "quit"}) | ||
|
|
||
| # Configure onecmd to exit the loop on "quit" | ||
| def side_effect(line): | ||
| return line == 'quit' | ||
| mock_onecmd.side_effect = side_effect | ||
|
|
||
| # Run the command loop | ||
| self.pdb.quitting = False # Set this by hand because we don't want to really call set_trace() | ||
| self.pdb.cmdloop() | ||
|
|
||
| # Should have processed 4 commands: 2 from cmdqueue, 2 from socket | ||
| self.assertEqual(mock_onecmd.call_count, 4) | ||
| mock_onecmd.assert_any_call('help') | ||
| mock_onecmd.assert_any_call('list') | ||
| mock_onecmd.assert_any_call('next') | ||
| mock_onecmd.assert_any_call('quit') | ||
|
|
||
| # Check if prompt was sent to client | ||
| outputs = self.sockfile.get_output() | ||
| prompts = [o for o in outputs if 'prompt' in o] | ||
| self.assertEqual(len(prompts), 2) # Should have sent 2 prompts | ||
|
|
||
|
|
||
| class PdbConnectTestCase(unittest.TestCase): | ||
| """Tests for the _connect mechanism using direct socket communication.""" | ||
|
|
||
| def setUp(self): | ||
| # Create a server socket that will wait for the debugger to connect | ||
| self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||
| self.server_sock.bind(('127.0.0.1', 0)) # Let OS assign port | ||
| self.server_sock.listen(1) | ||
| self.port = self.server_sock.getsockname()[1] | ||
|
|
||
| # Create a file for subprocess script | ||
| self.script_path = TESTFN + "_connect_test.py" | ||
| with open(self.script_path, 'w') as f: | ||
| f.write(f""" | ||
| import pdb | ||
| import sys | ||
| import time | ||
|
|
||
| def connect_to_debugger(): | ||
| # Create a frame to debug | ||
| def dummy_function(): | ||
| x = 42 | ||
| # Call connect to establish connection with the test server | ||
| frame = sys._getframe() # Get the current frame | ||
| pdb._connect('127.0.0.1', {self.port}, frame) | ||
| return x # This line should not be reached in debugging | ||
|
|
||
| return dummy_function() | ||
|
|
||
| result = connect_to_debugger() | ||
| print(f"Function returned: {{result}}") | ||
| """) | ||
|
|
||
| def tearDown(self): | ||
| self.server_sock.close() | ||
| try: | ||
| unlink(self.script_path) | ||
| except OSError: | ||
| pass | ||
|
|
||
| def test_connect_and_basic_commands(self): | ||
| """Test connecting to a remote debugger and sending basic commands.""" | ||
| # Start the subprocess that will connect to our socket | ||
| with subprocess.Popen( | ||
| [sys.executable, self.script_path], | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| text=True | ||
| ) as process: | ||
| # Accept the connection from the subprocess | ||
| client_sock, _ = self.server_sock.accept() | ||
| client_file = client_sock.makefile('rwb') | ||
| self.addCleanup(client_file.close) | ||
| self.addCleanup(client_sock.close) | ||
|
|
||
| # We should receive initial data from the debugger | ||
| data = client_file.readline() | ||
| initial_data = json.loads(data.decode()) | ||
| self.assertIn('message', initial_data) | ||
| self.assertIn('pdb._connect', initial_data['message']) | ||
|
|
||
| # First, look for command_list message | ||
| data = client_file.readline() | ||
| command_list = json.loads(data.decode()) | ||
| self.assertIn('command_list', command_list) | ||
|
|
||
| # Then, look for the first prompt | ||
| data = client_file.readline() | ||
| prompt_data = json.loads(data.decode()) | ||
| self.assertIn('prompt', prompt_data) | ||
| self.assertEqual(prompt_data['mode'], 'pdb') | ||
|
|
||
| # Send 'bt' (backtrace) command | ||
| client_file.write(json.dumps({"command": "bt"}).encode() + b"\n") | ||
| client_file.flush() | ||
|
|
||
| # Check for response - we should get some stack frames | ||
| # We may get multiple messages so we need to read until we get a new prompt | ||
| got_stack_info = False | ||
| text_msg = [] | ||
| while True: | ||
| data = client_file.readline() | ||
| if not data: | ||
| break | ||
|
|
||
| msg = json.loads(data.decode()) | ||
| if 'message' in msg and 'connect_to_debugger' in msg['message']: | ||
| got_stack_info = True | ||
| text_msg.append(msg['message']) | ||
|
|
||
| if 'prompt' in msg: | ||
| break | ||
|
|
||
| expected_stacks = [ | ||
| "<module>", | ||
| "connect_to_debugger", | ||
| ] | ||
|
|
||
| for stack, msg in zip(expected_stacks, text_msg, strict=True): | ||
| self.assertIn(stack, msg) | ||
|
|
||
| self.assertTrue(got_stack_info, "Should have received stack trace information") | ||
|
|
||
| # Send 'c' (continue) command to let the program finish | ||
| client_file.write(json.dumps({"command": "c"}).encode() + b"\n") | ||
| client_file.flush() | ||
|
|
||
| # Wait for process to finish | ||
| stdout, _ = process.communicate(timeout=5) | ||
|
|
||
| # Check if we got the expected output | ||
| self.assertIn("Function returned: 42", stdout) | ||
| self.assertEqual(process.returncode, 0) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you use
textwrap.dedent()here so the code can be indended visually?