From c98b355ef46089b15fd8e07f20df95b021aae29a Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Wed, 11 Jun 2025 14:28:00 +1000 Subject: [PATCH 01/31] python-ecosys/debugpy: Add VS Code debugging support for MicroPython. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This implementation provides a Debug Adapter Protocol (DAP) server that enables VS Code to debug MicroPython code with full breakpoint, stepping, and variable inspection capabilities. Features: - Manual breakpoints via debugpy.breakpoint() - Line breakpoints set from VS Code - Stack trace inspection - Variable scopes (locals/globals) - Source code viewing - Stepping (into/over/out) - Non-blocking architecture for MicroPython's single-threaded environment - Conditional debug logging based on VS Code's logToFile setting Implementation highlights: - Uses MicroPython's sys.settrace() for execution monitoring - Handles path mapping between VS Code and MicroPython - Efficient O(n) fibonacci demo (was O(2^n) recursive) - Compatible with MicroPython's limited frame object attributes - Comprehensive DAP protocol support Files: - debugpy/: Core debugging implementation - test_vscode.py: VS Code integration test - VSCODE_TESTING_GUIDE.md: Setup and usage instructions - dap_monitor.py: Protocol debugging utility Usage: ```python import debugpy debugpy.listen() # Start debug server debugpy.debug_this_thread() # Enable tracing debugpy.breakpoint() # Manual breakpoint ``` 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- python-ecosys/debugpy/README.md | 172 +++++++ python-ecosys/debugpy/dap_monitor.py | 162 +++++++ python-ecosys/debugpy/debugpy/__init__.py | 20 + .../debugpy/debugpy/common/__init__.py | 1 + .../debugpy/debugpy/common/constants.py | 60 +++ .../debugpy/debugpy/common/messaging.py | 154 +++++++ python-ecosys/debugpy/debugpy/public_api.py | 126 ++++++ .../debugpy/debugpy/server/__init__.py | 1 + .../debugpy/debugpy/server/debug_session.py | 423 ++++++++++++++++++ .../debugpy/debugpy/server/pdb_adapter.py | 285 ++++++++++++ python-ecosys/debugpy/demo.py | 68 +++ python-ecosys/debugpy/development_guide.md | 84 ++++ python-ecosys/debugpy/manifest.py | 6 + python-ecosys/debugpy/test_vscode.py | 72 +++ .../debugpy/vscode_launch_example.json | 22 + 15 files changed, 1656 insertions(+) create mode 100644 python-ecosys/debugpy/README.md create mode 100644 python-ecosys/debugpy/dap_monitor.py create mode 100644 python-ecosys/debugpy/debugpy/__init__.py create mode 100644 python-ecosys/debugpy/debugpy/common/__init__.py create mode 100644 python-ecosys/debugpy/debugpy/common/constants.py create mode 100644 python-ecosys/debugpy/debugpy/common/messaging.py create mode 100644 python-ecosys/debugpy/debugpy/public_api.py create mode 100644 python-ecosys/debugpy/debugpy/server/__init__.py create mode 100644 python-ecosys/debugpy/debugpy/server/debug_session.py create mode 100644 python-ecosys/debugpy/debugpy/server/pdb_adapter.py create mode 100644 python-ecosys/debugpy/demo.py create mode 100644 python-ecosys/debugpy/development_guide.md create mode 100644 python-ecosys/debugpy/manifest.py create mode 100644 python-ecosys/debugpy/test_vscode.py create mode 100644 python-ecosys/debugpy/vscode_launch_example.json diff --git a/python-ecosys/debugpy/README.md b/python-ecosys/debugpy/README.md new file mode 100644 index 000000000..70859b974 --- /dev/null +++ b/python-ecosys/debugpy/README.md @@ -0,0 +1,172 @@ +# MicroPython debugpy + +A minimal implementation of debugpy for MicroPython, enabling remote debugging +such as VS Code debugging support. + +## Features + +- Debug Adapter Protocol (DAP) support for VS Code integration +- Basic debugging operations: + - Breakpoints + - Step over/into/out + - Stack trace inspection + - Variable inspection (globals, locals generally not supported) + - Expression evaluation + - Pause/continue execution + +## Requirements + +- MicroPython with `sys.settrace` support (enabled with `MICROPY_PY_SYS_SETTRACE`) +- Socket support for network communication +- JSON support for DAP message parsing + +## Usage + +### Basic Usage + +```python +import debugpy + +# Start listening for debugger connections +host, port = debugpy.listen() # Default: 127.0.0.1:5678 +print(f"Debugger listening on {host}:{port}") + +# Enable debugging for current thread +debugpy.debug_this_thread() + +# Your code here... +def my_function(): + x = 10 + y = 20 + result = x + y # Set breakpoint here in VS Code + return result + +result = my_function() +print(f"Result: {result}") + +# Manual breakpoint +debugpy.breakpoint() +``` + +### VS Code Configuration + +Create a `.vscode/launch.json` file in your project: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to MicroPython", + "type": "python", + "request": "attach", + "connect": { + "host": "127.0.0.1", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ], + "justMyCode": false + } + ] +} +``` + +### Testing + +1. Build the MicroPython Unix coverage port: + ```bash + cd ports/unix + make CFLAGS_EXTRA="-DMICROPY_PY_SYS_SETTRACE=1" + ``` + +2. Run the test script: + ```bash + cd lib/micropython-lib/python-ecosys/debugpy + ../../../../ports/unix/build-coverage/micropython test_debugpy.py + ``` + +3. In VS Code, open the debugpy folder and press F5 to attach the debugger + +4. Set breakpoints in the test script and observe debugging functionality + +## API Reference + +### `debugpy.listen(port=5678, host="127.0.0.1")` + +Start listening for debugger connections. + +**Parameters:** +- `port`: Port number to listen on (default: 5678) +- `host`: Host address to bind to (default: "127.0.0.1") + +**Returns:** Tuple of (host, port) actually used + +### `debugpy.debug_this_thread()` + +Enable debugging for the current thread by installing the trace function. + +### `debugpy.breakpoint()` + +Trigger a manual breakpoint that will pause execution if a debugger is attached. + +### `debugpy.wait_for_client()` + +Wait for the debugger client to connect and initialize. + +### `debugpy.is_client_connected()` + +Check if a debugger client is currently connected. + +**Returns:** Boolean indicating connection status + +### `debugpy.disconnect()` + +Disconnect from the debugger client and clean up resources. + +## Architecture + +The implementation consists of several key components: + +1. **Public API** (`public_api.py`): Main entry points for users +2. **Debug Session** (`server/debug_session.py`): Handles DAP protocol communication +3. **PDB Adapter** (`server/pdb_adapter.py`): Bridges DAP and MicroPython's trace system +4. **Messaging** (`common/messaging.py`): JSON message handling for DAP +5. **Constants** (`common/constants.py`): DAP protocol constants + +## Limitations + +This is a minimal implementation with the following limitations: + +- Single-threaded debugging only +- No conditional breakpoints +- No function breakpoints +- Limited variable inspection (no nested object expansion) +- No step back functionality +- No hot code reloading +- Simplified stepping implementation + +## Compatibility + +Tested with: +- MicroPython Unix port +- VS Code with Python/debugpy extension +- CPython 3.x (for comparison) + +## Contributing + +This implementation provides a foundation for MicroPython debugging. Contributions are welcome to add: + +- Conditional breakpoint support +- Better variable inspection +- Multi-threading support +- Performance optimizations +- Additional DAP features + +## License + +MIT License - see the MicroPython project license for details. diff --git a/python-ecosys/debugpy/dap_monitor.py b/python-ecosys/debugpy/dap_monitor.py new file mode 100644 index 000000000..3af4eba16 --- /dev/null +++ b/python-ecosys/debugpy/dap_monitor.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""DAP protocol monitor - sits between VS Code and MicroPython debugpy.""" + +import socket +import threading +import json +import time +import sys + +class DAPMonitor: + def __init__(self, listen_port=5679, target_host='127.0.0.1', target_port=5678): + self.listen_port = listen_port + self.target_host = target_host + self.target_port = target_port + self.client_sock = None + self.server_sock = None + + def start(self): + """Start the DAP monitor proxy.""" + print(f"DAP Monitor starting on port {self.listen_port}") + print(f"Will forward to {self.target_host}:{self.target_port}") + print("Start MicroPython debugpy server first, then connect VS Code to port 5679") + + # Create listening socket + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(('127.0.0.1', self.listen_port)) + listener.listen(1) + + print(f"Listening for VS Code connection on port {self.listen_port}...") + + try: + # Wait for VS Code to connect + self.client_sock, client_addr = listener.accept() + print(f"VS Code connected from {client_addr}") + + # Connect to MicroPython debugpy server + self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server_sock.connect((self.target_host, self.target_port)) + print(f"Connected to MicroPython debugpy at {self.target_host}:{self.target_port}") + + # Start forwarding threads + threading.Thread(target=self.forward_client_to_server, daemon=True).start() + threading.Thread(target=self.forward_server_to_client, daemon=True).start() + + print("DAP Monitor active - press Ctrl+C to stop") + while True: + time.sleep(1) + + except KeyboardInterrupt: + print("\nStopping DAP Monitor...") + except Exception as e: + print(f"Error: {e}") + finally: + self.cleanup() + + def forward_client_to_server(self): + """Forward messages from VS Code client to MicroPython server.""" + try: + while True: + data = self.receive_dap_message(self.client_sock, "VS Code") + if data is None: + break + self.send_raw_data(self.server_sock, data) + except Exception as e: + print(f"Client->Server forwarding error: {e}") + + def forward_server_to_client(self): + """Forward messages from MicroPython server to VS Code client.""" + try: + while True: + data = self.receive_dap_message(self.server_sock, "MicroPython") + if data is None: + break + self.send_raw_data(self.client_sock, data) + except Exception as e: + print(f"Server->Client forwarding error: {e}") + + def receive_dap_message(self, sock, source): + """Receive and log a DAP message.""" + try: + # Read headers + header = b"" + while b"\r\n\r\n" not in header: + byte = sock.recv(1) + if not byte: + return None + header += byte + + # Parse content length + header_str = header.decode('utf-8') + content_length = 0 + for line in header_str.split('\r\n'): + if line.startswith('Content-Length:'): + content_length = int(line.split(':', 1)[1].strip()) + break + + if content_length == 0: + return None + + # Read content + content = b"" + while len(content) < content_length: + chunk = sock.recv(content_length - len(content)) + if not chunk: + return None + content += chunk + + # Log the message + try: + message = json.loads(content.decode('utf-8')) + msg_type = message.get('type', 'unknown') + command = message.get('command', message.get('event', 'unknown')) + seq = message.get('seq', 0) + + print(f"\n[{source}] {msg_type.upper()}: {command} (seq={seq})") + + if msg_type == 'request': + args = message.get('arguments', {}) + if args: + print(f" Arguments: {json.dumps(args, indent=2)}") + elif msg_type == 'response': + success = message.get('success', False) + req_seq = message.get('request_seq', 0) + print(f" Success: {success}, Request Seq: {req_seq}") + body = message.get('body') + if body: + print(f" Body: {json.dumps(body, indent=2)}") + msg = message.get('message') + if msg: + print(f" Message: {msg}") + elif msg_type == 'event': + body = message.get('body', {}) + if body: + print(f" Body: {json.dumps(body, indent=2)}") + + except json.JSONDecodeError: + print(f"\n[{source}] Invalid JSON: {content}") + + return header + content + + except Exception as e: + print(f"Error receiving from {source}: {e}") + return None + + def send_raw_data(self, sock, data): + """Send raw data to socket.""" + try: + sock.send(data) + except Exception as e: + print(f"Error sending data: {e}") + + def cleanup(self): + """Clean up sockets.""" + if self.client_sock: + self.client_sock.close() + if self.server_sock: + self.server_sock.close() + +if __name__ == "__main__": + monitor = DAPMonitor() + monitor.start() \ No newline at end of file diff --git a/python-ecosys/debugpy/debugpy/__init__.py b/python-ecosys/debugpy/debugpy/__init__.py new file mode 100644 index 000000000..b7649bd5c --- /dev/null +++ b/python-ecosys/debugpy/debugpy/__init__.py @@ -0,0 +1,20 @@ +"""MicroPython debugpy implementation. + +A minimal port of debugpy for MicroPython to enable VS Code debugging support. +This implementation focuses on the core DAP (Debug Adapter Protocol) functionality +needed for basic debugging operations like breakpoints, stepping, and variable inspection. +""" + +__version__ = "0.1.0" + +from .public_api import listen, wait_for_client, breakpoint, debug_this_thread +from .common.constants import DEFAULT_HOST, DEFAULT_PORT + +__all__ = [ + "listen", + "wait_for_client", + "breakpoint", + "debug_this_thread", + "DEFAULT_HOST", + "DEFAULT_PORT", +] diff --git a/python-ecosys/debugpy/debugpy/common/__init__.py b/python-ecosys/debugpy/debugpy/common/__init__.py new file mode 100644 index 000000000..c53632010 --- /dev/null +++ b/python-ecosys/debugpy/debugpy/common/__init__.py @@ -0,0 +1 @@ +# Common utilities and constants for debugpy diff --git a/python-ecosys/debugpy/debugpy/common/constants.py b/python-ecosys/debugpy/debugpy/common/constants.py new file mode 100644 index 000000000..aeee675e3 --- /dev/null +++ b/python-ecosys/debugpy/debugpy/common/constants.py @@ -0,0 +1,60 @@ +"""Constants used throughout debugpy.""" + +# Default networking settings +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 5678 + +# DAP message types +MSG_TYPE_REQUEST = "request" +MSG_TYPE_RESPONSE = "response" +MSG_TYPE_EVENT = "event" + +# DAP events +EVENT_INITIALIZED = "initialized" +EVENT_STOPPED = "stopped" +EVENT_CONTINUED = "continued" +EVENT_THREAD = "thread" +EVENT_BREAKPOINT = "breakpoint" +EVENT_OUTPUT = "output" +EVENT_TERMINATED = "terminated" +EVENT_EXITED = "exited" + +# DAP commands +CMD_INITIALIZE = "initialize" +CMD_LAUNCH = "launch" +CMD_ATTACH = "attach" +CMD_SET_BREAKPOINTS = "setBreakpoints" +CMD_CONTINUE = "continue" +CMD_NEXT = "next" +CMD_STEP_IN = "stepIn" +CMD_STEP_OUT = "stepOut" +CMD_PAUSE = "pause" +CMD_STACK_TRACE = "stackTrace" +CMD_SCOPES = "scopes" +CMD_VARIABLES = "variables" +CMD_EVALUATE = "evaluate" +CMD_DISCONNECT = "disconnect" +CMD_CONFIGURATION_DONE = "configurationDone" +CMD_THREADS = "threads" +CMD_SOURCE = "source" + +# Stop reasons +STOP_REASON_STEP = "step" +STOP_REASON_BREAKPOINT = "breakpoint" +STOP_REASON_EXCEPTION = "exception" +STOP_REASON_PAUSE = "pause" +STOP_REASON_ENTRY = "entry" + +# Thread reasons +THREAD_REASON_STARTED = "started" +THREAD_REASON_EXITED = "exited" + +# Trace events +TRACE_CALL = "call" +TRACE_LINE = "line" +TRACE_RETURN = "return" +TRACE_EXCEPTION = "exception" + +# Scope types +SCOPE_LOCALS = "locals" +SCOPE_GLOBALS = "globals" diff --git a/python-ecosys/debugpy/debugpy/common/messaging.py b/python-ecosys/debugpy/debugpy/common/messaging.py new file mode 100644 index 000000000..bc264e3ff --- /dev/null +++ b/python-ecosys/debugpy/debugpy/common/messaging.py @@ -0,0 +1,154 @@ +"""JSON message handling for DAP protocol.""" + +import json +from .constants import MSG_TYPE_REQUEST, MSG_TYPE_RESPONSE, MSG_TYPE_EVENT + + +class JsonMessageChannel: + """Handles JSON message communication over a socket using DAP format.""" + + def __init__(self, sock, debug_callback=None): + self.sock = sock + self.seq = 0 + self.closed = False + self._recv_buffer = b"" + self._debug_print = debug_callback or (lambda x: None) # Default to no-op + + def send_message(self, msg_type, command=None, **kwargs): + """Send a DAP message.""" + if self.closed: + return + + self.seq += 1 + message = { + "seq": self.seq, + "type": msg_type, + } + + if command: + if msg_type == MSG_TYPE_REQUEST: + message["command"] = command + if kwargs: + message["arguments"] = kwargs + elif msg_type == MSG_TYPE_RESPONSE: + message["command"] = command + message["request_seq"] = kwargs.get("request_seq", 0) + message["success"] = kwargs.get("success", True) + if "body" in kwargs: + message["body"] = kwargs["body"] + if "message" in kwargs: + message["message"] = kwargs["message"] + elif msg_type == MSG_TYPE_EVENT: + message["event"] = command + if kwargs: + message["body"] = kwargs + + json_str = json.dumps(message) + content = json_str.encode("utf-8") + header = f"Content-Length: {len(content)}\r\n\r\n".encode("utf-8") + + try: + self.sock.send(header + content) + except OSError: + self.closed = True + + def send_request(self, command, **kwargs): + """Send a request message.""" + self.send_message(MSG_TYPE_REQUEST, command, **kwargs) + + def send_response(self, command, request_seq, success=True, body=None, message=None): + """Send a response message.""" + kwargs = {"request_seq": request_seq, "success": success} + if body is not None: + kwargs["body"] = body + if message is not None: + kwargs["message"] = message + + self._debug_print(f"[DAP] SEND: response {command} (req_seq={request_seq}, success={success})") + if body: + self._debug_print(f"[DAP] body: {body}") + if message: + self._debug_print(f"[DAP] message: {message}") + + self.send_message(MSG_TYPE_RESPONSE, command, **kwargs) + + def send_event(self, event, **kwargs): + """Send an event message.""" + self._debug_print(f"[DAP] SEND: event {event}") + if kwargs: + self._debug_print(f"[DAP] body: {kwargs}") + self.send_message(MSG_TYPE_EVENT, event, **kwargs) + + def recv_message(self): + """Receive a DAP message.""" + if self.closed: + return None + + try: + # Read headers + while b"\r\n\r\n" not in self._recv_buffer: + try: + data = self.sock.recv(1024) + if not data: + self.closed = True + return None + self._recv_buffer += data + except OSError as e: + # Handle timeout and other socket errors + if hasattr(e, 'errno') and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK + return None # No data available + self.closed = True + return None + + header_end = self._recv_buffer.find(b"\r\n\r\n") + header_str = self._recv_buffer[:header_end].decode("utf-8") + self._recv_buffer = self._recv_buffer[header_end + 4:] + + # Parse Content-Length + content_length = 0 + for line in header_str.split("\r\n"): + if line.startswith("Content-Length:"): + content_length = int(line.split(":", 1)[1].strip()) + break + + if content_length == 0: + return None + + # Read body + while len(self._recv_buffer) < content_length: + try: + data = self.sock.recv(content_length - len(self._recv_buffer)) + if not data: + self.closed = True + return None + self._recv_buffer += data + except OSError as e: + if hasattr(e, 'errno') and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK + return None + self.closed = True + return None + + body = self._recv_buffer[:content_length] + self._recv_buffer = self._recv_buffer[content_length:] + + # Parse JSON + try: + message = json.loads(body.decode("utf-8")) + self._debug_print(f"[DAP] Successfully received message: {message.get('type')} {message.get('command', message.get('event', 'unknown'))}") + return message + except (ValueError, UnicodeDecodeError) as e: + print(f"[DAP] JSON parse error: {e}") + return None + + except OSError as e: + print(f"[DAP] Socket error in recv_message: {e}") + self.closed = True + return None + + def close(self): + """Close the channel.""" + self.closed = True + try: + self.sock.close() + except OSError: + pass diff --git a/python-ecosys/debugpy/debugpy/public_api.py b/python-ecosys/debugpy/debugpy/public_api.py new file mode 100644 index 000000000..137706efe --- /dev/null +++ b/python-ecosys/debugpy/debugpy/public_api.py @@ -0,0 +1,126 @@ +"""Public API for debugpy.""" + +import socket +import sys +from .common.constants import DEFAULT_HOST, DEFAULT_PORT +from .server.debug_session import DebugSession + +_debug_session = None + + +def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): + """Start listening for debugger connections. + + Args: + port: Port number to listen on (default: 5678) + host: Host address to bind to (default: "127.0.0.1") + + Returns: + (host, port) tuple of the actual listening address + """ + global _debug_session + + if _debug_session is not None: + raise RuntimeError("Already listening for debugger") + + # Create listening socket + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + except: + pass # Not supported in MicroPython + + # Use getaddrinfo for MicroPython compatibility + addr_info = socket.getaddrinfo(host, port) + addr = addr_info[0][-1] # Get the sockaddr + listener.bind(addr) + listener.listen(1) + + # getsockname not available in MicroPython, use original values + print(f"Debugpy listening on {host}:{port}") + + # Wait for connection + client_sock = None + try: + client_sock, client_addr = listener.accept() + print(f"Debugger connected from {client_addr}") + + # Create debug session + _debug_session = DebugSession(client_sock) + + # Handle just the initialize request, then return immediately + print("[DAP] Waiting for initialize request...") + init_message = _debug_session.channel.recv_message() + if init_message and init_message.get('command') == 'initialize': + _debug_session._handle_message(init_message) + print("[DAP] Initialize request handled - returning control immediately") + else: + print(f"[DAP] Warning: Expected initialize, got {init_message}") + + # Set socket to non-blocking for subsequent message processing + _debug_session.channel.sock.settimeout(0.001) + + print("[DAP] Debug session ready - all other messages will be handled in trace function") + + except Exception as e: + print(f"[DAP] Connection error: {e}") + if client_sock: + client_sock.close() + _debug_session = None + finally: + # Only close the listener, not the client connection + listener.close() + + return (host, port) + + +def wait_for_client(): + """Wait for the debugger client to connect and initialize.""" + global _debug_session + if _debug_session: + _debug_session.wait_for_client() + + +def breakpoint(): + """Trigger a breakpoint in the debugger.""" + global _debug_session + if _debug_session: + _debug_session.trigger_breakpoint() + else: + # Fallback to built-in breakpoint if available + if hasattr(__builtins__, 'breakpoint'): + __builtins__.breakpoint() + + +def debug_this_thread(): + """Enable debugging for the current thread.""" + global _debug_session + if _debug_session: + _debug_session.debug_this_thread() + else: + # Install trace function even if no session yet + if hasattr(sys, 'settrace'): + sys.settrace(_default_trace_func) + else: + raise RuntimeError("MICROPY_PY_SYS_SETTRACE required") + + +def _default_trace_func(frame, event, arg): + """Default trace function when no debug session is active.""" + # Just return None to continue execution + return None + + + +def is_client_connected(): + """Check if a debugger client is connected.""" + global _debug_session + return _debug_session is not None and _debug_session.is_connected() + + +def disconnect(): + """Disconnect from the debugger client.""" + global _debug_session + if _debug_session: + _debug_session.disconnect() + _debug_session = None diff --git a/python-ecosys/debugpy/debugpy/server/__init__.py b/python-ecosys/debugpy/debugpy/server/__init__.py new file mode 100644 index 000000000..1ab7a0ff5 --- /dev/null +++ b/python-ecosys/debugpy/debugpy/server/__init__.py @@ -0,0 +1 @@ +# Debug server components diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py new file mode 100644 index 000000000..4f60ee358 --- /dev/null +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -0,0 +1,423 @@ +"""Main debug session handling DAP protocol communication.""" + +import sys +from ..common.messaging import JsonMessageChannel +from ..common.constants import ( + CMD_INITIALIZE, CMD_LAUNCH, CMD_ATTACH, CMD_SET_BREAKPOINTS, + CMD_CONTINUE, CMD_NEXT, CMD_STEP_IN, CMD_STEP_OUT, CMD_PAUSE, + CMD_STACK_TRACE, CMD_SCOPES, CMD_VARIABLES, CMD_EVALUATE, CMD_DISCONNECT, + CMD_CONFIGURATION_DONE, CMD_THREADS, CMD_SOURCE, EVENT_INITIALIZED, EVENT_STOPPED, EVENT_CONTINUED, EVENT_TERMINATED, + STOP_REASON_BREAKPOINT, STOP_REASON_STEP, STOP_REASON_PAUSE, + TRACE_CALL, TRACE_LINE, TRACE_RETURN, TRACE_EXCEPTION +) +from .pdb_adapter import PdbAdapter + + +class DebugSession: + """Manages a debugging session with a DAP client.""" + + def __init__(self, client_socket): + self.debug_logging = False # Initialize first + self.channel = JsonMessageChannel(client_socket, self._debug_print) + self.pdb = PdbAdapter() + self.pdb._debug_session = self # Allow PDB to process messages during wait + self.initialized = False + self.connected = True + self.thread_id = 1 # Simple single-thread model + self.stepping = False + self.paused = False + + def _debug_print(self, message): + """Print debug message only if debug logging is enabled.""" + if self.debug_logging: + print(message) + + def start(self): + """Start the debug session message loop.""" + try: + while self.connected and not self.channel.closed: + message = self.channel.recv_message() + if message is None: + break + + self._handle_message(message) + + except Exception as e: + print(f"Debug session error: {e}") + finally: + self.disconnect() + + def initialize_connection(self): + """Initialize the connection - handle just the essential initial messages then return.""" + # Note: debug_logging not available yet during init, so we always show these messages + print("[DAP] Processing initial DAP messages...") + + try: + # Process initial messages quickly and return control to main thread + # We'll handle ongoing messages in the trace function + attached = False + message_count = 0 + max_init_messages = 6 # Just handle the first few essential messages + + while message_count < max_init_messages and not attached: + try: + # Short timeout - don't block the main thread for long + self.channel.sock.settimeout(1.0) + message = self.channel.recv_message() + if message is None: + print(f"[DAP] No more messages in initial batch") + break + + print(f"[DAP] Initial message #{message_count + 1}: {message.get('command')}") + self._handle_message(message) + message_count += 1 + + # Just wait for attach, then we can return control + if message.get('command') == 'attach': + attached = True + print("[DAP] ✅ Attach received - returning control to main thread") + break + + except Exception as e: + print(f"[DAP] Exception in initial processing: {e}") + break + finally: + self.channel.sock.settimeout(None) + + # After attach, continue processing a few more messages quickly + if attached: + self._debug_print("[DAP] Processing remaining setup messages...") + additional_count = 0 + while additional_count < 4: # Just a few more + try: + self.channel.sock.settimeout(0.5) # Short timeout + message = self.channel.recv_message() + if message is None: + break + self._debug_print(f"[DAP] Setup message: {message.get('command')}") + self._handle_message(message) + additional_count += 1 + except: + break + finally: + self.channel.sock.settimeout(None) + + print(f"[DAP] Initial setup complete - main thread can continue") + + except Exception as e: + print(f"[DAP] Initialization error: {e}") + + def process_pending_messages(self): + """Process any pending DAP messages without blocking.""" + try: + # Set socket to non-blocking mode for message processing + self.channel.sock.settimeout(0.001) # Very short timeout + + while True: + message = self.channel.recv_message() + if message is None: + break + self._handle_message(message) + + except Exception: + # No messages available or socket error + pass + finally: + # Reset to blocking mode + self.channel.sock.settimeout(None) + + def _handle_message(self, message): + """Handle incoming DAP messages.""" + msg_type = message.get("type") + command = message.get("command", message.get("event", "unknown")) + seq = message.get("seq", 0) + + self._debug_print(f"[DAP] RECV: {msg_type} {command} (seq={seq})") + if message.get("arguments"): + self._debug_print(f"[DAP] args: {message['arguments']}") + + if msg_type == "request": + self._handle_request(message) + elif msg_type == "response": + # We don't expect responses from client + self._debug_print(f"[DAP] Unexpected response from client: {message}") + elif msg_type == "event": + # We don't expect events from client + self._debug_print(f"[DAP] Unexpected event from client: {message}") + + def _handle_request(self, message): + """Handle DAP request messages.""" + command = message.get("command") + seq = message.get("seq", 0) + args = message.get("arguments", {}) + + try: + if command == CMD_INITIALIZE: + self._handle_initialize(seq, args) + elif command == CMD_LAUNCH: + self._handle_launch(seq, args) + elif command == CMD_ATTACH: + self._handle_attach(seq, args) + elif command == CMD_SET_BREAKPOINTS: + self._handle_set_breakpoints(seq, args) + elif command == CMD_CONTINUE: + self._handle_continue(seq, args) + elif command == CMD_NEXT: + self._handle_next(seq, args) + elif command == CMD_STEP_IN: + self._handle_step_in(seq, args) + elif command == CMD_STEP_OUT: + self._handle_step_out(seq, args) + elif command == CMD_PAUSE: + self._handle_pause(seq, args) + elif command == CMD_STACK_TRACE: + self._handle_stack_trace(seq, args) + elif command == CMD_SCOPES: + self._handle_scopes(seq, args) + elif command == CMD_VARIABLES: + self._handle_variables(seq, args) + elif command == CMD_EVALUATE: + self._handle_evaluate(seq, args) + elif command == CMD_DISCONNECT: + self._handle_disconnect(seq, args) + elif command == CMD_CONFIGURATION_DONE: + self._handle_configuration_done(seq, args) + elif command == CMD_THREADS: + self._handle_threads(seq, args) + elif command == CMD_SOURCE: + self._handle_source(seq, args) + else: + self.channel.send_response(command, seq, success=False, + message=f"Unknown command: {command}") + + except Exception as e: + self.channel.send_response(command, seq, success=False, + message=str(e)) + + def _handle_initialize(self, seq, args): + """Handle initialize request.""" + capabilities = { + "supportsConfigurationDoneRequest": True, + "supportsFunctionBreakpoints": False, + "supportsConditionalBreakpoints": False, + "supportsHitConditionalBreakpoints": False, + "supportsEvaluateForHovers": True, + "supportsStepBack": False, + "supportsSetVariable": False, + "supportsRestartFrame": False, + "supportsGotoTargetsRequest": False, + "supportsStepInTargetsRequest": False, + "supportsCompletionsRequest": False, + "supportsModulesRequest": False, + "additionalModuleColumns": [], + "supportedChecksumAlgorithms": [], + "supportsRestartRequest": False, + "supportsExceptionOptions": False, + "supportsValueFormattingOptions": False, + "supportsExceptionInfoRequest": False, + "supportTerminateDebuggee": True, + "supportSuspendDebuggee": True, + "supportsDelayedStackTraceLoading": False, + "supportsLoadedSourcesRequest": False, + "supportsLogPoints": False, + "supportsTerminateThreadsRequest": False, + "supportsSetExpression": False, + "supportsTerminateRequest": True, + "supportsDataBreakpoints": False, + "supportsReadMemoryRequest": False, + "supportsWriteMemoryRequest": False, + "supportsDisassembleRequest": False, + "supportsCancelRequest": False, + "supportsBreakpointLocationsRequest": False, + "supportsClipboardContext": False, + } + + self.channel.send_response(CMD_INITIALIZE, seq, body=capabilities) + self.channel.send_event(EVENT_INITIALIZED) + self.initialized = True + + def _handle_launch(self, seq, args): + """Handle launch request.""" + # For attach-mode debugging, we don't need to launch anything + self.channel.send_response(CMD_LAUNCH, seq) + + def _handle_attach(self, seq, args): + """Handle attach request.""" + # Check if debug logging should be enabled + self.debug_logging = args.get("logToFile", False) + + self._debug_print(f"[DAP] Processing attach request with args: {args}") + print(f"[DAP] Debug logging {'enabled' if self.debug_logging else 'disabled'} (logToFile={self.debug_logging})") + + # Enable trace function + self.pdb.set_trace_function(self._trace_function) + self.channel.send_response(CMD_ATTACH, seq) + + # After successful attach, we might need to send additional events + # Some debuggers expect a 'process' event or thread events + self._debug_print("[DAP] Attach completed, debugging is now active") + + def _handle_set_breakpoints(self, seq, args): + """Handle setBreakpoints request.""" + source = args.get("source", {}) + filename = source.get("path", "") + breakpoints = args.get("breakpoints", []) + + # Set breakpoints in pdb adapter + actual_breakpoints = self.pdb.set_breakpoints(filename, breakpoints) + + self.channel.send_response(CMD_SET_BREAKPOINTS, seq, + body={"breakpoints": actual_breakpoints}) + + def _handle_continue(self, seq, args): + """Handle continue request.""" + self.stepping = False + self.paused = False + self.pdb.continue_execution() + self.channel.send_response(CMD_CONTINUE, seq) + + def _handle_next(self, seq, args): + """Handle next (step over) request.""" + self.stepping = True + self.paused = False + self.pdb.step_over() + self.channel.send_response(CMD_NEXT, seq) + + def _handle_step_in(self, seq, args): + """Handle stepIn request.""" + self.stepping = True + self.paused = False + self.pdb.step_into() + self.channel.send_response(CMD_STEP_IN, seq) + + def _handle_step_out(self, seq, args): + """Handle stepOut request.""" + self.stepping = True + self.paused = False + self.pdb.step_out() + self.channel.send_response(CMD_STEP_OUT, seq) + + def _handle_pause(self, seq, args): + """Handle pause request.""" + self.paused = True + self.pdb.pause() + self.channel.send_response(CMD_PAUSE, seq) + + def _handle_stack_trace(self, seq, args): + """Handle stackTrace request.""" + stack_frames = self.pdb.get_stack_trace() + self.channel.send_response(CMD_STACK_TRACE, seq, + body={"stackFrames": stack_frames, "totalFrames": len(stack_frames)}) + + def _handle_scopes(self, seq, args): + """Handle scopes request.""" + frame_id = args.get("frameId", 0) + self._debug_print(f"[DAP] Processing scopes request for frameId={frame_id}") + scopes = self.pdb.get_scopes(frame_id) + self._debug_print(f"[DAP] Generated scopes: {scopes}") + self.channel.send_response(CMD_SCOPES, seq, body={"scopes": scopes}) + + def _handle_variables(self, seq, args): + """Handle variables request.""" + variables_ref = args.get("variablesReference", 0) + variables = self.pdb.get_variables(variables_ref) + self.channel.send_response(CMD_VARIABLES, seq, body={"variables": variables}) + + def _handle_evaluate(self, seq, args): + """Handle evaluate request.""" + expression = args.get("expression", "") + frame_id = args.get("frameId") + context = args.get("context", "watch") + + try: + result = self.pdb.evaluate_expression(expression, frame_id) + self.channel.send_response(CMD_EVALUATE, seq, body={ + "result": str(result), + "variablesReference": 0 + }) + except Exception as e: + self.channel.send_response(CMD_EVALUATE, seq, success=False, + message=str(e)) + + def _handle_disconnect(self, seq, args): + """Handle disconnect request.""" + self.channel.send_response(CMD_DISCONNECT, seq) + self.disconnect() + + def _handle_configuration_done(self, seq, args): + """Handle configurationDone request.""" + # This indicates that the client has finished configuring breakpoints + # and is ready to start debugging + self.channel.send_response(CMD_CONFIGURATION_DONE, seq) + + def _handle_threads(self, seq, args): + """Handle threads request.""" + # MicroPython is single-threaded, so return one thread + threads = [{ + "id": self.thread_id, + "name": "main" + }] + self.channel.send_response(CMD_THREADS, seq, body={"threads": threads}) + + def _handle_source(self, seq, args): + """Handle source request.""" + source = args.get("source", {}) + source_path = source.get("path", "") + + try: + # Try to read the source file + with open(source_path, 'r') as f: + content = f.read() + self.channel.send_response(CMD_SOURCE, seq, body={"content": content}) + except Exception as e: + self.channel.send_response(CMD_SOURCE, seq, success=False, + message=f"Could not read source: {e}") + + def _trace_function(self, frame, event, arg): + """Trace function called by sys.settrace.""" + # Process any pending DAP messages frequently + self.process_pending_messages() + + # Handle breakpoints and stepping + if self.pdb.should_stop(frame, event, arg): + self._send_stopped_event(STOP_REASON_BREAKPOINT if self.pdb.hit_breakpoint else + STOP_REASON_STEP if self.stepping else STOP_REASON_PAUSE) + # Wait for continue command + self.pdb.wait_for_continue() + + return self._trace_function + + def _send_stopped_event(self, reason): + """Send stopped event to client.""" + self.channel.send_event(EVENT_STOPPED, + reason=reason, + threadId=self.thread_id, + allThreadsStopped=True) + + def wait_for_client(self): + """Wait for client to initialize.""" + # This is a simplified version - in a real implementation + # we might want to wait for specific initialization steps + pass + + def trigger_breakpoint(self): + """Trigger a manual breakpoint.""" + if self.initialized: + self._send_stopped_event(STOP_REASON_BREAKPOINT) + + def debug_this_thread(self): + """Enable debugging for current thread.""" + if hasattr(sys, 'settrace'): + sys.settrace(self._trace_function) + + def is_connected(self): + """Check if client is connected.""" + return self.connected and not self.channel.closed + + def disconnect(self): + """Disconnect from client.""" + self.connected = False + if hasattr(sys, 'settrace'): + sys.settrace(None) + self.pdb.cleanup() + self.channel.close() diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py new file mode 100644 index 000000000..83693c65c --- /dev/null +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -0,0 +1,285 @@ +"""PDB adapter for integrating with MicroPython's trace system.""" + +import sys +import time +from ..common.constants import ( + TRACE_CALL, TRACE_LINE, TRACE_RETURN, TRACE_EXCEPTION, + SCOPE_LOCALS, SCOPE_GLOBALS +) + + +class PdbAdapter: + """Adapter between DAP protocol and MicroPython's sys.settrace functionality.""" + + def __init__(self): + self.breakpoints = {} # filename -> {line_no: breakpoint_info} + self.current_frame = None + self.step_mode = None # None, 'over', 'into', 'out' + self.step_frame = None + self.step_depth = 0 + self.hit_breakpoint = False + self.continue_event = False + self.variables_cache = {} # frameId -> variables + self.frame_id_counter = 1 + + def _debug_print(self, message): + """Print debug message only if debug logging is enabled.""" + if hasattr(self, '_debug_session') and self._debug_session.debug_logging: + print(message) + + def set_trace_function(self, trace_func): + """Install the trace function.""" + if hasattr(sys, 'settrace'): + sys.settrace(trace_func) + else: + raise RuntimeError("sys.settrace not available") + + def set_breakpoints(self, filename, breakpoints): + """Set breakpoints for a file.""" + self.breakpoints[filename] = {} + actual_breakpoints = [] + + for bp in breakpoints: + line = bp.get("line") + if line: + self.breakpoints[filename][line] = { + "line": line, + "verified": True, + "source": {"path": filename} + } + actual_breakpoints.append({ + "line": line, + "verified": True, + "source": {"path": filename} + }) + + return actual_breakpoints + + def should_stop(self, frame, event, arg): + """Determine if execution should stop at this point.""" + self.current_frame = frame + self.hit_breakpoint = False + + # Get frame information + filename = frame.f_code.co_filename + lineno = frame.f_lineno + + # Debug: print filename and line for debugging + if event == TRACE_LINE and lineno in [20, 21, 22, 23, 24]: # Only log lines near our breakpoints + self._debug_print(f"[PDB] Checking {filename}:{lineno} (event={event})") + self._debug_print(f"[PDB] Available breakpoint files: {list(self.breakpoints.keys())}") + + # Check for exact filename match first + if filename in self.breakpoints: + if lineno in self.breakpoints[filename]: + self._debug_print(f"[PDB] HIT BREAKPOINT (exact match) at {filename}:{lineno}") + self.hit_breakpoint = True + return True + + # Also try checking by basename for path mismatches + def basename(path): + return path.split('/')[-1] if '/' in path else path + + file_basename = basename(filename) + self._debug_print(f"[PDB] Fallback basename match: '{file_basename}' vs available files") + for bp_file in self.breakpoints: + bp_basename = basename(bp_file) + self._debug_print(f"[PDB] Comparing '{file_basename}' == '{bp_basename}' ?") + if bp_basename == file_basename: + self._debug_print(f"[PDB] Basename match found! Checking line {lineno} in {list(self.breakpoints[bp_file].keys())}") + if lineno in self.breakpoints[bp_file]: + self._debug_print(f"[PDB] HIT BREAKPOINT (fallback basename match) at {filename}:{lineno} -> {bp_file}") + self.hit_breakpoint = True + return True + + # Check stepping + if self.step_mode == 'into': + if event in (TRACE_CALL, TRACE_LINE): + self.step_mode = None + return True + + elif self.step_mode == 'over': + if event == TRACE_LINE and frame == self.step_frame: + self.step_mode = None + return True + elif event == TRACE_RETURN and frame == self.step_frame: + # Continue stepping in caller + if hasattr(frame, 'f_back') and frame.f_back: + self.step_frame = frame.f_back + else: + self.step_mode = None + + elif self.step_mode == 'out': + if event == TRACE_RETURN and frame == self.step_frame: + self.step_mode = None + return True + + return False + + def continue_execution(self): + """Continue execution.""" + self.step_mode = None + self.continue_event = True + + def step_over(self): + """Step over (next line).""" + self.step_mode = 'over' + self.step_frame = self.current_frame + self.continue_event = True + + def step_into(self): + """Step into function calls.""" + self.step_mode = 'into' + self.continue_event = True + + def step_out(self): + """Step out of current function.""" + self.step_mode = 'out' + self.step_frame = self.current_frame + self.continue_event = True + + def pause(self): + """Pause execution at next opportunity.""" + # This is handled by the debug session + pass + + def wait_for_continue(self): + """Wait for continue command (simplified implementation).""" + # In a real implementation, this would block until continue + # For MicroPython, we'll use a simple polling approach + self.continue_event = False + + # Process DAP messages while waiting for continue + self._debug_print("[PDB] Waiting for continue command...") + while not self.continue_event: + # Process any pending DAP messages (scopes, variables, etc.) + if hasattr(self, '_debug_session'): + self._debug_session.process_pending_messages() + time.sleep(0.01) + + def get_stack_trace(self): + """Get the current stack trace.""" + if not self.current_frame: + return [] + + frames = [] + frame = self.current_frame + frame_id = 0 + + while frame: + filename = frame.f_code.co_filename + name = frame.f_code.co_name + line = frame.f_lineno + + # Create frame info + frames.append({ + "id": frame_id, + "name": name, + "source": {"path": filename}, + "line": line, + "column": 1, + "endLine": line, + "endColumn": 1 + }) + + # Cache frame for variable access + self.variables_cache[frame_id] = frame + + # MicroPython doesn't have f_back attribute + if hasattr(frame, 'f_back'): + frame = frame.f_back + else: + # Only return the current frame for MicroPython + break + frame_id += 1 + + return frames + + def get_scopes(self, frame_id): + """Get variable scopes for a frame.""" + scopes = [ + { + "name": "Locals", + "variablesReference": frame_id * 1000 + 1, + "expensive": False + }, + { + "name": "Globals", + "variablesReference": frame_id * 1000 + 2, + "expensive": False + } + ] + return scopes + + def get_variables(self, variables_ref): + """Get variables for a scope.""" + frame_id = variables_ref // 1000 + scope_type = variables_ref % 1000 + + if frame_id not in self.variables_cache: + return [] + + frame = self.variables_cache[frame_id] + variables = [] + + if scope_type == 1: # Locals + var_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} + elif scope_type == 2: # Globals + var_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} + else: + return [] + + for name, value in var_dict.items(): + # Skip private/internal variables + if name.startswith('__') and name.endswith('__'): + continue + + try: + value_str = str(value) + type_str = type(value).__name__ + + variables.append({ + "name": name, + "value": value_str, + "type": type_str, + "variablesReference": 0 # Simple implementation - no nested objects + }) + except Exception: + variables.append({ + "name": name, + "value": "", + "type": "unknown", + "variablesReference": 0 + }) + + return variables + + def evaluate_expression(self, expression, frame_id=None): + """Evaluate an expression in the context of a frame.""" + if frame_id is not None and frame_id in self.variables_cache: + frame = self.variables_cache[frame_id] + globals_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} + locals_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} + else: + # Use current frame + frame = self.current_frame + if frame: + globals_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} + locals_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} + else: + globals_dict = globals() + locals_dict = {} + + try: + # Evaluate the expression + result = eval(expression, globals_dict, locals_dict) + return result + except Exception as e: + raise Exception(f"Evaluation error: {e}") + + def cleanup(self): + """Clean up resources.""" + self.variables_cache.clear() + self.breakpoints.clear() + if hasattr(sys, 'settrace'): + sys.settrace(None) diff --git a/python-ecosys/debugpy/demo.py b/python-ecosys/debugpy/demo.py new file mode 100644 index 000000000..d5b3d0923 --- /dev/null +++ b/python-ecosys/debugpy/demo.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Simple demo of MicroPython debugpy functionality.""" + +import sys +sys.path.insert(0, '.') + +import debugpy + +def simple_function(a, b): + """A simple function to demonstrate debugging.""" + result = a + b + print(f"Computing {a} + {b} = {result}") + return result + +def main(): + print("MicroPython debugpy Demo") + print("========================") + print() + + # Demonstrate trace functionality + print("1. Testing trace functionality:") + + def trace_function(frame, event, arg): + if event == 'call': + print(f" -> Entering function: {frame.f_code.co_name}") + elif event == 'line': + print(f" -> Executing line {frame.f_lineno} in {frame.f_code.co_name}") + elif event == 'return': + print(f" -> Returning from {frame.f_code.co_name} with value: {arg}") + return trace_function + + # Enable tracing + sys.settrace(trace_function) + + # Execute traced function + result = simple_function(5, 3) + + # Disable tracing + sys.settrace(None) + + print(f"Result: {result}") + print() + + # Demonstrate debugpy components + print("2. Testing debugpy components:") + + # Test PDB adapter + from debugpy.server.pdb_adapter import PdbAdapter + pdb = PdbAdapter() + + # Set some mock breakpoints + breakpoints = pdb.set_breakpoints("demo.py", [{"line": 10}, {"line": 15}]) + print(f" Set breakpoints: {len(breakpoints)} breakpoints") + + # Test messaging + from debugpy.common.messaging import JsonMessageChannel + print(" JsonMessageChannel available") + + print() + print("3. debugpy is ready for VS Code integration!") + print(" To use with VS Code:") + print(" - Import debugpy in your script") + print(" - Call debugpy.listen() to start the debug server") + print(" - Connect VS Code using the 'Attach to MicroPython' configuration") + print(" - Set breakpoints and debug normally") + +if __name__ == "__main__": + main() diff --git a/python-ecosys/debugpy/development_guide.md b/python-ecosys/debugpy/development_guide.md new file mode 100644 index 000000000..94f06b420 --- /dev/null +++ b/python-ecosys/debugpy/development_guide.md @@ -0,0 +1,84 @@ +# Debugging MicroPython debugpy with VS Code + +## Method 1: Direct Connection with Enhanced Logging + +1. **Start MicroPython with enhanced logging:** + ```bash + ~/micropython2/ports/unix/build-standard/micropython test_vscode.py + ``` + + This will now show detailed DAP protocol messages like: + ``` + [DAP] RECV: request initialize (seq=1) + [DAP] args: {...} + [DAP] SEND: response initialize (req_seq=1, success=True) + ``` + +2. **Connect VS Code debugger:** + - Use the launch configuration in `.vscode/launch.json` + - Or manually attach to `127.0.0.1:5678` + +3. **Look for issues in the terminal output** - you'll see all DAP message exchanges + +## Method 2: Using DAP Monitor (Recommended for detailed analysis) + +1. **Start MicroPython debugpy server:** + ```bash + ~/micropython2/ports/unix/build-standard/micropython test_vscode.py + ``` + +2. **In another terminal, start the DAP monitor:** + ```bash + python3 dap_monitor.py + ``` + + The monitor listens on port 5679 and forwards to port 5678 + +3. **Connect VS Code to the monitor:** + - Modify your VS Code launch config to connect to port `5679` instead of `5678` + - Or create a new launch config: + ```json + { + "name": "Debug via Monitor", + "type": "python", + "request": "attach", + "connect": { + "host": "127.0.0.1", + "port": 5679 + } + } + ``` + +4. **Analyze the complete DAP conversation** in the monitor terminal + +## VS Code Debug Logging + +Enable VS Code's built-in DAP logging: + +1. **Open VS Code settings** (Ctrl+,) +2. **Search for:** `debug.console.verbosity` +3. **Set to:** `verbose` +4. **Also set:** `debug.allowBreakpointsEverywhere` to `true` + +## Common Issues to Look For + +1. **Missing required DAP capabilities** - check the `initialize` response +2. **Breakpoint verification failures** - look for `setBreakpoints` exchanges +3. **Thread/stack frame issues** - check `stackTrace` and `scopes` responses +4. **Evaluation problems** - monitor `evaluate` request/response pairs + +## Expected DAP Sequence + +A successful debug session should show this sequence: + +1. `initialize` request → response with capabilities +2. `initialized` event +3. `setBreakpoints` request → response with verified breakpoints +4. `configurationDone` request → response +5. `attach` request → response +6. When execution hits breakpoint: `stopped` event +7. `stackTrace` request → response with frames +8. `scopes` request → response with local/global scopes +9. `continue` request → response to resume + +If any step fails or is missing, that's where the issue lies. \ No newline at end of file diff --git a/python-ecosys/debugpy/manifest.py b/python-ecosys/debugpy/manifest.py new file mode 100644 index 000000000..6c4228298 --- /dev/null +++ b/python-ecosys/debugpy/manifest.py @@ -0,0 +1,6 @@ +metadata( + description="MicroPython implementation of debugpy for remote debugging", + version="0.1.0", +) + +package("debugpy") diff --git a/python-ecosys/debugpy/test_vscode.py b/python-ecosys/debugpy/test_vscode.py new file mode 100644 index 000000000..aca063baf --- /dev/null +++ b/python-ecosys/debugpy/test_vscode.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Test script for VS Code debugging with MicroPython debugpy.""" + +import sys +sys.path.insert(0, '.') + +import debugpy + +def fibonacci(n): + """Calculate fibonacci number (iterative for efficiency).""" + if n <= 1: + return n + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + +def debuggable_code(): + """The actual code we want to debug - wrapped in a function so sys.settrace will trace it.""" + print("Starting debuggable code...") + + # Test data - set breakpoint here (using smaller numbers to avoid slow fibonacci) + numbers = [3, 4, 5] + for i, num in enumerate(numbers): + print(f"Calculating fibonacci({num})...") + result = fibonacci(num) # <-- SET BREAKPOINT HERE (line 26) + print(f"fibonacci({num}) = {result}") + print(sys.implementation) + import machine + print(dir(machine)) + + # Test manual breakpoint + print("\nTriggering manual breakpoint...") + debugpy.breakpoint() + print("Manual breakpoint triggered!") + + print("Test completed successfully!") + +def main(): + print("MicroPython VS Code Debugging Test") + print("==================================") + + # Start debug server + try: + debugpy.listen() + print("Debug server attached on 127.0.0.1:5678") + print("Connecting back to VS Code debugger now...") + # print("Set a breakpoint on line 26: 'result = fibonacci(num)'") + # print("Press Enter to continue after connecting debugger...") + # try: + # input() + # except: + # pass + + # Enable debugging for this thread + debugpy.debug_this_thread() + + # Give VS Code a moment to set breakpoints after attach + print("\nGiving VS Code time to set breakpoints...") + import time + time.sleep(2) + + # Call the debuggable code function so it gets traced + debuggable_code() + + except KeyboardInterrupt: + print("\nTest interrupted by user") + except Exception as e: + print(f"Error: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/python-ecosys/debugpy/vscode_launch_example.json b/python-ecosys/debugpy/vscode_launch_example.json new file mode 100644 index 000000000..358f4d543 --- /dev/null +++ b/python-ecosys/debugpy/vscode_launch_example.json @@ -0,0 +1,22 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Micropython Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}/lib/micropython-lib/python-ecosys/debugpy", + "remoteRoot": "." + } + ], + // "logToFile": true, + "justMyCode": false + } + ] +} \ No newline at end of file From 9adb8862608210bb79a632c70d4d68c56c56981e Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Thu, 12 Jun 2025 17:37:23 +0200 Subject: [PATCH 02/31] test_vscode: Add global variables to show vaiable tracking and hover. Signed-off-by: Jos Verlinde --- python-ecosys/debugpy/test_vscode.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/python-ecosys/debugpy/test_vscode.py b/python-ecosys/debugpy/test_vscode.py index aca063baf..2dca82d34 100644 --- a/python-ecosys/debugpy/test_vscode.py +++ b/python-ecosys/debugpy/test_vscode.py @@ -2,10 +2,14 @@ """Test script for VS Code debugging with MicroPython debugpy.""" import sys + sys.path.insert(0, '.') import debugpy +foo = 42 +bar = "Hello, MicroPython!" + def fibonacci(n): """Calculate fibonacci number (iterative for efficiency).""" if n <= 1: @@ -17,6 +21,7 @@ def fibonacci(n): def debuggable_code(): """The actual code we want to debug - wrapped in a function so sys.settrace will trace it.""" + global foo print("Starting debuggable code...") # Test data - set breakpoint here (using smaller numbers to avoid slow fibonacci) @@ -24,6 +29,7 @@ def debuggable_code(): for i, num in enumerate(numbers): print(f"Calculating fibonacci({num})...") result = fibonacci(num) # <-- SET BREAKPOINT HERE (line 26) + foo += result # Modify foo to see if it gets traced print(f"fibonacci({num}) = {result}") print(sys.implementation) import machine From 3ed2d89a86add35c099013d9179b45d76d04dc9e Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Thu, 12 Jun 2025 17:38:44 +0200 Subject: [PATCH 03/31] debugpy: Improve variable retrievals. Signed-off-by: Jos Verlinde --- python-ecosys/debugpy/debugpy/server/debug_session.py | 5 ++++- python-ecosys/debugpy/debugpy/server/pdb_adapter.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 4f60ee358..624467522 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -328,7 +328,10 @@ def _handle_evaluate(self, seq, args): expression = args.get("expression", "") frame_id = args.get("frameId") context = args.get("context", "watch") - + if not expression: + self.channel.send_response(CMD_EVALUATE, seq, success=False, + message="No expression provided") + return try: result = self.pdb.evaluate_expression(expression, frame_id) self.channel.send_response(CMD_EVALUATE, seq, body={ diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 83693c65c..a33cf6655 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -235,7 +235,7 @@ def get_variables(self, variables_ref): continue try: - value_str = str(value) + value_str = repr(value) type_str = type(value).__name__ variables.append({ From c4202e42da0493693472a2baa0eacc8f2e82f047 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Thu, 12 Jun 2025 22:42:35 +0200 Subject: [PATCH 04/31] dap_monitor: Exit session on debugger disconnect. Signed-off-by: Jos Verlinde --- python-ecosys/debugpy/dap_monitor.py | 83 ++++++++++++++++------------ 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/python-ecosys/debugpy/dap_monitor.py b/python-ecosys/debugpy/dap_monitor.py index 3af4eba16..b323a61cb 100644 --- a/python-ecosys/debugpy/dap_monitor.py +++ b/python-ecosys/debugpy/dap_monitor.py @@ -9,6 +9,7 @@ class DAPMonitor: def __init__(self, listen_port=5679, target_host='127.0.0.1', target_port=5678): + self.disconnect = False self.listen_port = listen_port self.target_host = target_host self.target_port = target_port @@ -44,9 +45,9 @@ def start(self): threading.Thread(target=self.forward_server_to_client, daemon=True).start() print("DAP Monitor active - press Ctrl+C to stop") - while True: + while not self.disconnect: time.sleep(1) - + except KeyboardInterrupt: print("\nStopping DAP Monitor...") except Exception as e: @@ -106,43 +107,55 @@ def receive_dap_message(self, sock, source): return None content += chunk - # Log the message - try: - message = json.loads(content.decode('utf-8')) - msg_type = message.get('type', 'unknown') - command = message.get('command', message.get('event', 'unknown')) - seq = message.get('seq', 0) - - print(f"\n[{source}] {msg_type.upper()}: {command} (seq={seq})") - - if msg_type == 'request': - args = message.get('arguments', {}) - if args: - print(f" Arguments: {json.dumps(args, indent=2)}") - elif msg_type == 'response': - success = message.get('success', False) - req_seq = message.get('request_seq', 0) - print(f" Success: {success}, Request Seq: {req_seq}") - body = message.get('body') - if body: - print(f" Body: {json.dumps(body, indent=2)}") - msg = message.get('message') - if msg: - print(f" Message: {msg}") - elif msg_type == 'event': - body = message.get('body', {}) - if body: - print(f" Body: {json.dumps(body, indent=2)}") - - except json.JSONDecodeError: - print(f"\n[{source}] Invalid JSON: {content}") - + # Parse and Log the message + message = self.parse_dap(source, content) + self.log_dap_message(source, message) + # Check for disconnect command + if message: + if "disconnect" == message.get('command', message.get('event', 'unknown')): + print(f"\n[{source}] Disconnect command received, stopping monitor.") + self.disconnect = True return header + content - except Exception as e: print(f"Error receiving from {source}: {e}") return None - + + def parse_dap(self, source, content): + """Parse DAP message and log it.""" + try: + message = json.loads(content.decode('utf-8')) + return message + except json.JSONDecodeError: + print(f"\n[{source}] Invalid JSON: {content}") + return None + + def log_dap_message(self, source, message): + """Log DAP message details.""" + msg_type = message.get('type', 'unknown') + command = message.get('command', message.get('event', 'unknown')) + seq = message.get('seq', 0) + + print(f"\n[{source}] {msg_type.upper()}: {command} (seq={seq})") + + if msg_type == 'request': + args = message.get('arguments', {}) + if args: + print(f" Arguments: {json.dumps(args, indent=2)}") + elif msg_type == 'response': + success = message.get('success', False) + req_seq = message.get('request_seq', 0) + print(f" Success: {success}, Request Seq: {req_seq}") + body = message.get('body') + if body: + print(f" Body: {json.dumps(body, indent=2)}") + msg = message.get('message') + if msg: + print(f" Message: {msg}") + elif msg_type == 'event': + body = message.get('body', {}) + if body: + print(f" Body: {json.dumps(body, indent=2)}") + def send_raw_data(self, sock, data): """Send raw data to socket.""" try: From 5d491e0227a7d5440822be23a46c7c2018eb8a0e Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Mon, 16 Jun 2025 12:12:30 +1000 Subject: [PATCH 05/31] debugpy: Fix VS Code path mapping to prevent read-only file copies. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When breakpoints are hit, VS Code was opening read-only copies of source files instead of the original workspace files due to path mismatches between VS Code's absolute paths and MicroPython's runtime paths. Changes: - Add path mapping dictionary to track VS Code path <-> runtime path relationships - Enhance breakpoint matching to handle relative paths and basename matches - Update stack trace reporting to use mapped VS Code paths - Add debug logging for path mapping diagnostics - Fix VS Code launch configuration (debugpy -> python, enable logging) This ensures VS Code correctly opens the original editable source files when debugging, rather than creating read-only temporary copies. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Andrew Leech --- .../debugpy/debugpy/server/debug_session.py | 3 ++ .../debugpy/debugpy/server/pdb_adapter.py | 50 ++++++++++++++++++- .../debugpy/vscode_launch_example.json | 8 +-- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 624467522..3a1a5135d 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -263,6 +263,9 @@ def _handle_set_breakpoints(self, seq, args): filename = source.get("path", "") breakpoints = args.get("breakpoints", []) + # Debug log the source information + self._debug_print(f"[DAP] setBreakpoints source info: {source}") + # Set breakpoints in pdb adapter actual_breakpoints = self.pdb.set_breakpoints(filename, breakpoints) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index a33cf6655..204862073 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -2,6 +2,7 @@ import sys import time +import os from ..common.constants import ( TRACE_CALL, TRACE_LINE, TRACE_RETURN, TRACE_EXCEPTION, SCOPE_LOCALS, SCOPE_GLOBALS @@ -21,11 +22,27 @@ def __init__(self): self.continue_event = False self.variables_cache = {} # frameId -> variables self.frame_id_counter = 1 + self.path_mapping = {} # runtime_path -> vscode_path mapping def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" if hasattr(self, '_debug_session') and self._debug_session.debug_logging: print(message) + + def _normalize_path(self, path): + """Normalize a file path for consistent comparisons.""" + # Convert to absolute path if possible + try: + if hasattr(os.path, 'abspath'): + path = os.path.abspath(path) + elif hasattr(os.path, 'realpath'): + path = os.path.realpath(path) + except: + pass + + # Ensure consistent separators + path = path.replace('\\', '/') + return path def set_trace_function(self, trace_func): """Install the trace function.""" @@ -39,6 +56,9 @@ def set_breakpoints(self, filename, breakpoints): self.breakpoints[filename] = {} actual_breakpoints = [] + # Debug log the breakpoint path + self._debug_print(f"[PDB] Setting breakpoints for file: {filename}") + for bp in breakpoints: line = bp.get("line") if line: @@ -73,12 +93,23 @@ def should_stop(self, frame, event, arg): if filename in self.breakpoints: if lineno in self.breakpoints[filename]: self._debug_print(f"[PDB] HIT BREAKPOINT (exact match) at {filename}:{lineno}") + # Record the path mapping (in this case, they're already the same) + self.path_mapping[filename] = filename self.hit_breakpoint = True return True # Also try checking by basename for path mismatches def basename(path): return path.split('/')[-1] if '/' in path else path + + # Check if this might be a relative path match + def ends_with_path(full_path, relative_path): + """Check if full_path ends with relative_path components.""" + full_parts = full_path.replace('\\', '/').split('/') + rel_parts = relative_path.replace('\\', '/').split('/') + if len(rel_parts) > len(full_parts): + return False + return full_parts[-len(rel_parts):] == rel_parts file_basename = basename(filename) self._debug_print(f"[PDB] Fallback basename match: '{file_basename}' vs available files") @@ -89,6 +120,18 @@ def basename(path): self._debug_print(f"[PDB] Basename match found! Checking line {lineno} in {list(self.breakpoints[bp_file].keys())}") if lineno in self.breakpoints[bp_file]: self._debug_print(f"[PDB] HIT BREAKPOINT (fallback basename match) at {filename}:{lineno} -> {bp_file}") + # Record the path mapping so we can report the correct path in stack traces + self.path_mapping[filename] = bp_file + self.hit_breakpoint = True + return True + + # Also check if the runtime path might be relative and the breakpoint path absolute + if ends_with_path(bp_file, filename): + self._debug_print(f"[PDB] Relative path match: {bp_file} ends with {filename}") + if lineno in self.breakpoints[bp_file]: + self._debug_print(f"[PDB] HIT BREAKPOINT (relative path match) at {filename}:{lineno} -> {bp_file}") + # Record the path mapping so we can report the correct path in stack traces + self.path_mapping[filename] = bp_file self.hit_breakpoint = True return True @@ -171,11 +214,16 @@ def get_stack_trace(self): name = frame.f_code.co_name line = frame.f_lineno + # Use the VS Code path if we have a mapping, otherwise use the original path + display_path = self.path_mapping.get(filename, filename) + if filename != display_path: + self._debug_print(f"[PDB] Stack trace path mapping: {filename} -> {display_path}") + # Create frame info frames.append({ "id": frame_id, "name": name, - "source": {"path": filename}, + "source": {"path": display_path}, "line": line, "column": 1, "endLine": line, diff --git a/python-ecosys/debugpy/vscode_launch_example.json b/python-ecosys/debugpy/vscode_launch_example.json index 358f4d543..388e696bd 100644 --- a/python-ecosys/debugpy/vscode_launch_example.json +++ b/python-ecosys/debugpy/vscode_launch_example.json @@ -2,8 +2,8 @@ "version": "0.2.0", "configurations": [ { - "name": "Micropython Attach", - "type": "debugpy", + "name": "Attach to MicroPython", + "type": "python", "request": "attach", "connect": { "host": "localhost", @@ -11,11 +11,11 @@ }, "pathMappings": [ { - "localRoot": "${workspaceFolder}/lib/micropython-lib/python-ecosys/debugpy", + "localRoot": "${workspaceFolder}", "remoteRoot": "." } ], - // "logToFile": true, + "logToFile": true, "justMyCode": false } ] From c35c6becc83d668c48d472a51b52df7cc4104a71 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Thu, 19 Jun 2025 01:02:47 +0200 Subject: [PATCH 06/31] debugpy: Enhance PDB adapter with special variable processing. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/pdb_adapter.py | 123 ++++++++++++------ 1 file changed, 84 insertions(+), 39 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 204862073..c05aec615 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -3,10 +3,15 @@ import sys import time import os +import json from ..common.constants import ( TRACE_CALL, TRACE_LINE, TRACE_RETURN, TRACE_EXCEPTION, SCOPE_LOCALS, SCOPE_GLOBALS ) +VARREF_LOCALS = 1 +VARREF_GLOBALS = 2 +VARREF_LOCALS_SPECIAL = 3 +VARREF_GLOBALS_SPECIAL = 4 class PdbAdapter: @@ -26,7 +31,7 @@ def __init__(self): def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" - if hasattr(self, '_debug_session') and self._debug_session.debug_logging: + if hasattr(self, '_debug_session') and self._debug_session.debug_logging: # type: ignore print(message) def _normalize_path(self, path): @@ -197,7 +202,7 @@ def wait_for_continue(self): while not self.continue_event: # Process any pending DAP messages (scopes, variables, etc.) if hasattr(self, '_debug_session'): - self._debug_session.process_pending_messages() + self._debug_session.process_pending_messages() # type: ignore time.sleep(0.01) def get_stack_trace(self): @@ -213,21 +218,25 @@ def get_stack_trace(self): filename = frame.f_code.co_filename name = frame.f_code.co_name line = frame.f_lineno - + if "" in filename or filename.endswith("debugpy.py") : + hint = 'subtle' + else : + hint = 'normal' + # Use the VS Code path if we have a mapping, otherwise use the original path display_path = self.path_mapping.get(filename, filename) if filename != display_path: self._debug_print(f"[PDB] Stack trace path mapping: {filename} -> {display_path}") - - # Create frame info + # Create StackFrame info frames.append({ "id": frame_id, - "name": name, + "name": name + f" {type(frame.f_code.co_filename).__name__}", "source": {"path": display_path}, "line": line, "column": 1, "endLine": line, - "endColumn": 1 + "endColumn": 1, + "presentationHint": hint }) # Cache frame for variable access @@ -248,60 +257,97 @@ def get_scopes(self, frame_id): scopes = [ { "name": "Locals", - "variablesReference": frame_id * 1000 + 1, + "variablesReference": frame_id * 1000 + VARREF_LOCALS, "expensive": False }, { "name": "Globals", - "variablesReference": frame_id * 1000 + 2, + "variablesReference": frame_id * 1000 + VARREF_GLOBALS , "expensive": False } ] return scopes - def get_variables(self, variables_ref): - """Get variables for a scope.""" - frame_id = variables_ref // 1000 - scope_type = variables_ref % 1000 - - if frame_id not in self.variables_cache: - return [] - - frame = self.variables_cache[frame_id] + def _process_special_variables(self, var_dict): + """Process special variables (those starting and ending with __).""" + variables = [] + for name, value in var_dict.items(): + if name.startswith('__') and name.endswith('__'): + try: + value_str = json.dumps(value) + type_str = type(value).__name__ + variables.append({ + "name": name, + "value": value_str, + "type": type_str, + "variablesReference": 0 + }) + except Exception: + variables.append(self._var_error(name)) + return variables + + def _process_regular_variables(self, var_dict): + """Process regular variables (excluding special ones).""" variables = [] - - if scope_type == 1: # Locals - var_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} - elif scope_type == 2: # Globals - var_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} - else: - return [] - for name, value in var_dict.items(): # Skip private/internal variables if name.startswith('__') and name.endswith('__'): continue - try: - value_str = repr(value) + value_str = json.dumps(value) type_str = type(value).__name__ - variables.append({ "name": name, "value": value_str, "type": type_str, - "variablesReference": 0 # Simple implementation - no nested objects - }) - except Exception: - variables.append({ - "name": name, - "value": "", - "type": "unknown", "variablesReference": 0 }) - + except Exception: + variables.append(self._var_error(name)) return variables + + @staticmethod + def _var_error(name:str): + return {"name": name, "value": "", "type": "unknown", "variablesReference": 0 } + + @staticmethod + def _special_vars(varref:int): + return {"name": "Special", "value": "", "variablesReference": varref} + + def get_variables(self, variables_ref): + """Get variables for a scope.""" + frame_id = variables_ref // 1000 + scope_type = variables_ref % 1000 + + if frame_id not in self.variables_cache: + return [] + + frame = self.variables_cache[frame_id] + # Handle special scope types first + if scope_type == VARREF_LOCALS_SPECIAL: + var_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} + return self._process_special_variables(var_dict) + elif scope_type == VARREF_GLOBALS_SPECIAL: + var_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} + return self._process_special_variables(var_dict) + + # Handle regular scope types with special folder + variables = [] + if scope_type == VARREF_LOCALS: + var_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} + variables.append(self._special_vars( VARREF_LOCALS_SPECIAL)) + elif scope_type == VARREF_GLOBALS: + var_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} + variables.append(self._special_vars( VARREF_GLOBALS_SPECIAL)) + else: + # Invalid reference, return empty + return [] + + # Add regular variables + variables.extend(self._process_regular_variables(var_dict)) + return variables + def evaluate_expression(self, expression, frame_id=None): """Evaluate an expression in the context of a frame.""" if frame_id is not None and frame_id in self.variables_cache: @@ -317,14 +363,13 @@ def evaluate_expression(self, expression, frame_id=None): else: globals_dict = globals() locals_dict = {} - try: # Evaluate the expression result = eval(expression, globals_dict, locals_dict) return result except Exception as e: raise Exception(f"Evaluation error: {e}") - + def cleanup(self): """Clean up resources.""" self.variables_cache.clear() From 5d081a579d2f571328a491e86b3f08b3765236ed Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Thu, 19 Jun 2025 02:13:14 +0200 Subject: [PATCH 07/31] debugpy/dap_monitor: Add cli for target and ports. Signed-off-by: Jos Verlinde --- python-ecosys/debugpy/dap_monitor.py | 14 +++++++++++++- .../debugpy/debugpy/server/pdb_adapter.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/python-ecosys/debugpy/dap_monitor.py b/python-ecosys/debugpy/dap_monitor.py index b323a61cb..20ab90800 100644 --- a/python-ecosys/debugpy/dap_monitor.py +++ b/python-ecosys/debugpy/dap_monitor.py @@ -6,6 +6,7 @@ import json import time import sys +import argparse class DAPMonitor: def __init__(self, listen_port=5679, target_host='127.0.0.1', target_port=5678): @@ -171,5 +172,16 @@ def cleanup(self): self.server_sock.close() if __name__ == "__main__": - monitor = DAPMonitor() + + parser = argparse.ArgumentParser(description="DAP protocol monitor proxy") + parser.add_argument("--target-host", "--th", default="127.0.0.1", help="Target debugpy host (default: 127.0.0.1)") + parser.add_argument("--target-port", "--tp", type=int, default=5678, help="Target debugpy port (default: 5678)") + parser.add_argument("--listen-port", "--lp", type=int, default=5679, help="Port to listen for VS Code (default: 5679)") + args = parser.parse_args() + + monitor = DAPMonitor( + listen_port=args.listen_port, + target_host=args.target_host, + target_port=args.target_port + ) monitor.start() \ No newline at end of file diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index c05aec615..524574b2b 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -230,7 +230,7 @@ def get_stack_trace(self): # Create StackFrame info frames.append({ "id": frame_id, - "name": name + f" {type(frame.f_code.co_filename).__name__}", + "name": name, "source": {"path": display_path}, "line": line, "column": 1, From f5d2977aac6b0251295010e5ec920c910c6a20cd Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Thu, 19 Jun 2025 13:07:46 +0200 Subject: [PATCH 08/31] debugpy/debug_session: Detect bare metal ports. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/debug_session.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 3a1a5135d..9b0f8edbe 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -31,6 +31,10 @@ def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" if self.debug_logging: print(message) + + @property + def _baremetal(self) -> bool: + return sys.platform not in ("linux") # to be expanded def start(self): """Start the debug session message loop.""" @@ -369,15 +373,23 @@ def _handle_source(self, seq, args): """Handle source request.""" source = args.get("source", {}) source_path = source.get("path", "") - + if self._baremetal or not source_path: + # BUGBUG: unable to read the source on ESP32 + # Possible an effect of the import / inialization sequence ? + # Nothe that other source files ( other.py) do not seem to get requested in the same way + self.channel.send_response(CMD_SOURCE, seq, success=False) + return + self._debug_print(f"[DAP] Processing source request for path: {source}") try: # Try to read the source file - with open(source_path, 'r') as f: + with open(source_path) as f: content = f.read() self.channel.send_response(CMD_SOURCE, seq, body={"content": content}) except Exception as e: self.channel.send_response(CMD_SOURCE, seq, success=False, - message=f"Could not read source: {e}") + message="cancelled" + # message=f"Could not read source: {e}" + ) def _trace_function(self, frame, event, arg): """Trace function called by sys.settrace.""" From 19883c1b5bf51ff01595d14385079f4a0d8f51a8 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Thu, 19 Jun 2025 13:15:16 +0200 Subject: [PATCH 09/31] debugpy : Format code. Signed-off-by: Jos Verlinde --- python-ecosys/debugpy/dap_monitor.py | 32 ++-- python-ecosys/debugpy/debugpy/__init__.py | 8 +- .../debugpy/debugpy/common/messaging.py | 42 ++--- python-ecosys/debugpy/debugpy/public_api.py | 22 +-- .../debugpy/debugpy/server/debug_session.py | 143 +++++++++--------- .../debugpy/debugpy/server/pdb_adapter.py | 85 +++++------ python-ecosys/debugpy/demo.py | 24 +-- python-ecosys/debugpy/test_vscode.py | 18 +-- 8 files changed, 186 insertions(+), 188 deletions(-) diff --git a/python-ecosys/debugpy/dap_monitor.py b/python-ecosys/debugpy/dap_monitor.py index 20ab90800..93d02ddf7 100644 --- a/python-ecosys/debugpy/dap_monitor.py +++ b/python-ecosys/debugpy/dap_monitor.py @@ -16,35 +16,35 @@ def __init__(self, listen_port=5679, target_host='127.0.0.1', target_port=5678): self.target_port = target_port self.client_sock = None self.server_sock = None - + def start(self): """Start the DAP monitor proxy.""" print(f"DAP Monitor starting on port {self.listen_port}") print(f"Will forward to {self.target_host}:{self.target_port}") print("Start MicroPython debugpy server first, then connect VS Code to port 5679") - + # Create listening socket listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(('127.0.0.1', self.listen_port)) listener.listen(1) - + print(f"Listening for VS Code connection on port {self.listen_port}...") - + try: # Wait for VS Code to connect self.client_sock, client_addr = listener.accept() print(f"VS Code connected from {client_addr}") - + # Connect to MicroPython debugpy server self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server_sock.connect((self.target_host, self.target_port)) print(f"Connected to MicroPython debugpy at {self.target_host}:{self.target_port}") - + # Start forwarding threads threading.Thread(target=self.forward_client_to_server, daemon=True).start() threading.Thread(target=self.forward_server_to_client, daemon=True).start() - + print("DAP Monitor active - press Ctrl+C to stop") while not self.disconnect: time.sleep(1) @@ -55,7 +55,7 @@ def start(self): print(f"Error: {e}") finally: self.cleanup() - + def forward_client_to_server(self): """Forward messages from VS Code client to MicroPython server.""" try: @@ -66,7 +66,7 @@ def forward_client_to_server(self): self.send_raw_data(self.server_sock, data) except Exception as e: print(f"Client->Server forwarding error: {e}") - + def forward_server_to_client(self): """Forward messages from MicroPython server to VS Code client.""" try: @@ -77,7 +77,7 @@ def forward_server_to_client(self): self.send_raw_data(self.client_sock, data) except Exception as e: print(f"Server->Client forwarding error: {e}") - + def receive_dap_message(self, sock, source): """Receive and log a DAP message.""" try: @@ -88,7 +88,7 @@ def receive_dap_message(self, sock, source): if not byte: return None header += byte - + # Parse content length header_str = header.decode('utf-8') content_length = 0 @@ -96,10 +96,10 @@ def receive_dap_message(self, sock, source): if line.startswith('Content-Length:'): content_length = int(line.split(':', 1)[1].strip()) break - + if content_length == 0: return None - + # Read content content = b"" while len(content) < content_length: @@ -107,7 +107,7 @@ def receive_dap_message(self, sock, source): if not chunk: return None content += chunk - + # Parse and Log the message message = self.parse_dap(source, content) self.log_dap_message(source, message) @@ -163,7 +163,7 @@ def send_raw_data(self, sock, data): sock.send(data) except Exception as e: print(f"Error sending data: {e}") - + def cleanup(self): """Clean up sockets.""" if self.client_sock: @@ -184,4 +184,4 @@ def cleanup(self): target_host=args.target_host, target_port=args.target_port ) - monitor.start() \ No newline at end of file + monitor.start() diff --git a/python-ecosys/debugpy/debugpy/__init__.py b/python-ecosys/debugpy/debugpy/__init__.py index b7649bd5c..3912a49a5 100644 --- a/python-ecosys/debugpy/debugpy/__init__.py +++ b/python-ecosys/debugpy/debugpy/__init__.py @@ -11,10 +11,10 @@ from .common.constants import DEFAULT_HOST, DEFAULT_PORT __all__ = [ - "listen", - "wait_for_client", - "breakpoint", - "debug_this_thread", "DEFAULT_HOST", "DEFAULT_PORT", + "breakpoint", + "debug_this_thread", + "listen", + "wait_for_client", ] diff --git a/python-ecosys/debugpy/debugpy/common/messaging.py b/python-ecosys/debugpy/debugpy/common/messaging.py index bc264e3ff..7a588bab3 100644 --- a/python-ecosys/debugpy/debugpy/common/messaging.py +++ b/python-ecosys/debugpy/debugpy/common/messaging.py @@ -6,25 +6,25 @@ class JsonMessageChannel: """Handles JSON message communication over a socket using DAP format.""" - + def __init__(self, sock, debug_callback=None): self.sock = sock self.seq = 0 self.closed = False self._recv_buffer = b"" self._debug_print = debug_callback or (lambda x: None) # Default to no-op - + def send_message(self, msg_type, command=None, **kwargs): """Send a DAP message.""" if self.closed: return - + self.seq += 1 message = { "seq": self.seq, "type": msg_type, } - + if command: if msg_type == MSG_TYPE_REQUEST: message["command"] = command @@ -42,20 +42,20 @@ def send_message(self, msg_type, command=None, **kwargs): message["event"] = command if kwargs: message["body"] = kwargs - + json_str = json.dumps(message) content = json_str.encode("utf-8") header = f"Content-Length: {len(content)}\r\n\r\n".encode("utf-8") - + try: self.sock.send(header + content) except OSError: self.closed = True - + def send_request(self, command, **kwargs): """Send a request message.""" self.send_message(MSG_TYPE_REQUEST, command, **kwargs) - + def send_response(self, command, request_seq, success=True, body=None, message=None): """Send a response message.""" kwargs = {"request_seq": request_seq, "success": success} @@ -63,27 +63,27 @@ def send_response(self, command, request_seq, success=True, body=None, message=N kwargs["body"] = body if message is not None: kwargs["message"] = message - + self._debug_print(f"[DAP] SEND: response {command} (req_seq={request_seq}, success={success})") if body: self._debug_print(f"[DAP] body: {body}") if message: self._debug_print(f"[DAP] message: {message}") - + self.send_message(MSG_TYPE_RESPONSE, command, **kwargs) - + def send_event(self, event, **kwargs): """Send an event message.""" self._debug_print(f"[DAP] SEND: event {event}") if kwargs: self._debug_print(f"[DAP] body: {kwargs}") self.send_message(MSG_TYPE_EVENT, event, **kwargs) - + def recv_message(self): """Receive a DAP message.""" if self.closed: return None - + try: # Read headers while b"\r\n\r\n" not in self._recv_buffer: @@ -99,21 +99,21 @@ def recv_message(self): return None # No data available self.closed = True return None - + header_end = self._recv_buffer.find(b"\r\n\r\n") header_str = self._recv_buffer[:header_end].decode("utf-8") self._recv_buffer = self._recv_buffer[header_end + 4:] - + # Parse Content-Length content_length = 0 for line in header_str.split("\r\n"): if line.startswith("Content-Length:"): content_length = int(line.split(":", 1)[1].strip()) break - + if content_length == 0: return None - + # Read body while len(self._recv_buffer) < content_length: try: @@ -127,10 +127,10 @@ def recv_message(self): return None self.closed = True return None - + body = self._recv_buffer[:content_length] self._recv_buffer = self._recv_buffer[content_length:] - + # Parse JSON try: message = json.loads(body.decode("utf-8")) @@ -139,12 +139,12 @@ def recv_message(self): except (ValueError, UnicodeDecodeError) as e: print(f"[DAP] JSON parse error: {e}") return None - + except OSError as e: print(f"[DAP] Socket error in recv_message: {e}") self.closed = True return None - + def close(self): """Close the channel.""" self.closed = True diff --git a/python-ecosys/debugpy/debugpy/public_api.py b/python-ecosys/debugpy/debugpy/public_api.py index 137706efe..8642be989 100644 --- a/python-ecosys/debugpy/debugpy/public_api.py +++ b/python-ecosys/debugpy/debugpy/public_api.py @@ -19,35 +19,35 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): (host, port) tuple of the actual listening address """ global _debug_session - + if _debug_session is not None: raise RuntimeError("Already listening for debugger") - + # Create listening socket listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) except: pass # Not supported in MicroPython - + # Use getaddrinfo for MicroPython compatibility addr_info = socket.getaddrinfo(host, port) addr = addr_info[0][-1] # Get the sockaddr listener.bind(addr) listener.listen(1) - + # getsockname not available in MicroPython, use original values print(f"Debugpy listening on {host}:{port}") - + # Wait for connection client_sock = None try: client_sock, client_addr = listener.accept() print(f"Debugger connected from {client_addr}") - + # Create debug session _debug_session = DebugSession(client_sock) - + # Handle just the initialize request, then return immediately print("[DAP] Waiting for initialize request...") init_message = _debug_session.channel.recv_message() @@ -56,12 +56,12 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): print("[DAP] Initialize request handled - returning control immediately") else: print(f"[DAP] Warning: Expected initialize, got {init_message}") - + # Set socket to non-blocking for subsequent message processing _debug_session.channel.sock.settimeout(0.001) - + print("[DAP] Debug session ready - all other messages will be handled in trace function") - + except Exception as e: print(f"[DAP] Connection error: {e}") if client_sock: @@ -70,7 +70,7 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): finally: # Only close the listener, not the client connection listener.close() - + return (host, port) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 9b0f8edbe..43e2d442c 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -15,7 +15,7 @@ class DebugSession: """Manages a debugging session with a DAP client.""" - + def __init__(self, client_socket): self.debug_logging = False # Initialize first self.channel = JsonMessageChannel(client_socket, self._debug_print) @@ -26,7 +26,7 @@ def __init__(self, client_socket): self.thread_id = 1 # Simple single-thread model self.stepping = False self.paused = False - + def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" if self.debug_logging: @@ -34,8 +34,8 @@ def _debug_print(self, message): @property def _baremetal(self) -> bool: - return sys.platform not in ("linux") # to be expanded - + return sys.platform not in ("linux") # to be expanded + def start(self): """Start the debug session message loop.""" try: @@ -43,51 +43,51 @@ def start(self): message = self.channel.recv_message() if message is None: break - + self._handle_message(message) - + except Exception as e: print(f"Debug session error: {e}") finally: self.disconnect() - + def initialize_connection(self): """Initialize the connection - handle just the essential initial messages then return.""" # Note: debug_logging not available yet during init, so we always show these messages print("[DAP] Processing initial DAP messages...") - + try: # Process initial messages quickly and return control to main thread # We'll handle ongoing messages in the trace function attached = False message_count = 0 max_init_messages = 6 # Just handle the first few essential messages - + while message_count < max_init_messages and not attached: try: # Short timeout - don't block the main thread for long self.channel.sock.settimeout(1.0) message = self.channel.recv_message() if message is None: - print(f"[DAP] No more messages in initial batch") + print("[DAP] No more messages in initial batch") break - + print(f"[DAP] Initial message #{message_count + 1}: {message.get('command')}") self._handle_message(message) message_count += 1 - + # Just wait for attach, then we can return control if message.get('command') == 'attach': attached = True print("[DAP] ✅ Attach received - returning control to main thread") break - + except Exception as e: print(f"[DAP] Exception in initial processing: {e}") break finally: self.channel.sock.settimeout(None) - + # After attach, continue processing a few more messages quickly if attached: self._debug_print("[DAP] Processing remaining setup messages...") @@ -105,41 +105,41 @@ def initialize_connection(self): break finally: self.channel.sock.settimeout(None) - - print(f"[DAP] Initial setup complete - main thread can continue") - + + print("[DAP] Initial setup complete - main thread can continue") + except Exception as e: print(f"[DAP] Initialization error: {e}") - + def process_pending_messages(self): """Process any pending DAP messages without blocking.""" try: # Set socket to non-blocking mode for message processing self.channel.sock.settimeout(0.001) # Very short timeout - + while True: message = self.channel.recv_message() if message is None: break self._handle_message(message) - + except Exception: # No messages available or socket error pass finally: # Reset to blocking mode self.channel.sock.settimeout(None) - + def _handle_message(self, message): """Handle incoming DAP messages.""" msg_type = message.get("type") command = message.get("command", message.get("event", "unknown")) seq = message.get("seq", 0) - + self._debug_print(f"[DAP] RECV: {msg_type} {command} (seq={seq})") if message.get("arguments"): self._debug_print(f"[DAP] args: {message['arguments']}") - + if msg_type == "request": self._handle_request(message) elif msg_type == "response": @@ -148,13 +148,13 @@ def _handle_message(self, message): elif msg_type == "event": # We don't expect events from client self._debug_print(f"[DAP] Unexpected event from client: {message}") - + def _handle_request(self, message): """Handle DAP request messages.""" command = message.get("command") seq = message.get("seq", 0) args = message.get("arguments", {}) - + try: if command == CMD_INITIALIZE: self._handle_initialize(seq, args) @@ -191,13 +191,13 @@ def _handle_request(self, message): elif command == CMD_SOURCE: self._handle_source(seq, args) else: - self.channel.send_response(command, seq, success=False, + self.channel.send_response(command, seq, success=False, message=f"Unknown command: {command}") - + except Exception as e: - self.channel.send_response(command, seq, success=False, + self.channel.send_response(command, seq, success=False, message=str(e)) - + def _handle_initialize(self, seq, args): """Handle initialize request.""" capabilities = { @@ -235,87 +235,87 @@ def _handle_initialize(self, seq, args): "supportsBreakpointLocationsRequest": False, "supportsClipboardContext": False, } - + self.channel.send_response(CMD_INITIALIZE, seq, body=capabilities) self.channel.send_event(EVENT_INITIALIZED) self.initialized = True - + def _handle_launch(self, seq, args): """Handle launch request.""" # For attach-mode debugging, we don't need to launch anything self.channel.send_response(CMD_LAUNCH, seq) - + def _handle_attach(self, seq, args): """Handle attach request.""" # Check if debug logging should be enabled self.debug_logging = args.get("logToFile", False) - + self._debug_print(f"[DAP] Processing attach request with args: {args}") print(f"[DAP] Debug logging {'enabled' if self.debug_logging else 'disabled'} (logToFile={self.debug_logging})") - + # Enable trace function self.pdb.set_trace_function(self._trace_function) self.channel.send_response(CMD_ATTACH, seq) - + # After successful attach, we might need to send additional events # Some debuggers expect a 'process' event or thread events self._debug_print("[DAP] Attach completed, debugging is now active") - + def _handle_set_breakpoints(self, seq, args): """Handle setBreakpoints request.""" source = args.get("source", {}) filename = source.get("path", "") breakpoints = args.get("breakpoints", []) - + # Debug log the source information self._debug_print(f"[DAP] setBreakpoints source info: {source}") - + # Set breakpoints in pdb adapter actual_breakpoints = self.pdb.set_breakpoints(filename, breakpoints) - - self.channel.send_response(CMD_SET_BREAKPOINTS, seq, + + self.channel.send_response(CMD_SET_BREAKPOINTS, seq, body={"breakpoints": actual_breakpoints}) - + def _handle_continue(self, seq, args): """Handle continue request.""" self.stepping = False self.paused = False self.pdb.continue_execution() self.channel.send_response(CMD_CONTINUE, seq) - + def _handle_next(self, seq, args): """Handle next (step over) request.""" self.stepping = True self.paused = False self.pdb.step_over() self.channel.send_response(CMD_NEXT, seq) - + def _handle_step_in(self, seq, args): """Handle stepIn request.""" self.stepping = True self.paused = False self.pdb.step_into() self.channel.send_response(CMD_STEP_IN, seq) - + def _handle_step_out(self, seq, args): """Handle stepOut request.""" self.stepping = True self.paused = False self.pdb.step_out() self.channel.send_response(CMD_STEP_OUT, seq) - + def _handle_pause(self, seq, args): """Handle pause request.""" self.paused = True self.pdb.pause() self.channel.send_response(CMD_PAUSE, seq) - + def _handle_stack_trace(self, seq, args): """Handle stackTrace request.""" stack_frames = self.pdb.get_stack_trace() - self.channel.send_response(CMD_STACK_TRACE, seq, + self.channel.send_response(CMD_STACK_TRACE, seq, body={"stackFrames": stack_frames, "totalFrames": len(stack_frames)}) - + def _handle_scopes(self, seq, args): """Handle scopes request.""" frame_id = args.get("frameId", 0) @@ -323,20 +323,20 @@ def _handle_scopes(self, seq, args): scopes = self.pdb.get_scopes(frame_id) self._debug_print(f"[DAP] Generated scopes: {scopes}") self.channel.send_response(CMD_SCOPES, seq, body={"scopes": scopes}) - + def _handle_variables(self, seq, args): """Handle variables request.""" variables_ref = args.get("variablesReference", 0) variables = self.pdb.get_variables(variables_ref) self.channel.send_response(CMD_VARIABLES, seq, body={"variables": variables}) - + def _handle_evaluate(self, seq, args): """Handle evaluate request.""" expression = args.get("expression", "") frame_id = args.get("frameId") context = args.get("context", "watch") if not expression: - self.channel.send_response(CMD_EVALUATE, seq, success=False, + self.channel.send_response(CMD_EVALUATE, seq, success=False, message="No expression provided") return try: @@ -346,20 +346,20 @@ def _handle_evaluate(self, seq, args): "variablesReference": 0 }) except Exception as e: - self.channel.send_response(CMD_EVALUATE, seq, success=False, + self.channel.send_response(CMD_EVALUATE, seq, success=False, message=str(e)) - + def _handle_disconnect(self, seq, args): """Handle disconnect request.""" self.channel.send_response(CMD_DISCONNECT, seq) self.disconnect() - + def _handle_configuration_done(self, seq, args): """Handle configurationDone request.""" # This indicates that the client has finished configuring breakpoints # and is ready to start debugging self.channel.send_response(CMD_CONFIGURATION_DONE, seq) - + def _handle_threads(self, seq, args): """Handle threads request.""" # MicroPython is single-threaded, so return one thread @@ -368,13 +368,13 @@ def _handle_threads(self, seq, args): "name": "main" }] self.channel.send_response(CMD_THREADS, seq, body={"threads": threads}) - + def _handle_source(self, seq, args): """Handle source request.""" source = args.get("source", {}) source_path = source.get("path", "") if self._baremetal or not source_path: - # BUGBUG: unable to read the source on ESP32 + # BUGBUG: unable to read the source on ESP32 # Possible an effect of the import / inialization sequence ? # Nothe that other source files ( other.py) do not seem to get requested in the same way self.channel.send_response(CMD_SOURCE, seq, success=False) @@ -385,53 +385,52 @@ def _handle_source(self, seq, args): with open(source_path) as f: content = f.read() self.channel.send_response(CMD_SOURCE, seq, body={"content": content}) - except Exception as e: - self.channel.send_response(CMD_SOURCE, seq, success=False, + except Exception: + self.channel.send_response(CMD_SOURCE, seq, success=False, message="cancelled" # message=f"Could not read source: {e}" ) - + def _trace_function(self, frame, event, arg): """Trace function called by sys.settrace.""" # Process any pending DAP messages frequently self.process_pending_messages() - + # Handle breakpoints and stepping if self.pdb.should_stop(frame, event, arg): - self._send_stopped_event(STOP_REASON_BREAKPOINT if self.pdb.hit_breakpoint else + self._send_stopped_event(STOP_REASON_BREAKPOINT if self.pdb.hit_breakpoint else STOP_REASON_STEP if self.stepping else STOP_REASON_PAUSE) # Wait for continue command self.pdb.wait_for_continue() - + return self._trace_function - + def _send_stopped_event(self, reason): """Send stopped event to client.""" - self.channel.send_event(EVENT_STOPPED, - reason=reason, + self.channel.send_event(EVENT_STOPPED, + reason=reason, threadId=self.thread_id, allThreadsStopped=True) - + def wait_for_client(self): """Wait for client to initialize.""" # This is a simplified version - in a real implementation # we might want to wait for specific initialization steps - pass - + def trigger_breakpoint(self): """Trigger a manual breakpoint.""" if self.initialized: self._send_stopped_event(STOP_REASON_BREAKPOINT) - + def debug_this_thread(self): """Enable debugging for current thread.""" if hasattr(sys, 'settrace'): sys.settrace(self._trace_function) - + def is_connected(self): """Check if client is connected.""" return self.connected and not self.channel.closed - + def disconnect(self): """Disconnect from client.""" self.connected = False diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 524574b2b..b40bcb35e 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -16,7 +16,7 @@ class PdbAdapter: """Adapter between DAP protocol and MicroPython's sys.settrace functionality.""" - + def __init__(self): self.breakpoints = {} # filename -> {line_no: breakpoint_info} self.current_frame = None @@ -28,12 +28,12 @@ def __init__(self): self.variables_cache = {} # frameId -> variables self.frame_id_counter = 1 self.path_mapping = {} # runtime_path -> vscode_path mapping - + def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" if hasattr(self, '_debug_session') and self._debug_session.debug_logging: # type: ignore print(message) - + def _normalize_path(self, path): """Normalize a file path for consistent comparisons.""" # Convert to absolute path if possible @@ -44,26 +44,26 @@ def _normalize_path(self, path): path = os.path.realpath(path) except: pass - + # Ensure consistent separators path = path.replace('\\', '/') return path - + def set_trace_function(self, trace_func): """Install the trace function.""" if hasattr(sys, 'settrace'): sys.settrace(trace_func) else: raise RuntimeError("sys.settrace not available") - + def set_breakpoints(self, filename, breakpoints): """Set breakpoints for a file.""" self.breakpoints[filename] = {} actual_breakpoints = [] - + # Debug log the breakpoint path self._debug_print(f"[PDB] Setting breakpoints for file: {filename}") - + for bp in breakpoints: line = bp.get("line") if line: @@ -77,23 +77,23 @@ def set_breakpoints(self, filename, breakpoints): "verified": True, "source": {"path": filename} }) - + return actual_breakpoints - + def should_stop(self, frame, event, arg): """Determine if execution should stop at this point.""" self.current_frame = frame self.hit_breakpoint = False - + # Get frame information filename = frame.f_code.co_filename lineno = frame.f_lineno - + # Debug: print filename and line for debugging if event == TRACE_LINE and lineno in [20, 21, 22, 23, 24]: # Only log lines near our breakpoints self._debug_print(f"[PDB] Checking {filename}:{lineno} (event={event})") self._debug_print(f"[PDB] Available breakpoint files: {list(self.breakpoints.keys())}") - + # Check for exact filename match first if filename in self.breakpoints: if lineno in self.breakpoints[filename]: @@ -102,11 +102,11 @@ def should_stop(self, frame, event, arg): self.path_mapping[filename] = filename self.hit_breakpoint = True return True - + # Also try checking by basename for path mismatches def basename(path): return path.split('/')[-1] if '/' in path else path - + # Check if this might be a relative path match def ends_with_path(full_path, relative_path): """Check if full_path ends with relative_path components.""" @@ -129,7 +129,7 @@ def ends_with_path(full_path, relative_path): self.path_mapping[filename] = bp_file self.hit_breakpoint = True return True - + # Also check if the runtime path might be relative and the breakpoint path absolute if ends_with_path(bp_file, filename): self._debug_print(f"[PDB] Relative path match: {bp_file} ends with {filename}") @@ -139,13 +139,13 @@ def ends_with_path(full_path, relative_path): self.path_mapping[filename] = bp_file self.hit_breakpoint = True return True - + # Check stepping if self.step_mode == 'into': if event in (TRACE_CALL, TRACE_LINE): self.step_mode = None return True - + elif self.step_mode == 'over': if event == TRACE_LINE and frame == self.step_frame: self.step_mode = None @@ -156,47 +156,46 @@ def ends_with_path(full_path, relative_path): self.step_frame = frame.f_back else: self.step_mode = None - + elif self.step_mode == 'out': if event == TRACE_RETURN and frame == self.step_frame: self.step_mode = None return True - + return False - + def continue_execution(self): """Continue execution.""" self.step_mode = None self.continue_event = True - + def step_over(self): """Step over (next line).""" self.step_mode = 'over' self.step_frame = self.current_frame self.continue_event = True - + def step_into(self): """Step into function calls.""" self.step_mode = 'into' self.continue_event = True - + def step_out(self): """Step out of current function.""" self.step_mode = 'out' self.step_frame = self.current_frame self.continue_event = True - + def pause(self): """Pause execution at next opportunity.""" # This is handled by the debug session - pass - + def wait_for_continue(self): """Wait for continue command (simplified implementation).""" # In a real implementation, this would block until continue # For MicroPython, we'll use a simple polling approach self.continue_event = False - + # Process DAP messages while waiting for continue self._debug_print("[PDB] Waiting for continue command...") while not self.continue_event: @@ -204,16 +203,16 @@ def wait_for_continue(self): if hasattr(self, '_debug_session'): self._debug_session.process_pending_messages() # type: ignore time.sleep(0.01) - + def get_stack_trace(self): """Get the current stack trace.""" if not self.current_frame: return [] - + frames = [] frame = self.current_frame frame_id = 0 - + while frame: filename = frame.f_code.co_filename name = frame.f_code.co_name @@ -238,10 +237,10 @@ def get_stack_trace(self): "endColumn": 1, "presentationHint": hint }) - + # Cache frame for variable access self.variables_cache[frame_id] = frame - + # MicroPython doesn't have f_back attribute if hasattr(frame, 'f_back'): frame = frame.f_back @@ -249,9 +248,9 @@ def get_stack_trace(self): # Only return the current frame for MicroPython break frame_id += 1 - + return frames - + def get_scopes(self, frame_id): """Get variable scopes for a frame.""" scopes = [ @@ -261,13 +260,13 @@ def get_scopes(self, frame_id): "expensive": False }, { - "name": "Globals", + "name": "Globals", "variablesReference": frame_id * 1000 + VARREF_GLOBALS , "expensive": False } ] return scopes - + def _process_special_variables(self, var_dict): """Process special variables (those starting and ending with __).""" variables = [] @@ -309,7 +308,7 @@ def _process_regular_variables(self, var_dict): @staticmethod def _var_error(name:str): return {"name": name, "value": "", "type": "unknown", "variablesReference": 0 } - + @staticmethod def _special_vars(varref:int): return {"name": "Special", "value": "", "variablesReference": varref} @@ -318,12 +317,12 @@ def get_variables(self, variables_ref): """Get variables for a scope.""" frame_id = variables_ref // 1000 scope_type = variables_ref % 1000 - + if frame_id not in self.variables_cache: return [] - + frame = self.variables_cache[frame_id] - + # Handle special scope types first if scope_type == VARREF_LOCALS_SPECIAL: var_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} @@ -331,7 +330,7 @@ def get_variables(self, variables_ref): elif scope_type == VARREF_GLOBALS_SPECIAL: var_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} return self._process_special_variables(var_dict) - + # Handle regular scope types with special folder variables = [] if scope_type == VARREF_LOCALS: @@ -343,7 +342,7 @@ def get_variables(self, variables_ref): else: # Invalid reference, return empty return [] - + # Add regular variables variables.extend(self._process_regular_variables(var_dict)) return variables diff --git a/python-ecosys/debugpy/demo.py b/python-ecosys/debugpy/demo.py index d5b3d0923..02a927257 100644 --- a/python-ecosys/debugpy/demo.py +++ b/python-ecosys/debugpy/demo.py @@ -16,10 +16,10 @@ def main(): print("MicroPython debugpy Demo") print("========================") print() - + # Demonstrate trace functionality print("1. Testing trace functionality:") - + def trace_function(frame, event, arg): if event == 'call': print(f" -> Entering function: {frame.f_code.co_name}") @@ -28,34 +28,34 @@ def trace_function(frame, event, arg): elif event == 'return': print(f" -> Returning from {frame.f_code.co_name} with value: {arg}") return trace_function - + # Enable tracing sys.settrace(trace_function) - + # Execute traced function result = simple_function(5, 3) - + # Disable tracing sys.settrace(None) - + print(f"Result: {result}") print() - + # Demonstrate debugpy components print("2. Testing debugpy components:") - + # Test PDB adapter from debugpy.server.pdb_adapter import PdbAdapter pdb = PdbAdapter() - + # Set some mock breakpoints breakpoints = pdb.set_breakpoints("demo.py", [{"line": 10}, {"line": 15}]) print(f" Set breakpoints: {len(breakpoints)} breakpoints") - + # Test messaging from debugpy.common.messaging import JsonMessageChannel print(" JsonMessageChannel available") - + print() print("3. debugpy is ready for VS Code integration!") print(" To use with VS Code:") @@ -63,6 +63,6 @@ def trace_function(frame, event, arg): print(" - Call debugpy.listen() to start the debug server") print(" - Connect VS Code using the 'Attach to MicroPython' configuration") print(" - Set breakpoints and debug normally") - + if __name__ == "__main__": main() diff --git a/python-ecosys/debugpy/test_vscode.py b/python-ecosys/debugpy/test_vscode.py index 2dca82d34..9a5672822 100644 --- a/python-ecosys/debugpy/test_vscode.py +++ b/python-ecosys/debugpy/test_vscode.py @@ -23,7 +23,7 @@ def debuggable_code(): """The actual code we want to debug - wrapped in a function so sys.settrace will trace it.""" global foo print("Starting debuggable code...") - + # Test data - set breakpoint here (using smaller numbers to avoid slow fibonacci) numbers = [3, 4, 5] for i, num in enumerate(numbers): @@ -34,18 +34,18 @@ def debuggable_code(): print(sys.implementation) import machine print(dir(machine)) - + # Test manual breakpoint print("\nTriggering manual breakpoint...") debugpy.breakpoint() print("Manual breakpoint triggered!") - + print("Test completed successfully!") def main(): print("MicroPython VS Code Debugging Test") print("==================================") - + # Start debug server try: debugpy.listen() @@ -57,22 +57,22 @@ def main(): # input() # except: # pass - + # Enable debugging for this thread debugpy.debug_this_thread() - + # Give VS Code a moment to set breakpoints after attach print("\nGiving VS Code time to set breakpoints...") import time time.sleep(2) - + # Call the debuggable code function so it gets traced debuggable_code() - + except KeyboardInterrupt: print("\nTest interrupted by user") except Exception as e: print(f"Error: {e}") if __name__ == "__main__": - main() \ No newline at end of file + main() From 2eb9cb5e9fd72ca3a47b89f2d160b7878ed4cfa7 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 24 Jun 2025 16:03:52 +0200 Subject: [PATCH 10/31] debugpy : Decode debugger IP address on connect. Signed-off-by: Jos Verlinde --- python-ecosys/debugpy/debugpy/public_api.py | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/python-ecosys/debugpy/debugpy/public_api.py b/python-ecosys/debugpy/debugpy/public_api.py index 8642be989..c8f1363e9 100644 --- a/python-ecosys/debugpy/debugpy/public_api.py +++ b/python-ecosys/debugpy/debugpy/public_api.py @@ -1,6 +1,7 @@ """Public API for debugpy.""" import socket +import struct import sys from .common.constants import DEFAULT_HOST, DEFAULT_PORT from .server.debug_session import DebugSession @@ -43,7 +44,7 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): client_sock = None try: client_sock, client_addr = listener.accept() - print(f"Debugger connected from {client_addr}") + print(f"Debugger connected from {format_client_addr(client_addr)}") # Create debug session _debug_session = DebugSession(client_sock) @@ -73,6 +74,26 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): return (host, port) +def format_client_addr(client_addr): + """Format client address using socket module methods""" + if isinstance(client_addr, (tuple, list)): + # Already in (ip, port) format + return f"{client_addr[0]}:{client_addr[1]}" + elif isinstance(client_addr, bytes) and len(client_addr) >= 8: + # Extract port (bytes 2-4, network byte order) + port = struct.unpack('!H', client_addr[2:4])[0] + # Extract IP address (bytes 4-8) using inet_ntoa + ip_packed = client_addr[4:8] + try: + # inet_ntoa expects 4-byte string in network byte order + ip_addr = socket.inet_ntoa(ip_packed) + return f"{ip_addr}:{port}" + except: + # Fallback if inet_ntoa not available (MicroPython) + ip_addr = '.'.join(str(b) for b in ip_packed) + return f"{ip_addr}:{port}" + else: + return str(client_addr) def wait_for_client(): """Wait for the debugger client to connect and initialize.""" From bb6dc8b1f139586a38aa60b4daff750d346f4232 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Sun, 29 Jun 2025 23:48:06 +0200 Subject: [PATCH 11/31] debugpy: Add type hints and improve path mapping logic in PDB adapter Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/debug_session.py | 2 +- .../debugpy/debugpy/server/pdb_adapter.py | 47 ++++++++----------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 43e2d442c..fe0784f77 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -20,7 +20,7 @@ def __init__(self, client_socket): self.debug_logging = False # Initialize first self.channel = JsonMessageChannel(client_socket, self._debug_print) self.pdb = PdbAdapter() - self.pdb._debug_session = self # Allow PDB to process messages during wait + self.pdb._debug_session = self # Allow PDB to process messages during wait # type: ignore self.initialized = False self.connected = True self.thread_id = 1 # Simple single-thread model diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index b40bcb35e..ab485ff06 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -14,6 +14,19 @@ VARREF_GLOBALS_SPECIAL = 4 +# Also try checking by basename for path mismatches +def basename(path:str): + return path.split('/')[-1] if '/' in path else path + +# Check if this might be a relative path match +def ends_with_path(full_path:str, relative_path:str): + """Check if full_path ends with relative_path components.""" + full_parts = full_path.replace('\\', '/').split('/') + rel_parts = relative_path.replace('\\', '/').split('/') + if len(rel_parts) > len(full_parts): + return False + return full_parts[-len(rel_parts):] == rel_parts + class PdbAdapter: """Adapter between DAP protocol and MicroPython's sys.settrace functionality.""" @@ -27,14 +40,14 @@ def __init__(self): self.continue_event = False self.variables_cache = {} # frameId -> variables self.frame_id_counter = 1 - self.path_mapping = {} # runtime_path -> vscode_path mapping + self.path_mappings : dict[str,str] = {} # runtime_path -> vscode_path mapping def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" if hasattr(self, '_debug_session') and self._debug_session.debug_logging: # type: ignore print(message) - def _normalize_path(self, path): + def _normalize_path(self, path:str): """Normalize a file path for consistent comparisons.""" # Convert to absolute path if possible try: @@ -44,7 +57,6 @@ def _normalize_path(self, path): path = os.path.realpath(path) except: pass - # Ensure consistent separators path = path.replace('\\', '/') return path @@ -80,7 +92,7 @@ def set_breakpoints(self, filename, breakpoints): return actual_breakpoints - def should_stop(self, frame, event, arg): + def should_stop(self, frame, event:str, arg): """Determine if execution should stop at this point.""" self.current_frame = frame self.hit_breakpoint = False @@ -88,34 +100,15 @@ def should_stop(self, frame, event, arg): # Get frame information filename = frame.f_code.co_filename lineno = frame.f_lineno - - # Debug: print filename and line for debugging - if event == TRACE_LINE and lineno in [20, 21, 22, 23, 24]: # Only log lines near our breakpoints - self._debug_print(f"[PDB] Checking {filename}:{lineno} (event={event})") - self._debug_print(f"[PDB] Available breakpoint files: {list(self.breakpoints.keys())}") - # Check for exact filename match first if filename in self.breakpoints: if lineno in self.breakpoints[filename]: self._debug_print(f"[PDB] HIT BREAKPOINT (exact match) at {filename}:{lineno}") # Record the path mapping (in this case, they're already the same) - self.path_mapping[filename] = filename + self.path_mappings[filename] = filename self.hit_breakpoint = True return True - # Also try checking by basename for path mismatches - def basename(path): - return path.split('/')[-1] if '/' in path else path - - # Check if this might be a relative path match - def ends_with_path(full_path, relative_path): - """Check if full_path ends with relative_path components.""" - full_parts = full_path.replace('\\', '/').split('/') - rel_parts = relative_path.replace('\\', '/').split('/') - if len(rel_parts) > len(full_parts): - return False - return full_parts[-len(rel_parts):] == rel_parts - file_basename = basename(filename) self._debug_print(f"[PDB] Fallback basename match: '{file_basename}' vs available files") for bp_file in self.breakpoints: @@ -126,7 +119,7 @@ def ends_with_path(full_path, relative_path): if lineno in self.breakpoints[bp_file]: self._debug_print(f"[PDB] HIT BREAKPOINT (fallback basename match) at {filename}:{lineno} -> {bp_file}") # Record the path mapping so we can report the correct path in stack traces - self.path_mapping[filename] = bp_file + self.path_mappings[filename] = bp_file self.hit_breakpoint = True return True @@ -136,7 +129,7 @@ def ends_with_path(full_path, relative_path): if lineno in self.breakpoints[bp_file]: self._debug_print(f"[PDB] HIT BREAKPOINT (relative path match) at {filename}:{lineno} -> {bp_file}") # Record the path mapping so we can report the correct path in stack traces - self.path_mapping[filename] = bp_file + self.path_mappings[filename] = bp_file self.hit_breakpoint = True return True @@ -223,7 +216,7 @@ def get_stack_trace(self): hint = 'normal' # Use the VS Code path if we have a mapping, otherwise use the original path - display_path = self.path_mapping.get(filename, filename) + display_path = self.path_mappings.get(filename, filename) if filename != display_path: self._debug_print(f"[PDB] Stack trace path mapping: {filename} -> {display_path}") # Create StackFrame info From 756d1746fd96a695b06825d21255db157499cab9 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Sun, 29 Jun 2025 23:58:00 +0200 Subject: [PATCH 12/31] debugpy: Enhance path mapping handling in PDB adapter and debug session. Store both folder mappings from the debugger, and 1:1 file mappings . Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/debug_session.py | 9 +++++++++ python-ecosys/debugpy/debugpy/server/pdb_adapter.py | 13 +++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index fe0784f77..a2a00c170 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -252,6 +252,15 @@ def _handle_attach(self, seq, args): self._debug_print(f"[DAP] Processing attach request with args: {args}") print(f"[DAP] Debug logging {'enabled' if self.debug_logging else 'disabled'} (logToFile={self.debug_logging})") + + # get debugger root and debugee root from pathMappings + for pm in args.get("pathMappings",[]): + # debugee - debugger + self.pdb.path_mappings.append( + (pm.get("remoteRoot", "./"), + pm.get("localRoot", "./")) + ) + # # TODO: justMyCode, debugOptions , # Enable trace function self.pdb.set_trace_function(self._trace_function) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index ab485ff06..a5ab3d2ce 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -40,7 +40,8 @@ def __init__(self): self.continue_event = False self.variables_cache = {} # frameId -> variables self.frame_id_counter = 1 - self.path_mappings : dict[str,str] = {} # runtime_path -> vscode_path mapping + self.path_mappings : list[tuple[str,str]] = [] # runtime_path -> vscode_path mapping + self.file_mappings : dict[str,str] = {} # runtime_path -> vscode_path mapping def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" @@ -68,7 +69,7 @@ def set_trace_function(self, trace_func): else: raise RuntimeError("sys.settrace not available") - def set_breakpoints(self, filename, breakpoints): + def set_breakpoints(self, filename, breakpoints:list[dict]): """Set breakpoints for a file.""" self.breakpoints[filename] = {} actual_breakpoints = [] @@ -105,7 +106,7 @@ def should_stop(self, frame, event:str, arg): if lineno in self.breakpoints[filename]: self._debug_print(f"[PDB] HIT BREAKPOINT (exact match) at {filename}:{lineno}") # Record the path mapping (in this case, they're already the same) - self.path_mappings[filename] = filename + self.file_mappings[filename] = filename self.hit_breakpoint = True return True @@ -119,7 +120,7 @@ def should_stop(self, frame, event:str, arg): if lineno in self.breakpoints[bp_file]: self._debug_print(f"[PDB] HIT BREAKPOINT (fallback basename match) at {filename}:{lineno} -> {bp_file}") # Record the path mapping so we can report the correct path in stack traces - self.path_mappings[filename] = bp_file + self.file_mappings[filename] = bp_file self.hit_breakpoint = True return True @@ -129,7 +130,7 @@ def should_stop(self, frame, event:str, arg): if lineno in self.breakpoints[bp_file]: self._debug_print(f"[PDB] HIT BREAKPOINT (relative path match) at {filename}:{lineno} -> {bp_file}") # Record the path mapping so we can report the correct path in stack traces - self.path_mappings[filename] = bp_file + self.file_mappings[filename] = bp_file self.hit_breakpoint = True return True @@ -216,7 +217,7 @@ def get_stack_trace(self): hint = 'normal' # Use the VS Code path if we have a mapping, otherwise use the original path - display_path = self.path_mappings.get(filename, filename) + display_path = self.file_mappings.get(filename, filename) if filename != display_path: self._debug_print(f"[PDB] Stack trace path mapping: {filename} -> {display_path}") # Create StackFrame info From 14b2e2734002fbc5552c81f8e2e074df8e6c548e Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Mon, 30 Jun 2025 13:10:05 +0200 Subject: [PATCH 13/31] debugpy: Enhance breakpoint handling and path mapping in PdbAdapter Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/pdb_adapter.py | 106 ++++++++++++------ 1 file changed, 71 insertions(+), 35 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index a5ab3d2ce..cd3354d8c 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -31,7 +31,7 @@ class PdbAdapter: """Adapter between DAP protocol and MicroPython's sys.settrace functionality.""" def __init__(self): - self.breakpoints = {} # filename -> {line_no: breakpoint_info} + self.breakpoints : dict[str,dict[int,dict]] = {} # filename -> {line_no: breakpoint_info} # todo - simplify - reduce info stored self.current_frame = None self.step_mode = None # None, 'over', 'into', 'out' self.step_frame = None @@ -40,8 +40,8 @@ def __init__(self): self.continue_event = False self.variables_cache = {} # frameId -> variables self.frame_id_counter = 1 - self.path_mappings : list[tuple[str,str]] = [] # runtime_path -> vscode_path mapping - self.file_mappings : dict[str,str] = {} # runtime_path -> vscode_path mapping + self.path_mappings : list[tuple[str,str]] = [] # runtime_path -> vscode_path mapping # todo: move to session level + self.file_mappings : dict[str,str] = {} # runtime_path -> vscode_path mapping # todo : merge with .breakpoints def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" @@ -69,17 +69,61 @@ def set_trace_function(self, trace_func): else: raise RuntimeError("sys.settrace not available") - def set_breakpoints(self, filename, breakpoints:list[dict]): + def _filename_as_debugee(self, path:str): + # check if we have a 1:1 file mapping for this path + if self.file_mappings.get(path): + return self.file_mappings[path] + # Check if we have a folder mapping for this path + for runtime_path, vscode_path in self.path_mappings: + if path.startswith(vscode_path): + path = path.replace(vscode_path, runtime_path, 1) + if path.startswith('//'): + path = path[1:] + # If no mapping found, return the original path + return path + + def _filename_as_debugger(self, path:str): + """Convert a file path to the debugger's expected format.""" + path = path or "" + if not path: + return path + if path.startswith('<'): + # Special case for or similar + return path + # Check if we have a 1:1 file mapping for this path + for runtime_path, vscode_path in self.path_mappings: + if path.startswith(runtime_path): + path = path.replace(runtime_path, vscode_path, 1) + return path + + # Check if we have a folder mapping for this path + for runtime_path, vscode_path in self.path_mappings: + if path.startswith(runtime_path): + path = path.replace(runtime_path, vscode_path, 1) + if path.startswith('//'): + path = path[1:] + # If no mapping found, return the original path + return path + + def set_breakpoints(self, filename:str, breakpoints:list[dict]): """Set breakpoints for a file.""" self.breakpoints[filename] = {} + local_name = self._filename_as_debugee(filename) + self.file_mappings[local_name] = filename actual_breakpoints = [] - - # Debug log the breakpoint path self._debug_print(f"[PDB] Setting breakpoints for file: {filename}") for bp in breakpoints: line = bp.get("line") if line: + if local_name != filename: + self.breakpoints[local_name] = {} + self._debug_print(f"[>>>] Setting breakpoints for local: {local_name}:{line}") + self.breakpoints[local_name][line] = { + "line": line, + "verified": True, + "source": {"path": filename} + } self.breakpoints[filename][line] = { "line": line, "verified": True, @@ -91,6 +135,8 @@ def set_breakpoints(self, filename, breakpoints:list[dict]): "source": {"path": filename} }) + self._debug_print(f"[PDB] Breakpoints set : {self.breakpoints}") + return actual_breakpoints def should_stop(self, frame, event:str, arg): @@ -106,33 +152,18 @@ def should_stop(self, frame, event:str, arg): if lineno in self.breakpoints[filename]: self._debug_print(f"[PDB] HIT BREAKPOINT (exact match) at {filename}:{lineno}") # Record the path mapping (in this case, they're already the same) - self.file_mappings[filename] = filename + self.file_mappings[filename] = self._filename_as_debugger(filename) self.hit_breakpoint = True return True - - file_basename = basename(filename) - self._debug_print(f"[PDB] Fallback basename match: '{file_basename}' vs available files") - for bp_file in self.breakpoints: - bp_basename = basename(bp_file) - self._debug_print(f"[PDB] Comparing '{file_basename}' == '{bp_basename}' ?") - if bp_basename == file_basename: - self._debug_print(f"[PDB] Basename match found! Checking line {lineno} in {list(self.breakpoints[bp_file].keys())}") - if lineno in self.breakpoints[bp_file]: - self._debug_print(f"[PDB] HIT BREAKPOINT (fallback basename match) at {filename}:{lineno} -> {bp_file}") - # Record the path mapping so we can report the correct path in stack traces - self.file_mappings[filename] = bp_file - self.hit_breakpoint = True - return True - - # Also check if the runtime path might be relative and the breakpoint path absolute - if ends_with_path(bp_file, filename): - self._debug_print(f"[PDB] Relative path match: {bp_file} ends with {filename}") - if lineno in self.breakpoints[bp_file]: - self._debug_print(f"[PDB] HIT BREAKPOINT (relative path match) at {filename}:{lineno} -> {bp_file}") - # Record the path mapping so we can report the correct path in stack traces - self.file_mappings[filename] = bp_file - self.hit_breakpoint = True - return True + # path/file.py matched - but not the line number - keep running + else: + # file not (yet) matched - this is slow so we do not want to do this often. + # TODO: use builins - sys.path method to find the file + # if we have a path match , but no breakpoints - add it to the file_mappings dict avoid this check + self.breakpoints[filename] = {} # Ensure the filename is in the breakpoints dict + if not filename in self.file_mappings: + self.file_mappings[filename] = self._filename_as_debugger(filename) + self._debug_print(f"[PDB] add mapping for :'{filename}' -> '{self.file_mappings[filename]}'") # Check stepping if self.step_mode == 'into': @@ -216,15 +247,20 @@ def get_stack_trace(self): else : hint = 'normal' + # self._debug_print("=" * 40 ) + # self._debug_print(f"[PDB] file mappings: {repr(self.file_mappings)} " ) + # self._debug_print(f"[PDB] path mappings: {repr(self.path_mappings)}" ) + # self._debug_print("=" * 40 ) + # Use the VS Code path if we have a mapping, otherwise use the original path - display_path = self.file_mappings.get(filename, filename) - if filename != display_path: - self._debug_print(f"[PDB] Stack trace path mapping: {filename} -> {display_path}") + debugger_path = self._filename_as_debugger(filename) + if filename != debugger_path: + self._debug_print(f"[PDB] Stack trace path mapping: {filename} -> {debugger_path}") # Create StackFrame info frames.append({ "id": frame_id, "name": name, - "source": {"path": display_path}, + "source": {"path": debugger_path}, "line": line, "column": 1, "endLine": line, From 817f5eccc39826eaebdd1e14de4daee7c4b1a869 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Mon, 30 Jun 2025 21:12:36 +0200 Subject: [PATCH 14/31] debugpy: Refactor breakpoint storage to use sets for improved efficiency Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/pdb_adapter.py | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index cd3354d8c..19fc89385 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -13,6 +13,7 @@ VARREF_LOCALS_SPECIAL = 3 VARREF_GLOBALS_SPECIAL = 4 +DEBUG = False # Also try checking by basename for path mismatches def basename(path:str): @@ -31,7 +32,7 @@ class PdbAdapter: """Adapter between DAP protocol and MicroPython's sys.settrace functionality.""" def __init__(self): - self.breakpoints : dict[str,dict[int,dict]] = {} # filename -> {line_no: breakpoint_info} # todo - simplify - reduce info stored + self.breakpoints : dict[str,set[int]] = {} # .breakpoints[filename] -> set of line numbers self.current_frame = None self.step_mode = None # None, 'over', 'into', 'out' self.step_frame = None @@ -40,8 +41,8 @@ def __init__(self): self.continue_event = False self.variables_cache = {} # frameId -> variables self.frame_id_counter = 1 - self.path_mappings : list[tuple[str,str]] = [] # runtime_path -> vscode_path mapping # todo: move to session level - self.file_mappings : dict[str,str] = {} # runtime_path -> vscode_path mapping # todo : merge with .breakpoints + self.path_mappings : list[tuple[str,str]] = [] # runtime_path -> vscode_path mapping + self.file_mappings : dict[str,str] = {} # runtime_path -> vscode_path mapping # todo : ? merge with .breakpoints def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" @@ -107,7 +108,7 @@ def _filename_as_debugger(self, path:str): def set_breakpoints(self, filename:str, breakpoints:list[dict]): """Set breakpoints for a file.""" - self.breakpoints[filename] = {} + self.breakpoints[filename] = set() local_name = self._filename_as_debugee(filename) self.file_mappings[local_name] = filename actual_breakpoints = [] @@ -117,18 +118,11 @@ def set_breakpoints(self, filename:str, breakpoints:list[dict]): line = bp.get("line") if line: if local_name != filename: - self.breakpoints[local_name] = {} + self.breakpoints[local_name] = set() self._debug_print(f"[>>>] Setting breakpoints for local: {local_name}:{line}") - self.breakpoints[local_name][line] = { - "line": line, - "verified": True, - "source": {"path": filename} - } - self.breakpoints[filename][line] = { - "line": line, - "verified": True, - "source": {"path": filename} - } + self.breakpoints[local_name].add(line) + + self.breakpoints[filename].add(line) actual_breakpoints.append({ "line": line, "verified": True, @@ -148,19 +142,19 @@ def should_stop(self, frame, event:str, arg): filename = frame.f_code.co_filename lineno = frame.f_lineno # Check for exact filename match first - if filename in self.breakpoints: - if lineno in self.breakpoints[filename]: + if filename in self.breakpoints and lineno in self.breakpoints[filename]: self._debug_print(f"[PDB] HIT BREAKPOINT (exact match) at {filename}:{lineno}") # Record the path mapping (in this case, they're already the same) - self.file_mappings[filename] = self._filename_as_debugger(filename) + # self.file_mappings[filename] = self._filename_as_debugger(filename) self.hit_breakpoint = True return True # path/file.py matched - but not the line number - keep running else: # file not (yet) matched - this is slow so we do not want to do this often. # TODO: use builins - sys.path method to find the file - # if we have a path match , but no breakpoints - add it to the file_mappings dict avoid this check - self.breakpoints[filename] = {} # Ensure the filename is in the breakpoints dict + # if we have a path match , but no breakpoints - add it to the file_mappings dict simplify this check + if not filename in self.breakpoints: + self.breakpoints[filename] = set() # Ensure the filename is in the breakpoints dict if not filename in self.file_mappings: self.file_mappings[filename] = self._filename_as_debugger(filename) self._debug_print(f"[PDB] add mapping for :'{filename}' -> '{self.file_mappings[filename]}'") @@ -238,6 +232,12 @@ def get_stack_trace(self): frame = self.current_frame frame_id = 0 + self._debug_print("=" * 40 ) + self._debug_print(f"[PDB] file mappings: {repr(self.file_mappings)} " ) + self._debug_print(f"[PDB] path mappings: {repr(self.path_mappings)}" ) + self._debug_print(f"[PDB] breakpoints: {repr(self.breakpoints)}" ) + self._debug_print("=" * 40 ) + while frame: filename = frame.f_code.co_filename name = frame.f_code.co_name @@ -247,15 +247,9 @@ def get_stack_trace(self): else : hint = 'normal' - # self._debug_print("=" * 40 ) - # self._debug_print(f"[PDB] file mappings: {repr(self.file_mappings)} " ) - # self._debug_print(f"[PDB] path mappings: {repr(self.path_mappings)}" ) - # self._debug_print("=" * 40 ) # Use the VS Code path if we have a mapping, otherwise use the original path debugger_path = self._filename_as_debugger(filename) - if filename != debugger_path: - self._debug_print(f"[PDB] Stack trace path mapping: {filename} -> {debugger_path}") # Create StackFrame info frames.append({ "id": frame_id, From 26c6e3a5c7f5ec1329d64c3913c91304bbb4dc36 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Mon, 30 Jun 2025 23:42:38 +0200 Subject: [PATCH 15/31] debugpy: Add complex variable handling and caching. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/pdb_adapter.py | 396 +++++++++++++----- 1 file changed, 298 insertions(+), 98 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index cd3354d8c..11b4dfb81 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -4,34 +4,92 @@ import time import os import json + +Any = object from ..common.constants import ( - TRACE_CALL, TRACE_LINE, TRACE_RETURN, TRACE_EXCEPTION, - SCOPE_LOCALS, SCOPE_GLOBALS + TRACE_CALL, + TRACE_LINE, + TRACE_RETURN, + TRACE_EXCEPTION, + SCOPE_LOCALS, + SCOPE_GLOBALS, ) + VARREF_LOCALS = 1 VARREF_GLOBALS = 2 VARREF_LOCALS_SPECIAL = 3 VARREF_GLOBALS_SPECIAL = 4 +# New constants for complex variable references +VARREF_COMPLEX_BASE = 10000 # Base for complex variable references +MAX_CACHE_SIZE = 50 # Limit cache size for memory constraints + + +class VariableReferenceCache: + """Lightweight cache for complex variable references optimized for MicroPython.""" + + def __init__(self, max_size: int = MAX_CACHE_SIZE): + self.cache: dict[int, Any] = {} + self.insertion_order: list[int] = [] # Track insertion order for proper FIFO + self.next_ref: int = VARREF_COMPLEX_BASE + self.max_size: int = max_size + + def add_variable(self, value: Any) -> int: + """Add a complex variable and return its reference ID.""" + # Clean cache if approaching limit + if len(self.cache) >= self.max_size: + self._cleanup_oldest() + + ref_id = self.next_ref + self.cache[ref_id] = value + self.insertion_order.append(ref_id) + self.next_ref += 1 + return ref_id + + def get_variable(self, ref_id: int): # -> Optional[Any] + """Get variable by reference ID.""" + return self.cache.get(ref_id) + + def _cleanup_oldest(self) -> None: + """Remove oldest entries to free memory.""" + if self.cache and self.insertion_order: + # Remove first quarter of entries (true FIFO based on insertion order) + to_remove = max(1, len(self.cache) // 4) # Remove at least 1 entry + keys_to_remove = self.insertion_order[:to_remove] + for key in keys_to_remove: + if key in self.cache: + del self.cache[key] + # Update insertion order + self.insertion_order = self.insertion_order[to_remove:] + + def clear(self) -> None: + """Clear all cached variables.""" + self.cache.clear() + self.insertion_order.clear() + # Also try checking by basename for path mismatches -def basename(path:str): - return path.split('/')[-1] if '/' in path else path +def basename(path: str): + return path.split("/")[-1] if "/" in path else path + # Check if this might be a relative path match -def ends_with_path(full_path:str, relative_path:str): +def ends_with_path(full_path: str, relative_path: str): """Check if full_path ends with relative_path components.""" - full_parts = full_path.replace('\\', '/').split('/') - rel_parts = relative_path.replace('\\', '/').split('/') + full_parts = full_path.replace("\\", "/").split("/") + rel_parts = relative_path.replace("\\", "/").split("/") if len(rel_parts) > len(full_parts): return False - return full_parts[-len(rel_parts):] == rel_parts + return full_parts[-len(rel_parts) :] == rel_parts + class PdbAdapter: """Adapter between DAP protocol and MicroPython's sys.settrace functionality.""" def __init__(self): - self.breakpoints : dict[str,dict[int,dict]] = {} # filename -> {line_no: breakpoint_info} # todo - simplify - reduce info stored + self.breakpoints: dict[ + str, dict[int, dict] + ] = {} # filename -> {line_no: breakpoint_info} # todo - simplify - reduce info stored self.current_frame = None self.step_mode = None # None, 'over', 'into', 'out' self.step_frame = None @@ -39,37 +97,42 @@ def __init__(self): self.hit_breakpoint = False self.continue_event = False self.variables_cache = {} # frameId -> variables + self.var_cache = VariableReferenceCache() # Enhanced variable reference cache self.frame_id_counter = 1 - self.path_mappings : list[tuple[str,str]] = [] # runtime_path -> vscode_path mapping # todo: move to session level - self.file_mappings : dict[str,str] = {} # runtime_path -> vscode_path mapping # todo : merge with .breakpoints + self.path_mappings: list[ + tuple[str, str] + ] = [] # runtime_path -> vscode_path mapping # todo: move to session level + self.file_mappings: dict[ + str, str + ] = {} # runtime_path -> vscode_path mapping # todo : merge with .breakpoints def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" - if hasattr(self, '_debug_session') and self._debug_session.debug_logging: # type: ignore + if hasattr(self, "_debug_session") and self._debug_session.debug_logging: # type: ignore print(message) - def _normalize_path(self, path:str): + def _normalize_path(self, path: str): """Normalize a file path for consistent comparisons.""" # Convert to absolute path if possible try: - if hasattr(os.path, 'abspath'): + if hasattr(os.path, "abspath"): path = os.path.abspath(path) - elif hasattr(os.path, 'realpath'): + elif hasattr(os.path, "realpath"): path = os.path.realpath(path) except: pass # Ensure consistent separators - path = path.replace('\\', '/') + path = path.replace("\\", "/") return path def set_trace_function(self, trace_func): """Install the trace function.""" - if hasattr(sys, 'settrace'): + if hasattr(sys, "settrace"): sys.settrace(trace_func) else: raise RuntimeError("sys.settrace not available") - def _filename_as_debugee(self, path:str): + def _filename_as_debugee(self, path: str): # check if we have a 1:1 file mapping for this path if self.file_mappings.get(path): return self.file_mappings[path] @@ -77,17 +140,17 @@ def _filename_as_debugee(self, path:str): for runtime_path, vscode_path in self.path_mappings: if path.startswith(vscode_path): path = path.replace(vscode_path, runtime_path, 1) - if path.startswith('//'): + if path.startswith("//"): path = path[1:] # If no mapping found, return the original path return path - - def _filename_as_debugger(self, path:str): + + def _filename_as_debugger(self, path: str): """Convert a file path to the debugger's expected format.""" path = path or "" if not path: return path - if path.startswith('<'): + if path.startswith("<"): # Special case for or similar return path # Check if we have a 1:1 file mapping for this path @@ -100,12 +163,12 @@ def _filename_as_debugger(self, path:str): for runtime_path, vscode_path in self.path_mappings: if path.startswith(runtime_path): path = path.replace(runtime_path, vscode_path, 1) - if path.startswith('//'): + if path.startswith("//"): path = path[1:] # If no mapping found, return the original path return path - def set_breakpoints(self, filename:str, breakpoints:list[dict]): + def set_breakpoints(self, filename: str, breakpoints: list[dict]): """Set breakpoints for a file.""" self.breakpoints[filename] = {} local_name = self._filename_as_debugee(filename) @@ -122,24 +185,22 @@ def set_breakpoints(self, filename:str, breakpoints:list[dict]): self.breakpoints[local_name][line] = { "line": line, "verified": True, - "source": {"path": filename} + "source": {"path": filename}, } self.breakpoints[filename][line] = { "line": line, "verified": True, - "source": {"path": filename} + "source": {"path": filename}, } - actual_breakpoints.append({ - "line": line, - "verified": True, - "source": {"path": filename} - }) + actual_breakpoints.append( + {"line": line, "verified": True, "source": {"path": filename}} + ) self._debug_print(f"[PDB] Breakpoints set : {self.breakpoints}") return actual_breakpoints - def should_stop(self, frame, event:str, arg): + def should_stop(self, frame, event: str, arg): """Determine if execution should stop at this point.""" self.current_frame = frame self.hit_breakpoint = False @@ -163,26 +224,28 @@ def should_stop(self, frame, event:str, arg): self.breakpoints[filename] = {} # Ensure the filename is in the breakpoints dict if not filename in self.file_mappings: self.file_mappings[filename] = self._filename_as_debugger(filename) - self._debug_print(f"[PDB] add mapping for :'{filename}' -> '{self.file_mappings[filename]}'") + self._debug_print( + f"[PDB] add mapping for :'{filename}' -> '{self.file_mappings[filename]}'" + ) # Check stepping - if self.step_mode == 'into': + if self.step_mode == "into": if event in (TRACE_CALL, TRACE_LINE): self.step_mode = None return True - elif self.step_mode == 'over': + elif self.step_mode == "over": if event == TRACE_LINE and frame == self.step_frame: self.step_mode = None return True elif event == TRACE_RETURN and frame == self.step_frame: # Continue stepping in caller - if hasattr(frame, 'f_back') and frame.f_back: + if hasattr(frame, "f_back") and frame.f_back: self.step_frame = frame.f_back else: self.step_mode = None - elif self.step_mode == 'out': + elif self.step_mode == "out": if event == TRACE_RETURN and frame == self.step_frame: self.step_mode = None return True @@ -196,18 +259,18 @@ def continue_execution(self): def step_over(self): """Step over (next line).""" - self.step_mode = 'over' + self.step_mode = "over" self.step_frame = self.current_frame self.continue_event = True def step_into(self): """Step into function calls.""" - self.step_mode = 'into' + self.step_mode = "into" self.continue_event = True def step_out(self): """Step out of current function.""" - self.step_mode = 'out' + self.step_mode = "out" self.step_frame = self.current_frame self.continue_event = True @@ -225,8 +288,8 @@ def wait_for_continue(self): self._debug_print("[PDB] Waiting for continue command...") while not self.continue_event: # Process any pending DAP messages (scopes, variables, etc.) - if hasattr(self, '_debug_session'): - self._debug_session.process_pending_messages() # type: ignore + if hasattr(self, "_debug_session"): + self._debug_session.process_pending_messages() # type: ignore time.sleep(0.01) def get_stack_trace(self): @@ -242,10 +305,10 @@ def get_stack_trace(self): filename = frame.f_code.co_filename name = frame.f_code.co_name line = frame.f_lineno - if "" in filename or filename.endswith("debugpy.py") : - hint = 'subtle' - else : - hint = 'normal' + if "" in filename or filename.endswith("debugpy.py"): + hint = "subtle" + else: + hint = "normal" # self._debug_print("=" * 40 ) # self._debug_print(f"[PDB] file mappings: {repr(self.file_mappings)} " ) @@ -257,22 +320,24 @@ def get_stack_trace(self): if filename != debugger_path: self._debug_print(f"[PDB] Stack trace path mapping: {filename} -> {debugger_path}") # Create StackFrame info - frames.append({ - "id": frame_id, - "name": name, - "source": {"path": debugger_path}, - "line": line, - "column": 1, - "endLine": line, - "endColumn": 1, - "presentationHint": hint - }) + frames.append( + { + "id": frame_id, + "name": name, + "source": {"path": debugger_path}, + "line": line, + "column": 1, + "endLine": line, + "endColumn": 1, + "presentationHint": hint, + } + ) # Cache frame for variable access self.variables_cache[frame_id] = frame # MicroPython doesn't have f_back attribute - if hasattr(frame, 'f_back'): + if hasattr(frame, "f_back"): frame = frame.f_back else: # Only return the current frame for MicroPython @@ -285,15 +350,15 @@ def get_scopes(self, frame_id): """Get variable scopes for a frame.""" scopes = [ { - "name": "Locals", + "name": SCOPE_LOCALS, "variablesReference": frame_id * 1000 + VARREF_LOCALS, - "expensive": False + "expensive": False, }, { - "name": "Globals", - "variablesReference": frame_id * 1000 + VARREF_GLOBALS , - "expensive": False - } + "name": SCOPE_GLOBALS, + "variablesReference": frame_id * 1000 + VARREF_GLOBALS, + "expensive": False, + }, ] return scopes @@ -301,16 +366,18 @@ def _process_special_variables(self, var_dict): """Process special variables (those starting and ending with __).""" variables = [] for name, value in var_dict.items(): - if name.startswith('__') and name.endswith('__'): + if name.startswith("__") and name.endswith("__"): try: value_str = json.dumps(value) type_str = type(value).__name__ - variables.append({ - "name": name, - "value": value_str, - "type": type_str, - "variablesReference": 0 - }) + variables.append( + { + "name": name, + "value": value_str, + "type": type_str, + "variablesReference": 0, + } + ) except Exception: variables.append(self._var_error(name)) return variables @@ -320,31 +387,163 @@ def _process_regular_variables(self, var_dict): variables = [] for name, value in var_dict.items(): # Skip private/internal variables - if name.startswith('__') and name.endswith('__'): + if name.startswith("__") and name.endswith("__"): continue + variables.append(self._get_variable_info(name, value)) + return variables + + def _is_expandable(self, value: Any) -> bool: + """Check if a variable can be expanded (has child elements).""" + return isinstance(value, (dict, list, tuple, set)) + + def _get_preview(self, value: Any, fallback_text: str = "") -> str: + """Get a truncated preview of a variable value.""" + try: + if value is None: + return "None" + + # Try to get a meaningful representation + preview_repr = repr(value) + if len(preview_repr) > 30: + return preview_repr[:30] + "..." + else: + return preview_repr + except (TypeError, ValueError): + # If repr fails, try str try: - value_str = json.dumps(value) - type_str = type(value).__name__ - variables.append({ - "name": name, - "value": value_str, - "type": type_str, - "variablesReference": 0 - }) - except Exception: - variables.append(self._var_error(name)) + preview_str = str(value) + if len(preview_str) > 30: + return preview_str[:30] + "..." + else: + return preview_str + except: + # Final fallback + return fallback_text or f"<{type(value).__name__} object>" + + def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: + """Get DAP-compliant variable information with proper type handling.""" + try: + # Handle expandable types + if self._is_expandable(value): + var_ref = self.var_cache.add_variable(value) + + if isinstance(value, dict): + preview = ( + self._get_preview(value, f"dict({len(value)} items)") + if value + else "dict(empty)" + ) + return { + "name": name, + "value": preview, + "type": "dict", + "variablesReference": var_ref, + "namedVariables": len(value), + "indexedVariables": 0, + } + elif isinstance(value, list): + preview = ( + self._get_preview(value, f"list({len(value)} items)") + if value + else "list(empty)" + ) + return { + "name": name, + "value": preview, + "type": "list", + "variablesReference": var_ref, + "indexedVariables": len(value), + "namedVariables": 0, + } + elif isinstance(value, tuple): + preview = ( + self._get_preview(value, f"tuple({len(value)} items)") + if value + else "tuple(empty)" + ) + return { + "name": name, + "value": preview, + "type": "tuple", + "variablesReference": var_ref, + "indexedVariables": len(value), + "namedVariables": 0, + } + elif isinstance(value, set): + preview = ( + self._get_preview(value, f"set({len(value)} items)") + if value + else "set(empty)" + ) + return { + "name": name, + "value": preview, + "type": "set", + "variablesReference": var_ref, + "indexedVariables": len(value), + "namedVariables": 0, + } + + # Simple types - use the preview helper + preview = self._get_preview(value) + + return { + "name": name, + "value": preview, + "type": type(value).__name__, + "variablesReference": 0, + } + except Exception: + return self._var_error(name) + + def _expand_complex_variable(self, ref_id: int) -> list[dict[str, str | int]]: + """Expand a complex variable into its child elements.""" + value = self.var_cache.get_variable(ref_id) + if value is None: + return [] + + variables = [] + try: + if isinstance(value, dict): + # Handle dictionary keys and values + for key, val in value.items(): + key_str = str(key) + variables.append(self._get_variable_info(key_str, val)) + elif isinstance(value, (list, tuple)): + # Handle list/tuple elements + for i, val in enumerate(value): + variables.append(self._get_variable_info(f"[{i}]", val)) + elif isinstance(value, set): + # Handle set elements (sorted for consistent display) + for i, val in enumerate(sorted(value, key=lambda x: str(x))): + variables.append(self._get_variable_info(f"<{i}>", val)) + except Exception as e: + # Return error info for debugging + variables.append( + { + "name": "error", + "value": f"Failed to expand: {e}", + "type": "error", + "variablesReference": 0, + } + ) + return variables @staticmethod - def _var_error(name:str): - return {"name": name, "value": "", "type": "unknown", "variablesReference": 0 } + def _var_error(name: str): + return {"name": name, "value": "", "type": "unknown", "variablesReference": 0} @staticmethod - def _special_vars(varref:int): + def _special_vars(varref: int): return {"name": "Special", "value": "", "variablesReference": varref} def get_variables(self, variables_ref): - """Get variables for a scope.""" + """Get variables for a scope with enhanced complex variable support.""" + # Handle complex variable expansion + if variables_ref >= VARREF_COMPLEX_BASE: + return self._expand_complex_variable(variables_ref) + frame_id = variables_ref // 1000 scope_type = variables_ref % 1000 @@ -355,25 +554,25 @@ def get_variables(self, variables_ref): # Handle special scope types first if scope_type == VARREF_LOCALS_SPECIAL: - var_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} + var_dict = frame.f_locals if hasattr(frame, "f_locals") else {} return self._process_special_variables(var_dict) elif scope_type == VARREF_GLOBALS_SPECIAL: - var_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} + var_dict = frame.f_globals if hasattr(frame, "f_globals") else {} return self._process_special_variables(var_dict) # Handle regular scope types with special folder variables = [] if scope_type == VARREF_LOCALS: - var_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} - variables.append(self._special_vars( VARREF_LOCALS_SPECIAL)) + var_dict = frame.f_locals if hasattr(frame, "f_locals") else {} + variables.append(self._special_vars(frame_id * 1000 + VARREF_LOCALS_SPECIAL)) elif scope_type == VARREF_GLOBALS: - var_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} - variables.append(self._special_vars( VARREF_GLOBALS_SPECIAL)) + var_dict = frame.f_globals if hasattr(frame, "f_globals") else {} + variables.append(self._special_vars(frame_id * 1000 + VARREF_GLOBALS_SPECIAL)) else: # Invalid reference, return empty return [] - # Add regular variables + # Add regular variables with enhanced processing variables.extend(self._process_regular_variables(var_dict)) return variables @@ -381,14 +580,14 @@ def evaluate_expression(self, expression, frame_id=None): """Evaluate an expression in the context of a frame.""" if frame_id is not None and frame_id in self.variables_cache: frame = self.variables_cache[frame_id] - globals_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} - locals_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} + globals_dict = frame.f_globals if hasattr(frame, "f_globals") else {} + locals_dict = frame.f_locals if hasattr(frame, "f_locals") else {} else: # Use current frame frame = self.current_frame if frame: - globals_dict = frame.f_globals if hasattr(frame, 'f_globals') else {} - locals_dict = frame.f_locals if hasattr(frame, 'f_locals') else {} + globals_dict = frame.f_globals if hasattr(frame, "f_globals") else {} + locals_dict = frame.f_locals if hasattr(frame, "f_locals") else {} else: globals_dict = globals() locals_dict = {} @@ -400,8 +599,9 @@ def evaluate_expression(self, expression, frame_id=None): raise Exception(f"Evaluation error: {e}") def cleanup(self): - """Clean up resources.""" + """Clean up resources with enhanced cache management.""" self.variables_cache.clear() + self.var_cache.clear() # Clear variable reference cache self.breakpoints.clear() - if hasattr(sys, 'settrace'): + if hasattr(sys, "settrace"): sys.settrace(None) From 16de3879ceb131f1e380038a39471e21a8c93fa0 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Mon, 30 Jun 2025 23:43:26 +0200 Subject: [PATCH 16/31] debugpy: Format code. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/common/messaging.py | 14 +- python-ecosys/debugpy/debugpy/public_api.py | 17 +-- .../debugpy/debugpy/server/debug_session.py | 124 +++++++++++------- 3 files changed, 94 insertions(+), 61 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/common/messaging.py b/python-ecosys/debugpy/debugpy/common/messaging.py index 7a588bab3..a491578ad 100644 --- a/python-ecosys/debugpy/debugpy/common/messaging.py +++ b/python-ecosys/debugpy/debugpy/common/messaging.py @@ -64,7 +64,9 @@ def send_response(self, command, request_seq, success=True, body=None, message=N if message is not None: kwargs["message"] = message - self._debug_print(f"[DAP] SEND: response {command} (req_seq={request_seq}, success={success})") + self._debug_print( + f"[DAP] SEND: response {command} (req_seq={request_seq}, success={success})" + ) if body: self._debug_print(f"[DAP] body: {body}") if message: @@ -95,14 +97,14 @@ def recv_message(self): self._recv_buffer += data except OSError as e: # Handle timeout and other socket errors - if hasattr(e, 'errno') and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK + if hasattr(e, "errno") and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK return None # No data available self.closed = True return None header_end = self._recv_buffer.find(b"\r\n\r\n") header_str = self._recv_buffer[:header_end].decode("utf-8") - self._recv_buffer = self._recv_buffer[header_end + 4:] + self._recv_buffer = self._recv_buffer[header_end + 4 :] # Parse Content-Length content_length = 0 @@ -123,7 +125,7 @@ def recv_message(self): return None self._recv_buffer += data except OSError as e: - if hasattr(e, 'errno') and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK + if hasattr(e, "errno") and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK return None self.closed = True return None @@ -134,7 +136,9 @@ def recv_message(self): # Parse JSON try: message = json.loads(body.decode("utf-8")) - self._debug_print(f"[DAP] Successfully received message: {message.get('type')} {message.get('command', message.get('event', 'unknown'))}") + self._debug_print( + f"[DAP] Successfully received message: {message.get('type')} {message.get('command', message.get('event', 'unknown'))}" + ) return message except (ValueError, UnicodeDecodeError) as e: print(f"[DAP] JSON parse error: {e}") diff --git a/python-ecosys/debugpy/debugpy/public_api.py b/python-ecosys/debugpy/debugpy/public_api.py index c8f1363e9..06b928965 100644 --- a/python-ecosys/debugpy/debugpy/public_api.py +++ b/python-ecosys/debugpy/debugpy/public_api.py @@ -11,11 +11,11 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): """Start listening for debugger connections. - + Args: port: Port number to listen on (default: 5678) host: Host address to bind to (default: "127.0.0.1") - + Returns: (host, port) tuple of the actual listening address """ @@ -52,7 +52,7 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): # Handle just the initialize request, then return immediately print("[DAP] Waiting for initialize request...") init_message = _debug_session.channel.recv_message() - if init_message and init_message.get('command') == 'initialize': + if init_message and init_message.get("command") == "initialize": _debug_session._handle_message(init_message) print("[DAP] Initialize request handled - returning control immediately") else: @@ -74,6 +74,7 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): return (host, port) + def format_client_addr(client_addr): """Format client address using socket module methods""" if isinstance(client_addr, (tuple, list)): @@ -81,7 +82,7 @@ def format_client_addr(client_addr): return f"{client_addr[0]}:{client_addr[1]}" elif isinstance(client_addr, bytes) and len(client_addr) >= 8: # Extract port (bytes 2-4, network byte order) - port = struct.unpack('!H', client_addr[2:4])[0] + port = struct.unpack("!H", client_addr[2:4])[0] # Extract IP address (bytes 4-8) using inet_ntoa ip_packed = client_addr[4:8] try: @@ -90,11 +91,12 @@ def format_client_addr(client_addr): return f"{ip_addr}:{port}" except: # Fallback if inet_ntoa not available (MicroPython) - ip_addr = '.'.join(str(b) for b in ip_packed) + ip_addr = ".".join(str(b) for b in ip_packed) return f"{ip_addr}:{port}" else: return str(client_addr) + def wait_for_client(): """Wait for the debugger client to connect and initialize.""" global _debug_session @@ -109,7 +111,7 @@ def breakpoint(): _debug_session.trigger_breakpoint() else: # Fallback to built-in breakpoint if available - if hasattr(__builtins__, 'breakpoint'): + if hasattr(__builtins__, "breakpoint"): __builtins__.breakpoint() @@ -120,7 +122,7 @@ def debug_this_thread(): _debug_session.debug_this_thread() else: # Install trace function even if no session yet - if hasattr(sys, 'settrace'): + if hasattr(sys, "settrace"): sys.settrace(_default_trace_func) else: raise RuntimeError("MICROPY_PY_SYS_SETTRACE required") @@ -132,7 +134,6 @@ def _default_trace_func(frame, event, arg): return None - def is_client_connected(): """Check if a debugger client is connected.""" global _debug_session diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index a2a00c170..c7553a604 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -3,12 +3,34 @@ import sys from ..common.messaging import JsonMessageChannel from ..common.constants import ( - CMD_INITIALIZE, CMD_LAUNCH, CMD_ATTACH, CMD_SET_BREAKPOINTS, - CMD_CONTINUE, CMD_NEXT, CMD_STEP_IN, CMD_STEP_OUT, CMD_PAUSE, - CMD_STACK_TRACE, CMD_SCOPES, CMD_VARIABLES, CMD_EVALUATE, CMD_DISCONNECT, - CMD_CONFIGURATION_DONE, CMD_THREADS, CMD_SOURCE, EVENT_INITIALIZED, EVENT_STOPPED, EVENT_CONTINUED, EVENT_TERMINATED, - STOP_REASON_BREAKPOINT, STOP_REASON_STEP, STOP_REASON_PAUSE, - TRACE_CALL, TRACE_LINE, TRACE_RETURN, TRACE_EXCEPTION + CMD_INITIALIZE, + CMD_LAUNCH, + CMD_ATTACH, + CMD_SET_BREAKPOINTS, + CMD_CONTINUE, + CMD_NEXT, + CMD_STEP_IN, + CMD_STEP_OUT, + CMD_PAUSE, + CMD_STACK_TRACE, + CMD_SCOPES, + CMD_VARIABLES, + CMD_EVALUATE, + CMD_DISCONNECT, + CMD_CONFIGURATION_DONE, + CMD_THREADS, + CMD_SOURCE, + EVENT_INITIALIZED, + EVENT_STOPPED, + EVENT_CONTINUED, + EVENT_TERMINATED, + STOP_REASON_BREAKPOINT, + STOP_REASON_STEP, + STOP_REASON_PAUSE, + TRACE_CALL, + TRACE_LINE, + TRACE_RETURN, + TRACE_EXCEPTION, ) from .pdb_adapter import PdbAdapter @@ -34,7 +56,7 @@ def _debug_print(self, message): @property def _baremetal(self) -> bool: - return sys.platform not in ("linux") # to be expanded + return sys.platform not in ("linux") # to be expanded def start(self): """Start the debug session message loop.""" @@ -77,7 +99,7 @@ def initialize_connection(self): message_count += 1 # Just wait for attach, then we can return control - if message.get('command') == 'attach': + if message.get("command") == "attach": attached = True print("[DAP] ✅ Attach received - returning control to main thread") break @@ -191,12 +213,12 @@ def _handle_request(self, message): elif command == CMD_SOURCE: self._handle_source(seq, args) else: - self.channel.send_response(command, seq, success=False, - message=f"Unknown command: {command}") + self.channel.send_response( + command, seq, success=False, message=f"Unknown command: {command}" + ) except Exception as e: - self.channel.send_response(command, seq, success=False, - message=str(e)) + self.channel.send_response(command, seq, success=False, message=str(e)) def _handle_initialize(self, seq, args): """Handle initialize request.""" @@ -251,16 +273,15 @@ def _handle_attach(self, seq, args): self.debug_logging = args.get("logToFile", False) self._debug_print(f"[DAP] Processing attach request with args: {args}") - print(f"[DAP] Debug logging {'enabled' if self.debug_logging else 'disabled'} (logToFile={self.debug_logging})") - + print( + f"[DAP] Debug logging {'enabled' if self.debug_logging else 'disabled'} (logToFile={self.debug_logging})" + ) + # get debugger root and debugee root from pathMappings - for pm in args.get("pathMappings",[]): + for pm in args.get("pathMappings", []): # debugee - debugger - self.pdb.path_mappings.append( - (pm.get("remoteRoot", "./"), - pm.get("localRoot", "./")) - ) - # # TODO: justMyCode, debugOptions , + self.pdb.path_mappings.append((pm.get("remoteRoot", "./"), pm.get("localRoot", "./"))) + # # TODO: justMyCode, debugOptions , # Enable trace function self.pdb.set_trace_function(self._trace_function) @@ -282,8 +303,9 @@ def _handle_set_breakpoints(self, seq, args): # Set breakpoints in pdb adapter actual_breakpoints = self.pdb.set_breakpoints(filename, breakpoints) - self.channel.send_response(CMD_SET_BREAKPOINTS, seq, - body={"breakpoints": actual_breakpoints}) + self.channel.send_response( + CMD_SET_BREAKPOINTS, seq, body={"breakpoints": actual_breakpoints} + ) def _handle_continue(self, seq, args): """Handle continue request.""" @@ -322,8 +344,11 @@ def _handle_pause(self, seq, args): def _handle_stack_trace(self, seq, args): """Handle stackTrace request.""" stack_frames = self.pdb.get_stack_trace() - self.channel.send_response(CMD_STACK_TRACE, seq, - body={"stackFrames": stack_frames, "totalFrames": len(stack_frames)}) + self.channel.send_response( + CMD_STACK_TRACE, + seq, + body={"stackFrames": stack_frames, "totalFrames": len(stack_frames)}, + ) def _handle_scopes(self, seq, args): """Handle scopes request.""" @@ -345,18 +370,17 @@ def _handle_evaluate(self, seq, args): frame_id = args.get("frameId") context = args.get("context", "watch") if not expression: - self.channel.send_response(CMD_EVALUATE, seq, success=False, - message="No expression provided") + self.channel.send_response( + CMD_EVALUATE, seq, success=False, message="No expression provided" + ) return try: result = self.pdb.evaluate_expression(expression, frame_id) - self.channel.send_response(CMD_EVALUATE, seq, body={ - "result": str(result), - "variablesReference": 0 - }) + self.channel.send_response( + CMD_EVALUATE, seq, body={"result": str(result), "variablesReference": 0} + ) except Exception as e: - self.channel.send_response(CMD_EVALUATE, seq, success=False, - message=str(e)) + self.channel.send_response(CMD_EVALUATE, seq, success=False, message=str(e)) def _handle_disconnect(self, seq, args): """Handle disconnect request.""" @@ -372,10 +396,7 @@ def _handle_configuration_done(self, seq, args): def _handle_threads(self, seq, args): """Handle threads request.""" # MicroPython is single-threaded, so return one thread - threads = [{ - "id": self.thread_id, - "name": "main" - }] + threads = [{"id": self.thread_id, "name": "main"}] self.channel.send_response(CMD_THREADS, seq, body={"threads": threads}) def _handle_source(self, seq, args): @@ -395,10 +416,13 @@ def _handle_source(self, seq, args): content = f.read() self.channel.send_response(CMD_SOURCE, seq, body={"content": content}) except Exception: - self.channel.send_response(CMD_SOURCE, seq, success=False, - message="cancelled" - # message=f"Could not read source: {e}" - ) + self.channel.send_response( + CMD_SOURCE, + seq, + success=False, + message="cancelled", + # message=f"Could not read source: {e}" + ) def _trace_function(self, frame, event, arg): """Trace function called by sys.settrace.""" @@ -407,8 +431,13 @@ def _trace_function(self, frame, event, arg): # Handle breakpoints and stepping if self.pdb.should_stop(frame, event, arg): - self._send_stopped_event(STOP_REASON_BREAKPOINT if self.pdb.hit_breakpoint else - STOP_REASON_STEP if self.stepping else STOP_REASON_PAUSE) + self._send_stopped_event( + STOP_REASON_BREAKPOINT + if self.pdb.hit_breakpoint + else STOP_REASON_STEP + if self.stepping + else STOP_REASON_PAUSE + ) # Wait for continue command self.pdb.wait_for_continue() @@ -416,10 +445,9 @@ def _trace_function(self, frame, event, arg): def _send_stopped_event(self, reason): """Send stopped event to client.""" - self.channel.send_event(EVENT_STOPPED, - reason=reason, - threadId=self.thread_id, - allThreadsStopped=True) + self.channel.send_event( + EVENT_STOPPED, reason=reason, threadId=self.thread_id, allThreadsStopped=True + ) def wait_for_client(self): """Wait for client to initialize.""" @@ -433,7 +461,7 @@ def trigger_breakpoint(self): def debug_this_thread(self): """Enable debugging for current thread.""" - if hasattr(sys, 'settrace'): + if hasattr(sys, "settrace"): sys.settrace(self._trace_function) def is_connected(self): @@ -443,7 +471,7 @@ def is_connected(self): def disconnect(self): """Disconnect from client.""" self.connected = False - if hasattr(sys, 'settrace'): + if hasattr(sys, "settrace"): sys.settrace(None) self.pdb.cleanup() self.channel.close() From 3c5fb37bf9bc7c5f269dd2c3de650c58709f61c7 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 1 Jul 2025 00:15:55 +0200 Subject: [PATCH 17/31] debugpy : Optimize memory management and performance in PDB adapter. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/pdb_adapter.py | 370 ++++++++++++++---- 1 file changed, 303 insertions(+), 67 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 11b4dfb81..46ff5a362 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -51,16 +51,22 @@ def get_variable(self, ref_id: int): # -> Optional[Any] return self.cache.get(ref_id) def _cleanup_oldest(self) -> None: - """Remove oldest entries to free memory.""" - if self.cache and self.insertion_order: - # Remove first quarter of entries (true FIFO based on insertion order) - to_remove = max(1, len(self.cache) // 4) # Remove at least 1 entry - keys_to_remove = self.insertion_order[:to_remove] - for key in keys_to_remove: - if key in self.cache: - del self.cache[key] - # Update insertion order - self.insertion_order = self.insertion_order[to_remove:] + """Remove oldest entries to free memory - optimized for MicroPython.""" + if not self.cache or not self.insertion_order: + return + + # More aggressive cleanup for memory-constrained environments + to_remove = max(1, len(self.cache) // 3) # Remove 1/3 instead of 1/4 + + # Direct list slicing is more memory efficient than iteration + keys_to_remove = self.insertion_order[:to_remove] + + # Batch delete for efficiency + for key in keys_to_remove: + self.cache.pop(key, None) # Use pop with default to avoid KeyError + + # Update insertion order in one operation + self.insertion_order = self.insertion_order[to_remove:] def clear(self) -> None: """Clear all cached variables.""" @@ -368,7 +374,8 @@ def _process_special_variables(self, var_dict): for name, value in var_dict.items(): if name.startswith("__") and name.endswith("__"): try: - value_str = json.dumps(value) + # Use lightweight serialization instead of json.dumps + value_str = self._lightweight_serialize(value) type_str = type(value).__name__ variables.append( { @@ -383,13 +390,14 @@ def _process_special_variables(self, var_dict): return variables def _process_regular_variables(self, var_dict): - """Process regular variables (excluding special ones).""" + """Process regular variables (excluding special ones) - optimized.""" variables = [] for name, value in var_dict.items(): # Skip private/internal variables if name.startswith("__") and name.endswith("__"): continue - variables.append(self._get_variable_info(name, value)) + # Use fast path for variable info generation + variables.append(self._get_variable_info_fast(name, value)) return variables def _is_expandable(self, value: Any) -> bool: @@ -397,28 +405,100 @@ def _is_expandable(self, value: Any) -> bool: return isinstance(value, (dict, list, tuple, set)) def _get_preview(self, value: Any, fallback_text: str = "") -> str: - """Get a truncated preview of a variable value.""" + """Get a truncated preview of a variable value - optimized for MicroPython.""" try: if value is None: return "None" - # Try to get a meaningful representation - preview_repr = repr(value) - if len(preview_repr) > 30: - return preview_repr[:30] + "..." - else: - return preview_repr - except (TypeError, ValueError): - # If repr fails, try str - try: - preview_str = str(value) - if len(preview_str) > 30: - return preview_str[:30] + "..." + # Fast path for common types to avoid repr() overhead + if isinstance(value, bool): + return "True" if value else "False" + elif isinstance(value, int): + return str(value) + elif isinstance(value, float): + # Limit float precision to reduce string length + return f"{value:.6g}" + elif isinstance(value, str): + if len(value) > 30: + return value[:30] + "..." + else: + return repr(value) # Only use repr for short strings + + # For collections, show actual content if small, otherwise use lightweight approach + elif isinstance(value, dict): + if len(value) == 0: + return "{}" + elif len(value) <= 3: + # Show actual content for small dictionaries + try: + repr_val = repr(value) + if len(repr_val) <= 60: + return repr_val + else: + # Fallback to key list if repr is too long + keys = list(value.keys())[:3] + key_str = ", ".join(repr(k) for k in keys) + return f"{{{key_str}}}" + except: + return f"dict({len(value)} items)" + else: + return f"dict({len(value)} items)" + elif isinstance(value, (list, tuple)): + if len(value) == 0: + return "[]" if isinstance(value, list) else "()" + elif len(value) <= 4: + # Show actual content for small lists/tuples + try: + repr_val = repr(value) + if len(repr_val) <= 60: + return repr_val + else: + # Fallback to item preview if repr is too long + items = [str(item)[:10] for item in value[:3]] + bracket = "[]" if isinstance(value, list) else "()" + return f"{bracket[0]}{', '.join(items)}...{bracket[1]}" + except: + type_name = type(value).__name__ + return f"{type_name}({len(value)} items)" else: - return preview_str + type_name = type(value).__name__ + return f"{type_name}({len(value)} items)" + elif isinstance(value, set): + if len(value) == 0: + return "set()" + elif len(value) <= 4: + # Show actual content for small sets + try: + repr_val = repr(value) + if len(repr_val) <= 60: + return repr_val + else: + # Fallback to item preview + items = [str(item)[:10] for item in list(value)[:3]] + return f"{{{', '.join(items)}...}}" + except: + return f"set({len(value)} items)" + else: + return f"set({len(value)} items)" + + # For other complex types, use lightweight approach + type_name = type(value).__name__ + try: + if hasattr(value, '__len__'): + length = len(value) # type: ignore + if length == 0: + return f"{type_name}(empty)" + else: + return f"{type_name}({length} items)" except: - # Final fallback - return fallback_text or f"<{type(value).__name__} object>" + pass + + # Final fallback - avoid expensive repr() for complex objects + return f"<{type_name} object>" + + except (TypeError, ValueError, MemoryError): + # Memory-safe fallback + return fallback_text or f"<{type(value).__name__} object>" def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: """Get DAP-compliant variable information with proper type handling.""" @@ -428,11 +508,15 @@ def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: var_ref = self.var_cache.add_variable(value) if isinstance(value, dict): - preview = ( - self._get_preview(value, f"dict({len(value)} items)") - if value - else "dict(empty)" - ) + # Show actual content for small dicts, generic preview for large ones + if len(value) == 0: + preview = "dict(empty)" + elif len(value) <= 3: + # Show actual keys for small dictionaries + preview = self._get_preview(value) + else: + preview = f"dict({len(value)} items)" + return { "name": name, "value": preview, @@ -442,11 +526,15 @@ def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: "indexedVariables": 0, } elif isinstance(value, list): - preview = ( - self._get_preview(value, f"list({len(value)} items)") - if value - else "list(empty)" - ) + # Show actual content for small lists, generic preview for large ones + if len(value) == 0: + preview = "list(empty)" + elif len(value) <= 4: + # Show actual items for small lists + preview = self._get_preview(value) + else: + preview = f"list({len(value)} items)" + return { "name": name, "value": preview, @@ -456,11 +544,14 @@ def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: "namedVariables": 0, } elif isinstance(value, tuple): - preview = ( - self._get_preview(value, f"tuple({len(value)} items)") - if value - else "tuple(empty)" - ) + # Show actual content for small tuples + if len(value) == 0: + preview = "tuple(empty)" + elif len(value) <= 4: + preview = self._get_preview(value) + else: + preview = f"tuple({len(value)} items)" + return { "name": name, "value": preview, @@ -470,11 +561,14 @@ def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: "namedVariables": 0, } elif isinstance(value, set): - preview = ( - self._get_preview(value, f"set({len(value)} items)") - if value - else "set(empty)" - ) + # Show actual content for small sets + if len(value) == 0: + preview = "set(empty)" + elif len(value) <= 4: + preview = self._get_preview(value) + else: + preview = f"set({len(value)} items)" + return { "name": name, "value": preview, @@ -496,8 +590,72 @@ def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: except Exception: return self._var_error(name) + def _get_variable_info_fast(self, name: str, value: Any) -> dict[str, str | int]: + """Fast path for variable info generation with reduced allocations.""" + try: + # Handle expandable types + if self._is_expandable(value): + var_ref = self.var_cache.add_variable(value) + type_name = type(value).__name__ + + # Use pre-calculated length for better performance + length = 0 + try: + length = len(value) # type: ignore + if length == 0: + preview = f"{type_name}(empty)" + else: + preview = f"{type_name}({length} items)" + except: + preview = f"<{type_name} object>" + + # Return optimized structure based on type + if isinstance(value, dict): + return { + "name": name, + "value": preview, + "type": "dict", + "variablesReference": var_ref, + "namedVariables": length if length < 1000 else 1000, # Cap for performance + "indexedVariables": 0, + } + elif isinstance(value, list): + return { + "name": name, + "value": preview, + "type": "list", + "variablesReference": var_ref, + "indexedVariables": min(length, 1000), # Cap for performance + "namedVariables": 0, + } + else: # tuple, set, other + return { + "name": name, + "value": preview, + "type": type_name, + "variablesReference": var_ref, + "indexedVariables": min(length, 1000), + "namedVariables": 0, + } + + # Simple types - optimized path + preview = self._get_preview(value) + return { + "name": name, + "value": preview, + "type": type(value).__name__, + "variablesReference": 0, + } + except Exception: + return { + "name": name, + "value": "", + "type": "unknown", + "variablesReference": 0 + } + def _expand_complex_variable(self, ref_id: int) -> list[dict[str, str | int]]: - """Expand a complex variable into its child elements.""" + """Expand a complex variable into its child elements - optimized for memory.""" value = self.var_cache.get_variable(ref_id) if value is None: return [] @@ -505,28 +663,53 @@ def _expand_complex_variable(self, ref_id: int) -> list[dict[str, str | int]]: variables = [] try: if isinstance(value, dict): - # Handle dictionary keys and values - for key, val in value.items(): - key_str = str(key) + # Limit dictionary expansion to prevent memory exhaustion + items = list(value.items()) + max_items = min(len(items), 50) # Limit to 50 items max + for i in range(max_items): + key, val = items[i] + key_str = str(key)[:50] # Limit key string length variables.append(self._get_variable_info(key_str, val)) + if len(items) > max_items: + variables.append({ + "name": f"<{len(items) - max_items} more items>", + "value": "...", + "type": "info", + "variablesReference": 0, + }) elif isinstance(value, (list, tuple)): - # Handle list/tuple elements - for i, val in enumerate(value): - variables.append(self._get_variable_info(f"[{i}]", val)) + # Limit list/tuple expansion + max_items = min(len(value), 100) # Limit to 100 items max + for i in range(max_items): + variables.append(self._get_variable_info(f"[{i}]", value[i])) + if len(value) > max_items: + variables.append({ + "name": f"<{len(value) - max_items} more items>", + "value": "...", + "type": "info", + "variablesReference": 0, + }) elif isinstance(value, set): - # Handle set elements (sorted for consistent display) - for i, val in enumerate(sorted(value, key=lambda x: str(x))): - variables.append(self._get_variable_info(f"<{i}>", val)) + # Handle set elements with size limit + items = list(value) # Convert once + max_items = min(len(items), 50) + for i in range(max_items): + variables.append(self._get_variable_info(f"<{i}>", items[i])) + if len(items) > max_items: + variables.append({ + "name": f"<{len(items) - max_items} more items>", + "value": "...", + "type": "info", + "variablesReference": 0, + }) except Exception as e: # Return error info for debugging - variables.append( - { - "name": "error", - "value": f"Failed to expand: {e}", - "type": "error", - "variablesReference": 0, - } - ) + variables.append({ + "name": "error", + "value": f"Failed to expand: {str(e)[:50]}", # Limit error message length + "type": "error", + "variablesReference": 0, + }) return variables @@ -605,3 +788,56 @@ def cleanup(self): self.breakpoints.clear() if hasattr(sys, "settrace"): sys.settrace(None) + + def _lightweight_serialize(self, value): + """Lightweight serialization optimized for MicroPython memory constraints.""" + if value is None: + return "None" + elif isinstance(value, bool): + return "true" if value else "false" + elif isinstance(value, (int, float)): + return str(value) + elif isinstance(value, str): + # Simple escaping for strings - avoid full JSON complexity + if len(value) > 30: + escaped = value[:27].replace('"', '\\"').replace('\n', '\\n') + return f'"{escaped}..."' + else: + escaped = value.replace('"', '\\"').replace('\n', '\\n') + return f'"{escaped}"' + elif isinstance(value, (list, tuple)): + if len(value) == 0: + return "[]" if isinstance(value, list) else "()" + elif len(value) <= 3: + # Show small collections in full + items = [self._lightweight_serialize(item) for item in value] + brackets = "[]" if isinstance(value, list) else "()" + return f"{brackets[0]}{', '.join(items)}{brackets[1]}" + else: + # Show preview for large collections + preview = f"{type(value).__name__}({len(value)} items)" + return preview + elif isinstance(value, dict): + if len(value) == 0: + return "{}" + elif len(value) <= 2: + # Show small dicts in preview form + items = [] + for k, v in value.items(): + key_str = self._lightweight_serialize(k) + val_str = self._lightweight_serialize(v) + items.append(f"{key_str}: {val_str}") + return "{" + ", ".join(items) + "}" + else: + return f"dict({len(value)} items)" + else: + # Fallback for other types + type_name = type(value).__name__ + try: + repr_val = repr(value) + if len(repr_val) > 30: + return f"<{type_name} object>" + else: + return repr_val + except: + return f"<{type_name} object>" From 3435c93eb0302dff6139c9a6286f20b9c657b404 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 1 Jul 2025 00:28:26 +0200 Subject: [PATCH 18/31] revert: Overly complex variable preview. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/pdb_adapter.py | 146 ++---------------- 1 file changed, 13 insertions(+), 133 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 4e8595449..9bee1c033 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -405,100 +405,17 @@ def _is_expandable(self, value: Any) -> bool: return isinstance(value, (dict, list, tuple, set)) def _get_preview(self, value: Any, fallback_text: str = "") -> str: - """Get a truncated preview of a variable value - optimized for MicroPython.""" + """Get a 30-char preview of a variable value with '...' if truncated - optimized for MicroPython.""" try: - if value is None: - return "None" - - # Fast path for common types to avoid repr() overhead - if isinstance(value, bool): - return "True" if value else "False" - elif isinstance(value, int): - return str(value) - elif isinstance(value, float): - # Limit float precision to reduce string length - return f"{value:.6g}" - elif isinstance(value, str): - if len(value) > 30: - return value[:30] + "..." - else: - return repr(value) # Only use repr for short strings - - # For collections, show actual content if small, otherwise use lightweight approach - elif isinstance(value, dict): - if len(value) == 0: - return "{}" - elif len(value) <= 3: - # Show actual content for small dictionaries - try: - repr_val = repr(value) - if len(repr_val) <= 60: - return repr_val - else: - # Fallback to key list if repr is too long - keys = list(value.keys())[:3] - key_str = ", ".join(repr(k) for k in keys) - return f"{{{key_str}}}" - except: - return f"dict({len(value)} items)" - else: - return f"dict({len(value)} items)" - elif isinstance(value, (list, tuple)): - if len(value) == 0: - return "[]" if isinstance(value, list) else "()" - elif len(value) <= 4: - # Show actual content for small lists/tuples - try: - repr_val = repr(value) - if len(repr_val) <= 60: - return repr_val - else: - # Fallback to item preview if repr is too long - items = [str(item)[:10] for item in value[:3]] - bracket = "[]" if isinstance(value, list) else "()" - return f"{bracket[0]}{', '.join(items)}...{bracket[1]}" - except: - type_name = type(value).__name__ - return f"{type_name}({len(value)} items)" - else: - type_name = type(value).__name__ - return f"{type_name}({len(value)} items)" - elif isinstance(value, set): - if len(value) == 0: - return "set()" - elif len(value) <= 4: - # Show actual content for small sets - try: - repr_val = repr(value) - if len(repr_val) <= 60: - return repr_val - else: - # Fallback to item preview - items = [str(item)[:10] for item in list(value)[:3]] - return f"{{{', '.join(items)}...}}" - except: - return f"set({len(value)} items)" - else: - return f"set({len(value)} items)" - - # For other complex types, use lightweight approach - type_name = type(value).__name__ - try: - if hasattr(value, '__len__'): - length = len(value) # type: ignore - if length == 0: - return f"{type_name}(empty)" - else: - return f"{type_name}({length} items)" - except: - pass - - # Final fallback - avoid expensive repr() for complex objects - return f"<{type_name} object>" - + # Get repr and truncate to exactly 30 chars with "..." if needed + repr_val = repr(value) + if len(repr_val) <= 30: + return repr_val + else: + return repr_val[:30] + "..." except (TypeError, ValueError, MemoryError): # Memory-safe fallback - return fallback_text or f"<{type(value).__name__} object>" + return fallback_text or f"<{type(value).__name__} object>"[:30] def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: """Get DAP-compliant variable information with proper type handling.""" @@ -506,17 +423,9 @@ def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: # Handle expandable types if self._is_expandable(value): var_ref = self.var_cache.add_variable(value) + preview = self._get_preview(value) # Always use consistent preview if isinstance(value, dict): - # Show actual content for small dicts, generic preview for large ones - if len(value) == 0: - preview = "dict(empty)" - elif len(value) <= 3: - # Show actual keys for small dictionaries - preview = self._get_preview(value) - else: - preview = f"dict({len(value)} items)" - return { "name": name, "value": preview, @@ -526,15 +435,6 @@ def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: "indexedVariables": 0, } elif isinstance(value, list): - # Show actual content for small lists, generic preview for large ones - if len(value) == 0: - preview = "list(empty)" - elif len(value) <= 4: - # Show actual items for small lists - preview = self._get_preview(value) - else: - preview = f"list({len(value)} items)" - return { "name": name, "value": preview, @@ -544,14 +444,6 @@ def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: "namedVariables": 0, } elif isinstance(value, tuple): - # Show actual content for small tuples - if len(value) == 0: - preview = "tuple(empty)" - elif len(value) <= 4: - preview = self._get_preview(value) - else: - preview = f"tuple({len(value)} items)" - return { "name": name, "value": preview, @@ -561,14 +453,6 @@ def _get_variable_info(self, name: str, value: Any) -> dict[str, str | int]: "namedVariables": 0, } elif isinstance(value, set): - # Show actual content for small sets - if len(value) == 0: - preview = "set(empty)" - elif len(value) <= 4: - preview = self._get_preview(value) - else: - preview = f"set({len(value)} items)" - return { "name": name, "value": preview, @@ -596,18 +480,14 @@ def _get_variable_info_fast(self, name: str, value: Any) -> dict[str, str | int] # Handle expandable types if self._is_expandable(value): var_ref = self.var_cache.add_variable(value) - type_name = type(value).__name__ + preview = self._get_preview(value) # Always use consistent preview # Use pre-calculated length for better performance length = 0 try: length = len(value) # type: ignore - if length == 0: - preview = f"{type_name}(empty)" - else: - preview = f"{type_name}({length} items)" except: - preview = f"<{type_name} object>" + pass # Return optimized structure based on type if isinstance(value, dict): @@ -632,7 +512,7 @@ def _get_variable_info_fast(self, name: str, value: Any) -> dict[str, str | int] return { "name": name, "value": preview, - "type": type_name, + "type": type(value).__name__, "variablesReference": var_ref, "indexedVariables": min(length, 1000), "namedVariables": 0, @@ -719,7 +599,7 @@ def _var_error(name: str): @staticmethod def _special_vars(varref: int): - return {"name": "Special", "value": "", "variablesReference": varref} + return {"name": "special", "value": "", "variablesReference": varref} def get_variables(self, variables_ref): """Get variables for a scope with enhanced complex variable support.""" From 5c41be68dfe6e4c9c62b90425d11e4808169f130 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 1 Jul 2025 12:56:41 +0200 Subject: [PATCH 19/31] debugpy: Performance improvements. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/common/constants.py | 89 ++++++++++--------- .../debugpy/debugpy/common/messaging.py | 45 +++++++--- .../debugpy/debugpy/server/debug_session.py | 10 ++- .../debugpy/debugpy/server/pdb_adapter.py | 59 +++++------- 4 files changed, 114 insertions(+), 89 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/common/constants.py b/python-ecosys/debugpy/debugpy/common/constants.py index aeee675e3..7f832eea8 100644 --- a/python-ecosys/debugpy/debugpy/common/constants.py +++ b/python-ecosys/debugpy/debugpy/common/constants.py @@ -1,60 +1,67 @@ """Constants used throughout debugpy.""" +from micropython import const # Default networking settings DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 5678 # DAP message types -MSG_TYPE_REQUEST = "request" -MSG_TYPE_RESPONSE = "response" -MSG_TYPE_EVENT = "event" +MSG_TYPE_REQUEST = const("request") +MSG_TYPE_RESPONSE = const("response") +MSG_TYPE_EVENT = const("event") # DAP events -EVENT_INITIALIZED = "initialized" -EVENT_STOPPED = "stopped" -EVENT_CONTINUED = "continued" -EVENT_THREAD = "thread" -EVENT_BREAKPOINT = "breakpoint" -EVENT_OUTPUT = "output" -EVENT_TERMINATED = "terminated" -EVENT_EXITED = "exited" +EVENT_INITIALIZED = const("initialized") +EVENT_STOPPED = const("stopped") +EVENT_CONTINUED = const("continued") +EVENT_THREAD = const("thread") +EVENT_BREAKPOINT = const("breakpoint") +EVENT_OUTPUT = const("output") +EVENT_TERMINATED = const("terminated") +EVENT_EXITED = const("exited") # DAP commands -CMD_INITIALIZE = "initialize" -CMD_LAUNCH = "launch" -CMD_ATTACH = "attach" -CMD_SET_BREAKPOINTS = "setBreakpoints" -CMD_CONTINUE = "continue" -CMD_NEXT = "next" -CMD_STEP_IN = "stepIn" -CMD_STEP_OUT = "stepOut" -CMD_PAUSE = "pause" -CMD_STACK_TRACE = "stackTrace" -CMD_SCOPES = "scopes" -CMD_VARIABLES = "variables" -CMD_EVALUATE = "evaluate" -CMD_DISCONNECT = "disconnect" -CMD_CONFIGURATION_DONE = "configurationDone" -CMD_THREADS = "threads" -CMD_SOURCE = "source" +CMD_INITIALIZE = const("initialize") +CMD_LAUNCH = const("launch") +CMD_ATTACH = const("attach") +CMD_SET_BREAKPOINTS = const("setBreakpoints") +CMD_CONTINUE = const("continue") +CMD_NEXT = const("next") +CMD_STEP_IN = const("stepIn") +CMD_STEP_OUT = const("stepOut") +CMD_PAUSE = const("pause") +CMD_STACK_TRACE = const("stackTrace") +CMD_SCOPES = const("scopes") +CMD_VARIABLES = const("variables") +CMD_EVALUATE = const("evaluate") +CMD_DISCONNECT = const("disconnect") +CMD_CONFIGURATION_DONE = const("configurationDone") +CMD_THREADS = const("threads") +CMD_SOURCE = const("source") # Stop reasons -STOP_REASON_STEP = "step" -STOP_REASON_BREAKPOINT = "breakpoint" -STOP_REASON_EXCEPTION = "exception" -STOP_REASON_PAUSE = "pause" -STOP_REASON_ENTRY = "entry" +STOP_REASON_STEP = const("step") +STOP_REASON_BREAKPOINT = const("breakpoint") +STOP_REASON_EXCEPTION = const("exception") +STOP_REASON_PAUSE = const("pause") +STOP_REASON_ENTRY = const("entry") # Thread reasons -THREAD_REASON_STARTED = "started" -THREAD_REASON_EXITED = "exited" +THREAD_REASON_STARTED = const("started") +THREAD_REASON_EXITED = const("exited") # Trace events -TRACE_CALL = "call" -TRACE_LINE = "line" -TRACE_RETURN = "return" -TRACE_EXCEPTION = "exception" +TRACE_CALL = const("call") +TRACE_LINE = const("line") +TRACE_RETURN = const("return") +TRACE_EXCEPTION = const("exception") + +# Step modes +STEP_INTO = const("into") +STEP_OVER = const("over") +STEP_OUT = const("out") + # Scope types -SCOPE_LOCALS = "locals" -SCOPE_GLOBALS = "globals" +SCOPE_LOCALS = const("locals") +SCOPE_GLOBALS = const("globals") diff --git a/python-ecosys/debugpy/debugpy/common/messaging.py b/python-ecosys/debugpy/debugpy/common/messaging.py index a491578ad..eb7df4dd2 100644 --- a/python-ecosys/debugpy/debugpy/common/messaging.py +++ b/python-ecosys/debugpy/debugpy/common/messaging.py @@ -86,15 +86,36 @@ def recv_message(self): if self.closed: return None + # Quick bail-out: if buffer is empty, do a non-blocking peek to see if data is available + if not self._recv_buffer: + try: + # Try to read a small amount non-blocking to see if anything is available + peek_data = self.sock.recv(1) + if not peek_data: + return None # No data available + # Put the peeked data back into buffer + self._recv_buffer = peek_data + except OSError as e: + # Handle non-blocking socket errors (no data available) + if hasattr(e, "errno") and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK + return None # No data available, quick exit + # Other errors + self.closed = True + return None + + # Cache frequently accessed attributes + recv_buffer = self._recv_buffer + sock_recv = self.sock.recv + try: # Read headers - while b"\r\n\r\n" not in self._recv_buffer: + while b"\r\n\r\n" not in recv_buffer: try: - data = self.sock.recv(1024) + data = sock_recv(1024) if not data: self.closed = True return None - self._recv_buffer += data + recv_buffer += data except OSError as e: # Handle timeout and other socket errors if hasattr(e, "errno") and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK @@ -102,9 +123,9 @@ def recv_message(self): self.closed = True return None - header_end = self._recv_buffer.find(b"\r\n\r\n") - header_str = self._recv_buffer[:header_end].decode("utf-8") - self._recv_buffer = self._recv_buffer[header_end + 4 :] + header_end = recv_buffer.find(b"\r\n\r\n") + header_str = recv_buffer[:header_end].decode("utf-8") + recv_buffer = recv_buffer[header_end + 4 :] # Parse Content-Length content_length = 0 @@ -114,24 +135,26 @@ def recv_message(self): break if content_length == 0: + self._recv_buffer = recv_buffer return None # Read body - while len(self._recv_buffer) < content_length: + while len(recv_buffer) < content_length: try: - data = self.sock.recv(content_length - len(self._recv_buffer)) + data = sock_recv(content_length - len(recv_buffer)) if not data: self.closed = True return None - self._recv_buffer += data + recv_buffer += data except OSError as e: if hasattr(e, "errno") and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK + self._recv_buffer = recv_buffer return None self.closed = True return None - body = self._recv_buffer[:content_length] - self._recv_buffer = self._recv_buffer[content_length:] + body = recv_buffer[:content_length] + self._recv_buffer = recv_buffer[content_length:] # Parse JSON try: diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index c7553a604..79f12b513 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -424,11 +424,13 @@ def _handle_source(self, seq, args): # message=f"Could not read source: {e}" ) - def _trace_function(self, frame, event, arg): + def _trace_function(self, frame, event:str, arg): """Trace function called by sys.settrace.""" + # https://docs.python.org/3/library/sys.html#sys.settrace + global _twiddel # Process any pending DAP messages frequently + self.process_pending_messages() - # Handle breakpoints and stepping if self.pdb.should_stop(frame, event, arg): self._send_stopped_event( @@ -441,6 +443,10 @@ def _trace_function(self, frame, event, arg): # Wait for continue command self.pdb.wait_for_continue() + # The trace function is invoked (with event set to 'call') whenever a new local scope is entered; + # it should return a reference to a local trace function to be used for the new scope, + # or None if the scope shouldn’t be traced. + return self._trace_function def _send_stopped_event(self, reason): diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 9bee1c033..40a8fc4d6 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -3,10 +3,13 @@ import sys import time import os -import json +from micropython import const Any = object from ..common.constants import ( + STEP_INTO, + STEP_OUT, + STEP_OVER, TRACE_CALL, TRACE_LINE, TRACE_RETURN, @@ -15,14 +18,14 @@ SCOPE_GLOBALS, ) -VARREF_LOCALS = 1 -VARREF_GLOBALS = 2 -VARREF_LOCALS_SPECIAL = 3 -VARREF_GLOBALS_SPECIAL = 4 +VARREF_LOCALS = const(1) +VARREF_GLOBALS = const(2) +VARREF_LOCALS_SPECIAL = const(3) +VARREF_GLOBALS_SPECIAL = const(4) # New constants for complex variable references -VARREF_COMPLEX_BASE = 10000 # Base for complex variable references -MAX_CACHE_SIZE = 50 # Limit cache size for memory constraints +VARREF_COMPLEX_BASE = const(10000) # Base for complex variable references +MAX_CACHE_SIZE = const(50) # Limit cache size for memory constraints class VariableReferenceCache: @@ -54,17 +57,12 @@ def _cleanup_oldest(self) -> None: """Remove oldest entries to free memory - optimized for MicroPython.""" if not self.cache or not self.insertion_order: return - - # More aggressive cleanup for memory-constrained environments - to_remove = max(1, len(self.cache) // 3) # Remove 1/3 instead of 1/4 - + to_remove = max(1, len(self.cache) // 3) # Direct list slicing is more memory efficient than iteration keys_to_remove = self.insertion_order[:to_remove] - # Batch delete for efficiency for key in keys_to_remove: self.cache.pop(key, None) # Use pop with default to avoid KeyError - # Update insertion order in one operation self.insertion_order = self.insertion_order[to_remove:] @@ -188,16 +186,8 @@ def set_breakpoints(self, filename: str, breakpoints: list[dict]): if local_name != filename: self.breakpoints[local_name] = {} self._debug_print(f"[>>>] Setting breakpoints for local: {local_name}:{line}") - self.breakpoints[local_name][line] = { - "line": line, - "verified": True, - "source": {"path": filename}, - } - self.breakpoints[filename][line] = { - "line": line, - "verified": True, - "source": {"path": filename}, - } + self.breakpoints[local_name][line] = {} + self.breakpoints[filename][line] = {} actual_breakpoints.append( {"line": line, "verified": True, "source": {"path": filename}} ) @@ -208,20 +198,19 @@ def set_breakpoints(self, filename: str, breakpoints: list[dict]): def should_stop(self, frame, event: str, arg): """Determine if execution should stop at this point.""" + # HOT path - no debug printing here self.current_frame = frame self.hit_breakpoint = False - # Get frame information - filename = frame.f_code.co_filename + # Cache frame attributes to reduce lookup overhead + frame_code = frame.f_code + filename = frame_code.co_filename lineno = frame.f_lineno + # Check for exact filename match first if filename in self.breakpoints and lineno in self.breakpoints[filename]: - self._debug_print(f"[PDB] HIT BREAKPOINT (exact match) at {filename}:{lineno}") - # Record the path mapping (in this case, they're already the same) - # self.file_mappings[filename] = self._filename_as_debugger(filename) self.hit_breakpoint = True return True - # path/file.py matched - but not the line number - keep running else: # file not (yet) matched - this is slow so we do not want to do this often. # TODO: use builins - sys.path method to find the file @@ -230,17 +219,17 @@ def should_stop(self, frame, event: str, arg): self.breakpoints[filename] = {} # Ensure the filename is in the breakpoints dict if not filename in self.file_mappings: self.file_mappings[filename] = self._filename_as_debugger(filename) - self._debug_print( - f"[PDB] add mapping for :'{filename}' -> '{self.file_mappings[filename]}'" - ) + # self._debug_print( + # f"[PDB] add mapping for :'{filename}' -> '{self.file_mappings[filename]}'" + # ) # Check stepping - if self.step_mode == "into": + if self.step_mode == STEP_INTO: if event in (TRACE_CALL, TRACE_LINE): self.step_mode = None return True - elif self.step_mode == "over": + elif self.step_mode == STEP_OVER: if event == TRACE_LINE and frame == self.step_frame: self.step_mode = None return True @@ -251,7 +240,7 @@ def should_stop(self, frame, event: str, arg): else: self.step_mode = None - elif self.step_mode == "out": + elif self.step_mode == STEP_OUT: if event == TRACE_RETURN and frame == self.step_frame: self.step_mode = None return True From 3bf4761ac1667a751c0284cacd4e60f6414c265b Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 1 Jul 2025 13:04:07 +0200 Subject: [PATCH 20/31] debugpy: Performance - aviod method access. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/pdb_adapter.py | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 40a8fc4d6..3064e54c4 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -203,33 +203,32 @@ def should_stop(self, frame, event: str, arg): self.hit_breakpoint = False # Cache frame attributes to reduce lookup overhead - frame_code = frame.f_code - filename = frame_code.co_filename - lineno = frame.f_lineno + _frame_code = frame.f_code + _filename = _frame_code.co_filename + _lineno = frame.f_lineno - # Check for exact filename match first - if filename in self.breakpoints and lineno in self.breakpoints[filename]: + # Optimize dictionary lookups - use .get() to avoid double lookup + file_breakpoints = self.breakpoints.get(_filename) + if file_breakpoints and _lineno in file_breakpoints: self.hit_breakpoint = True return True else: # file not (yet) matched - this is slow so we do not want to do this often. # TODO: use builins - sys.path method to find the file # if we have a path match , but no breakpoints - add it to the file_mappings dict simplify this check - if not filename in self.breakpoints: - self.breakpoints[filename] = {} # Ensure the filename is in the breakpoints dict - if not filename in self.file_mappings: - self.file_mappings[filename] = self._filename_as_debugger(filename) - # self._debug_print( - # f"[PDB] add mapping for :'{filename}' -> '{self.file_mappings[filename]}'" - # ) + if file_breakpoints is None: + self.breakpoints[_filename] = {} # Ensure the filename is in the breakpoints dict + if _filename not in self.file_mappings: + self.file_mappings[_filename] = self._filename_as_debugger(_filename) # Check stepping - if self.step_mode == STEP_INTO: + _step_mode = self.step_mode + if _step_mode == STEP_INTO: if event in (TRACE_CALL, TRACE_LINE): self.step_mode = None return True - elif self.step_mode == STEP_OVER: + elif _step_mode == STEP_OVER: if event == TRACE_LINE and frame == self.step_frame: self.step_mode = None return True @@ -240,7 +239,7 @@ def should_stop(self, frame, event: str, arg): else: self.step_mode = None - elif self.step_mode == STEP_OUT: + elif _step_mode == STEP_OUT: if event == TRACE_RETURN and frame == self.step_frame: self.step_mode = None return True From b45fba470fd3a4628d3c1f2d56fd77c856bf8ce1 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 1 Jul 2025 15:09:28 +0200 Subject: [PATCH 21/31] py-ecosys/debugpy: Add pause functionality. Signed-off-by: Jos Verlinde --- python-ecosys/debugpy/debugpy/server/pdb_adapter.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 3064e54c4..92cdca715 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -98,6 +98,7 @@ def __init__(self): self.step_mode = None # None, 'over', 'into', 'out' self.step_frame = None self.step_depth = 0 + self.paused = False self.hit_breakpoint = False self.continue_event = False self.variables_cache = {} # frameId -> variables @@ -202,6 +203,14 @@ def should_stop(self, frame, event: str, arg): self.current_frame = frame self.hit_breakpoint = False + # Get frame information + filename = frame.f_code.co_filename + lineno = frame.f_lineno + # Check for exact filename match first + if self.paused or filename in self.breakpoints and lineno in self.breakpoints[filename]: + self._debug_print(f"[PDB] HIT BREAKPOINT (exact match) at {filename}:{lineno}") + # Record the path mapping (in this case, they're already the same) + # self.file_mappings[filename] = self._filename_as_debugger(filename) # Cache frame attributes to reduce lookup overhead _frame_code = frame.f_code _filename = _frame_code.co_filename @@ -271,6 +280,7 @@ def step_out(self): def pause(self): """Pause execution at next opportunity.""" # This is handled by the debug session + self.paused = True def wait_for_continue(self): """Wait for continue command (simplified implementation).""" From 2f8e9b8777d3ec69ec0805c8b0fa3bcca83c511c Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 1 Jul 2025 16:54:17 +0200 Subject: [PATCH 22/31] python-ecosys/debugpy: Add set Variable functionality. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/debug_session.py | 82 ++++++++++++------- .../debugpy/debugpy/server/pdb_adapter.py | 47 +++++++++++ 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 79f12b513..b7a277bf4 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -15,6 +15,7 @@ CMD_STACK_TRACE, CMD_SCOPES, CMD_VARIABLES, + CMD_SET_VARIABLE, CMD_EVALUATE, CMD_DISCONNECT, CMD_CONFIGURATION_DONE, @@ -202,6 +203,8 @@ def _handle_request(self, message): self._handle_scopes(seq, args) elif command == CMD_VARIABLES: self._handle_variables(seq, args) + elif command == CMD_SET_VARIABLE: + self._handle_set_variable(seq, args) elif command == CMD_EVALUATE: self._handle_evaluate(seq, args) elif command == CMD_DISCONNECT: @@ -224,38 +227,39 @@ def _handle_initialize(self, seq, args): """Handle initialize request.""" capabilities = { "supportsConfigurationDoneRequest": True, - "supportsFunctionBreakpoints": False, - "supportsConditionalBreakpoints": False, - "supportsHitConditionalBreakpoints": False, "supportsEvaluateForHovers": True, - "supportsStepBack": False, - "supportsSetVariable": False, - "supportsRestartFrame": False, - "supportsGotoTargetsRequest": False, - "supportsStepInTargetsRequest": False, - "supportsCompletionsRequest": False, - "supportsModulesRequest": False, - "additionalModuleColumns": [], - "supportedChecksumAlgorithms": [], - "supportsRestartRequest": False, - "supportsExceptionOptions": False, - "supportsValueFormattingOptions": False, - "supportsExceptionInfoRequest": False, "supportTerminateDebuggee": True, "supportSuspendDebuggee": True, - "supportsDelayedStackTraceLoading": False, - "supportsLoadedSourcesRequest": False, - "supportsLogPoints": False, - "supportsTerminateThreadsRequest": False, - "supportsSetExpression": False, "supportsTerminateRequest": True, - "supportsDataBreakpoints": False, - "supportsReadMemoryRequest": False, - "supportsWriteMemoryRequest": False, - "supportsDisassembleRequest": False, - "supportsCancelRequest": False, - "supportsBreakpointLocationsRequest": False, - "supportsClipboardContext": False, + "supportsSetVariable": True, + + # "supportsFunctionBreakpoints": False, + # "supportsConditionalBreakpoints": False, + # "supportsHitConditionalBreakpoints": False, + # "supportsStepBack": False, + # "supportsRestartFrame": False, + # "supportsGotoTargetsRequest": False, + # "supportsStepInTargetsRequest": False, + # "supportsCompletionsRequest": False, + # "supportsModulesRequest": False, + # "additionalModuleColumns": [], + # "supportedChecksumAlgorithms": [], + # "supportsRestartRequest": False, + # "supportsExceptionOptions": False, + # "supportsValueFormattingOptions": False, + # "supportsExceptionInfoRequest": False, + # "supportsDelayedStackTraceLoading": False, + # "supportsLoadedSourcesRequest": False, + # "supportsLogPoints": False, + # "supportsTerminateThreadsRequest": False, + # "supportsSetExpression": False, + # "supportsDataBreakpoints": False, + # "supportsReadMemoryRequest": False, + # "supportsWriteMemoryRequest": False, + # "supportsDisassembleRequest": False, + # "supportsCancelRequest": False, + # "supportsBreakpointLocationsRequest": False, + # "supportsClipboardContext": False, } self.channel.send_response(CMD_INITIALIZE, seq, body=capabilities) @@ -364,6 +368,28 @@ def _handle_variables(self, seq, args): variables = self.pdb.get_variables(variables_ref) self.channel.send_response(CMD_VARIABLES, seq, body={"variables": variables}) + def _handle_set_variable(self, seq, args): + """Handle setVariable request.""" + variables_ref = args.get("variablesReference", 0) + name = args.get("name", "") + value = args.get("value", "") + + if not name: + self.channel.send_response( + CMD_SET_VARIABLE, seq, success=False, message="No variable name provided" + ) + return + + self._debug_print(f"[DAP] Processing setVariable request: name={name}, value={value}, ref={variables_ref}") + + try: + updated_variable = self.pdb.set_variable(variables_ref, name, value) + self.channel.send_response(CMD_SET_VARIABLE, seq, body=updated_variable) + except Exception as e: + self.channel.send_response( + CMD_SET_VARIABLE, seq, success=False, message=str(e) + ) + def _handle_evaluate(self, seq, args): """Handle evaluate request.""" expression = args.get("expression", "") diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 92cdca715..6e0fdcf9b 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -719,3 +719,50 @@ def _lightweight_serialize(self, value): return repr_val except: return f"<{type_name} object>" + + def set_variable(self, variables_ref: int, name: str, value: str) -> dict[str, str | int]: + """Set a variable to a new value and return the updated variable info.""" + # Handle complex variable references (not supported for setting) + if variables_ref >= VARREF_COMPLEX_BASE: + raise Exception("Cannot set variables in complex object expansions") + + frame_id = variables_ref // 1000 + scope_type = variables_ref % 1000 + + if frame_id not in self.variables_cache: + raise Exception("Invalid frame reference") + + frame = self.variables_cache[frame_id] + + # Determine the variable dictionary to modify + if scope_type == VARREF_LOCALS or scope_type == VARREF_LOCALS_SPECIAL: + var_dict = frame.f_locals if hasattr(frame, "f_locals") else {} + elif scope_type == VARREF_GLOBALS or scope_type == VARREF_GLOBALS_SPECIAL: + var_dict = frame.f_globals if hasattr(frame, "f_globals") else {} + else: + raise Exception("Invalid scope reference") + + # Check if variable exists + if name not in var_dict: + raise Exception(f"Variable '{name}' not found in the specified scope") + + try: + # Evaluate the new value in the context of the frame + globals_dict = frame.f_globals if hasattr(frame, "f_globals") else {} + locals_dict = frame.f_locals if hasattr(frame, "f_locals") else {} + + # Try to evaluate the value as a Python expression + try: + new_value = eval(value, globals_dict, locals_dict) + except: + # If evaluation fails, treat as string literal + new_value = value + + # Set the variable + var_dict[name] = new_value + + # Return the updated variable info + return self._get_variable_info(name, new_value) + + except Exception as e: + raise Exception(f"Failed to set variable '{name}': {str(e)}") From 8a45947a13a8d99609f084efb6747050f4295c53 Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 1 Jul 2025 18:09:22 +0200 Subject: [PATCH 23/31] python-ecosys/debugpy: Add local variable modification while debugging. Signed-off-by: Jos Verlinde (cherry picked from commit 215300dad99c2dab2adbf8d48e7044508b17b3e9) Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/common/constants.py | 1 + .../debugpy/debugpy/server/pdb_adapter.py | 78 +++++++++++++------ 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/common/constants.py b/python-ecosys/debugpy/debugpy/common/constants.py index 7f832eea8..bc8a4e382 100644 --- a/python-ecosys/debugpy/debugpy/common/constants.py +++ b/python-ecosys/debugpy/debugpy/common/constants.py @@ -33,6 +33,7 @@ CMD_STACK_TRACE = const("stackTrace") CMD_SCOPES = const("scopes") CMD_VARIABLES = const("variables") +CMD_SET_VARIABLE = const("setVariable") CMD_EVALUATE = const("evaluate") CMD_DISCONNECT = const("disconnect") CMD_CONFIGURATION_DONE = const("configurationDone") diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 6e0fdcf9b..442b54747 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -721,7 +721,14 @@ def _lightweight_serialize(self, value): return f"<{type_name} object>" def set_variable(self, variables_ref: int, name: str, value: str) -> dict[str, str | int]: - """Set a variable to a new value and return the updated variable info.""" + """Set a variable to a new value and return the updated variable info. + + This function can modify both global and local variables when using a MicroPython + build with settrace and local variable modification support (sys._set_local_var). + + For global variables: Works reliably on all MicroPython builds. + For local variables: Requires MicroPython build with C-level local variable support. + """ # Handle complex variable references (not supported for setting) if variables_ref >= VARREF_COMPLEX_BASE: raise Exception("Cannot set variables in complex object expansions") @@ -729,37 +736,64 @@ def set_variable(self, variables_ref: int, name: str, value: str) -> dict[str, s frame_id = variables_ref // 1000 scope_type = variables_ref % 1000 - if frame_id not in self.variables_cache: - raise Exception("Invalid frame reference") - - frame = self.variables_cache[frame_id] + # Only allow setting variables in the topmost frame (frame_id = 0) + if frame_id != 0: + raise Exception("Variable modification is only allowed in the topmost frame") - # Determine the variable dictionary to modify - if scope_type == VARREF_LOCALS or scope_type == VARREF_LOCALS_SPECIAL: - var_dict = frame.f_locals if hasattr(frame, "f_locals") else {} - elif scope_type == VARREF_GLOBALS or scope_type == VARREF_GLOBALS_SPECIAL: - var_dict = frame.f_globals if hasattr(frame, "f_globals") else {} - else: - raise Exception("Invalid scope reference") + # Use the current frame for modification + frame = self.current_frame + if frame is None: + raise Exception("No current frame available") - # Check if variable exists - if name not in var_dict: - raise Exception(f"Variable '{name}' not found in the specified scope") + # Get the appropriate variable contexts + globals_dict = frame.f_globals if hasattr(frame, "f_globals") else {} + locals_dict = frame.f_locals if hasattr(frame, "f_locals") else {} try: - # Evaluate the new value in the context of the frame - globals_dict = frame.f_globals if hasattr(frame, "f_globals") else {} - locals_dict = frame.f_locals if hasattr(frame, "f_locals") else {} - - # Try to evaluate the value as a Python expression + # Try to evaluate the new value as a Python expression try: new_value = eval(value, globals_dict, locals_dict) except: # If evaluation fails, treat as string literal new_value = value - # Set the variable - var_dict[name] = new_value + if scope_type == VARREF_GLOBALS or scope_type == VARREF_GLOBALS_SPECIAL: + # Check if variable exists in globals + if name not in globals_dict: + raise Exception(f"Global variable '{name}' not found") + + # For global variables, direct assignment works reliably + globals_dict[name] = new_value + self._debug_print(f"[PDB] Successfully set global variable '{name}' = {new_value}") + + elif scope_type == VARREF_LOCALS or scope_type == VARREF_LOCALS_SPECIAL: + # Check if variable exists in locals + if name not in locals_dict: + raise Exception(f"Local variable '{name}' not found") + + # Try to use the frame._set_local method to set local variables + try: + if hasattr(frame, '_set_local'): + # Use the frame._set_local method (CPython-compatible API) + frame._set_local(name, new_value) + self._debug_print(f"[PDB] Successfully set local variable '{name}' = {new_value}") + else: + # Fallback error if the method is not available + raise Exception( + f"Cannot modify local variable '{name}'. " + f"This MicroPython build doesn't support local variable modification. " + f"Please use a MicroPython build with settrace and local variable support." + ) + except Exception as inner_e: + # If frame.set_local fails, provide detailed error + raise Exception( + f"Failed to modify local variable '{name}': {str(inner_e)}. " + f"Local variables in MicroPython are stored in internal code_state->state[] slots. " + f"Consider using global variables for reliable modification during debugging." + ) + + else: + raise Exception("Invalid scope reference") # Return the updated variable info return self._get_variable_info(name, new_value) From 80ef0223abd2b3ea9f3aed47bdacf01b0ffcbcac Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 1 Jul 2025 22:21:43 +0200 Subject: [PATCH 24/31] python-ecosys/debugpy: Format code with ruff. Signed-off-by: Jos Verlinde --- python-ecosys/debugpy/dap_monitor.py | 66 ++++++----- .../debugpy/debugpy/common/constants.py | 1 + .../debugpy/debugpy/server/debug_session.py | 23 ++-- .../debugpy/debugpy/server/pdb_adapter.py | 107 +++++++++--------- python-ecosys/debugpy/demo.py | 14 ++- python-ecosys/debugpy/test_vscode.py | 8 +- 6 files changed, 124 insertions(+), 95 deletions(-) diff --git a/python-ecosys/debugpy/dap_monitor.py b/python-ecosys/debugpy/dap_monitor.py index 93d02ddf7..85455c9a6 100644 --- a/python-ecosys/debugpy/dap_monitor.py +++ b/python-ecosys/debugpy/dap_monitor.py @@ -8,8 +8,9 @@ import sys import argparse + class DAPMonitor: - def __init__(self, listen_port=5679, target_host='127.0.0.1', target_port=5678): + def __init__(self, listen_port=5679, target_host="127.0.0.1", target_port=5678): self.disconnect = False self.listen_port = listen_port self.target_host = target_host @@ -26,7 +27,7 @@ def start(self): # Create listening socket listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - listener.bind(('127.0.0.1', self.listen_port)) + listener.bind(("127.0.0.1", self.listen_port)) listener.listen(1) print(f"Listening for VS Code connection on port {self.listen_port}...") @@ -90,11 +91,11 @@ def receive_dap_message(self, sock, source): header += byte # Parse content length - header_str = header.decode('utf-8') + header_str = header.decode("utf-8") content_length = 0 - for line in header_str.split('\r\n'): - if line.startswith('Content-Length:'): - content_length = int(line.split(':', 1)[1].strip()) + for line in header_str.split("\r\n"): + if line.startswith("Content-Length:"): + content_length = int(line.split(":", 1)[1].strip()) break if content_length == 0: @@ -113,7 +114,7 @@ def receive_dap_message(self, sock, source): self.log_dap_message(source, message) # Check for disconnect command if message: - if "disconnect" == message.get('command', message.get('event', 'unknown')): + if "disconnect" == message.get("command", message.get("event", "unknown")): print(f"\n[{source}] Disconnect command received, stopping monitor.") self.disconnect = True return header + content @@ -124,7 +125,7 @@ def receive_dap_message(self, sock, source): def parse_dap(self, source, content): """Parse DAP message and log it.""" try: - message = json.loads(content.decode('utf-8')) + message = json.loads(content.decode("utf-8")) return message except json.JSONDecodeError: print(f"\n[{source}] Invalid JSON: {content}") @@ -132,28 +133,28 @@ def parse_dap(self, source, content): def log_dap_message(self, source, message): """Log DAP message details.""" - msg_type = message.get('type', 'unknown') - command = message.get('command', message.get('event', 'unknown')) - seq = message.get('seq', 0) + msg_type = message.get("type", "unknown") + command = message.get("command", message.get("event", "unknown")) + seq = message.get("seq", 0) print(f"\n[{source}] {msg_type.upper()}: {command} (seq={seq})") - if msg_type == 'request': - args = message.get('arguments', {}) + if msg_type == "request": + args = message.get("arguments", {}) if args: print(f" Arguments: {json.dumps(args, indent=2)}") - elif msg_type == 'response': - success = message.get('success', False) - req_seq = message.get('request_seq', 0) + elif msg_type == "response": + success = message.get("success", False) + req_seq = message.get("request_seq", 0) print(f" Success: {success}, Request Seq: {req_seq}") - body = message.get('body') + body = message.get("body") if body: print(f" Body: {json.dumps(body, indent=2)}") - msg = message.get('message') + msg = message.get("message") if msg: print(f" Message: {msg}") - elif msg_type == 'event': - body = message.get('body', {}) + elif msg_type == "event": + body = message.get("body", {}) if body: print(f" Body: {json.dumps(body, indent=2)}") @@ -171,17 +172,28 @@ def cleanup(self): if self.server_sock: self.server_sock.close() -if __name__ == "__main__": +if __name__ == "__main__": parser = argparse.ArgumentParser(description="DAP protocol monitor proxy") - parser.add_argument("--target-host", "--th", default="127.0.0.1", help="Target debugpy host (default: 127.0.0.1)") - parser.add_argument("--target-port", "--tp", type=int, default=5678, help="Target debugpy port (default: 5678)") - parser.add_argument("--listen-port", "--lp", type=int, default=5679, help="Port to listen for VS Code (default: 5679)") + parser.add_argument( + "--target-host", + "--th", + default="127.0.0.1", + help="Target debugpy host (default: 127.0.0.1)", + ) + parser.add_argument( + "--target-port", "--tp", type=int, default=5678, help="Target debugpy port (default: 5678)" + ) + parser.add_argument( + "--listen-port", + "--lp", + type=int, + default=5679, + help="Port to listen for VS Code (default: 5679)", + ) args = parser.parse_args() monitor = DAPMonitor( - listen_port=args.listen_port, - target_host=args.target_host, - target_port=args.target_port + listen_port=args.listen_port, target_host=args.target_host, target_port=args.target_port ) monitor.start() diff --git a/python-ecosys/debugpy/debugpy/common/constants.py b/python-ecosys/debugpy/debugpy/common/constants.py index bc8a4e382..44c12334c 100644 --- a/python-ecosys/debugpy/debugpy/common/constants.py +++ b/python-ecosys/debugpy/debugpy/common/constants.py @@ -1,4 +1,5 @@ """Constants used throughout debugpy.""" + from micropython import const # Default networking settings diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index b7a277bf4..6869c8790 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -232,7 +232,6 @@ def _handle_initialize(self, seq, args): "supportSuspendDebuggee": True, "supportsTerminateRequest": True, "supportsSetVariable": True, - # "supportsFunctionBreakpoints": False, # "supportsConditionalBreakpoints": False, # "supportsHitConditionalBreakpoints": False, @@ -373,22 +372,22 @@ def _handle_set_variable(self, seq, args): variables_ref = args.get("variablesReference", 0) name = args.get("name", "") value = args.get("value", "") - + if not name: self.channel.send_response( CMD_SET_VARIABLE, seq, success=False, message="No variable name provided" ) return - - self._debug_print(f"[DAP] Processing setVariable request: name={name}, value={value}, ref={variables_ref}") - + + self._debug_print( + f"[DAP] Processing setVariable request: name={name}, value={value}, ref={variables_ref}" + ) + try: updated_variable = self.pdb.set_variable(variables_ref, name, value) self.channel.send_response(CMD_SET_VARIABLE, seq, body=updated_variable) except Exception as e: - self.channel.send_response( - CMD_SET_VARIABLE, seq, success=False, message=str(e) - ) + self.channel.send_response(CMD_SET_VARIABLE, seq, success=False, message=str(e)) def _handle_evaluate(self, seq, args): """Handle evaluate request.""" @@ -450,12 +449,12 @@ def _handle_source(self, seq, args): # message=f"Could not read source: {e}" ) - def _trace_function(self, frame, event:str, arg): + def _trace_function(self, frame, event: str, arg): """Trace function called by sys.settrace.""" # https://docs.python.org/3/library/sys.html#sys.settrace global _twiddel # Process any pending DAP messages frequently - + self.process_pending_messages() # Handle breakpoints and stepping if self.pdb.should_stop(frame, event, arg): @@ -469,8 +468,8 @@ def _trace_function(self, frame, event:str, arg): # Wait for continue command self.pdb.wait_for_continue() - # The trace function is invoked (with event set to 'call') whenever a new local scope is entered; - # it should return a reference to a local trace function to be used for the new scope, + # The trace function is invoked (with event set to 'call') whenever a new local scope is entered; + # it should return a reference to a local trace function to be used for the new scope, # or None if the scope shouldn’t be traced. return self._trace_function diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 442b54747..4de655f4e 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -3,9 +3,8 @@ import sys import time import os -from micropython import const +from micropython import const # type: ignore[import-untyped] -Any = object from ..common.constants import ( STEP_INTO, STEP_OUT, @@ -18,6 +17,8 @@ SCOPE_GLOBALS, ) +Any = object + VARREF_LOCALS = const(1) VARREF_GLOBALS = const(2) VARREF_LOCALS_SPECIAL = const(3) @@ -57,7 +58,7 @@ def _cleanup_oldest(self) -> None: """Remove oldest entries to free memory - optimized for MicroPython.""" if not self.cache or not self.insertion_order: return - to_remove = max(1, len(self.cache) // 3) + to_remove = max(1, len(self.cache) // 3) # Direct list slicing is more memory efficient than iteration keys_to_remove = self.insertion_order[:to_remove] # Batch delete for efficiency @@ -215,7 +216,7 @@ def should_stop(self, frame, event: str, arg): _frame_code = frame.f_code _filename = _frame_code.co_filename _lineno = frame.f_lineno - + # Optimize dictionary lookups - use .get() to avoid double lookup file_breakpoints = self.breakpoints.get(_filename) if file_breakpoints and _lineno in file_breakpoints: @@ -305,11 +306,11 @@ def get_stack_trace(self): frame = self.current_frame frame_id = 0 - self._debug_print("=" * 40 ) - self._debug_print(f"[PDB] file mappings: {repr(self.file_mappings)} " ) - self._debug_print(f"[PDB] path mappings: {repr(self.path_mappings)}" ) - self._debug_print(f"[PDB] breakpoints: {repr(self.breakpoints)}" ) - self._debug_print("=" * 40 ) + self._debug_print("=" * 40) + self._debug_print(f"[PDB] file mappings: {repr(self.file_mappings)} ") + self._debug_print(f"[PDB] path mappings: {repr(self.path_mappings)}") + self._debug_print(f"[PDB] breakpoints: {repr(self.breakpoints)}") + self._debug_print("=" * 40) while frame: filename = frame.f_code.co_filename @@ -320,7 +321,6 @@ def get_stack_trace(self): else: hint = "normal" - # Use the VS Code path if we have a mapping, otherwise use the original path debugger_path = self._filename_as_debugger(filename) # Create StackFrame info @@ -479,7 +479,7 @@ def _get_variable_info_fast(self, name: str, value: Any) -> dict[str, str | int] if self._is_expandable(value): var_ref = self.var_cache.add_variable(value) preview = self._get_preview(value) # Always use consistent preview - + # Use pre-calculated length for better performance length = 0 try: @@ -525,12 +525,7 @@ def _get_variable_info_fast(self, name: str, value: Any) -> dict[str, str | int] "variablesReference": 0, } except Exception: - return { - "name": name, - "value": "", - "type": "unknown", - "variablesReference": 0 - } + return {"name": name, "value": "", "type": "unknown", "variablesReference": 0} def _expand_complex_variable(self, ref_id: int) -> list[dict[str, str | int]]: """Expand a complex variable into its child elements - optimized for memory.""" @@ -549,24 +544,28 @@ def _expand_complex_variable(self, ref_id: int) -> list[dict[str, str | int]]: key_str = str(key)[:50] # Limit key string length variables.append(self._get_variable_info(key_str, val)) if len(items) > max_items: - variables.append({ - "name": f"<{len(items) - max_items} more items>", - "value": "...", - "type": "info", - "variablesReference": 0, - }) + variables.append( + { + "name": f"<{len(items) - max_items} more items>", + "value": "...", + "type": "info", + "variablesReference": 0, + } + ) elif isinstance(value, (list, tuple)): # Limit list/tuple expansion max_items = min(len(value), 100) # Limit to 100 items max for i in range(max_items): variables.append(self._get_variable_info(f"[{i}]", value[i])) if len(value) > max_items: - variables.append({ - "name": f"<{len(value) - max_items} more items>", - "value": "...", - "type": "info", - "variablesReference": 0, - }) + variables.append( + { + "name": f"<{len(value) - max_items} more items>", + "value": "...", + "type": "info", + "variablesReference": 0, + } + ) elif isinstance(value, set): # Handle set elements with size limit items = list(value) # Convert once @@ -574,20 +573,24 @@ def _expand_complex_variable(self, ref_id: int) -> list[dict[str, str | int]]: for i in range(max_items): variables.append(self._get_variable_info(f"<{i}>", items[i])) if len(items) > max_items: - variables.append({ - "name": f"<{len(items) - max_items} more items>", - "value": "...", - "type": "info", - "variablesReference": 0, - }) + variables.append( + { + "name": f"<{len(items) - max_items} more items>", + "value": "...", + "type": "info", + "variablesReference": 0, + } + ) except Exception as e: # Return error info for debugging - variables.append({ - "name": "error", - "value": f"Failed to expand: {str(e)[:50]}", # Limit error message length - "type": "error", - "variablesReference": 0, - }) + variables.append( + { + "name": "error", + "value": f"Failed to expand: {str(e)[:50]}", # Limit error message length + "type": "error", + "variablesReference": 0, + } + ) return variables @@ -678,10 +681,10 @@ def _lightweight_serialize(self, value): elif isinstance(value, str): # Simple escaping for strings - avoid full JSON complexity if len(value) > 30: - escaped = value[:27].replace('"', '\\"').replace('\n', '\\n') + escaped = value[:27].replace('"', '\\"').replace("\n", "\\n") return f'"{escaped}..."' else: - escaped = value.replace('"', '\\"').replace('\n', '\\n') + escaped = value.replace('"', '\\"').replace("\n", "\\n") return f'"{escaped}"' elif isinstance(value, (list, tuple)): if len(value) == 0: @@ -722,10 +725,10 @@ def _lightweight_serialize(self, value): def set_variable(self, variables_ref: int, name: str, value: str) -> dict[str, str | int]: """Set a variable to a new value and return the updated variable info. - + This function can modify both global and local variables when using a MicroPython build with settrace and local variable modification support (sys._set_local_var). - + For global variables: Works reliably on all MicroPython builds. For local variables: Requires MicroPython build with C-level local variable support. """ @@ -761,22 +764,24 @@ def set_variable(self, variables_ref: int, name: str, value: str) -> dict[str, s # Check if variable exists in globals if name not in globals_dict: raise Exception(f"Global variable '{name}' not found") - + # For global variables, direct assignment works reliably globals_dict[name] = new_value self._debug_print(f"[PDB] Successfully set global variable '{name}' = {new_value}") - + elif scope_type == VARREF_LOCALS or scope_type == VARREF_LOCALS_SPECIAL: # Check if variable exists in locals if name not in locals_dict: raise Exception(f"Local variable '{name}' not found") - + # Try to use the frame._set_local method to set local variables try: - if hasattr(frame, '_set_local'): + if hasattr(frame, "_set_local"): # Use the frame._set_local method (CPython-compatible API) frame._set_local(name, new_value) - self._debug_print(f"[PDB] Successfully set local variable '{name}' = {new_value}") + self._debug_print( + f"[PDB] Successfully set local variable '{name}' = {new_value}" + ) else: # Fallback error if the method is not available raise Exception( @@ -791,7 +796,7 @@ def set_variable(self, variables_ref: int, name: str, value: str) -> dict[str, s f"Local variables in MicroPython are stored in internal code_state->state[] slots. " f"Consider using global variables for reliable modification during debugging." ) - + else: raise Exception("Invalid scope reference") diff --git a/python-ecosys/debugpy/demo.py b/python-ecosys/debugpy/demo.py index 02a927257..fd88c0272 100644 --- a/python-ecosys/debugpy/demo.py +++ b/python-ecosys/debugpy/demo.py @@ -2,16 +2,19 @@ """Simple demo of MicroPython debugpy functionality.""" import sys -sys.path.insert(0, '.') + +sys.path.insert(0, ".") import debugpy + def simple_function(a, b): """A simple function to demonstrate debugging.""" result = a + b print(f"Computing {a} + {b} = {result}") return result + def main(): print("MicroPython debugpy Demo") print("========================") @@ -21,11 +24,11 @@ def main(): print("1. Testing trace functionality:") def trace_function(frame, event, arg): - if event == 'call': + if event == "call": print(f" -> Entering function: {frame.f_code.co_name}") - elif event == 'line': + elif event == "line": print(f" -> Executing line {frame.f_lineno} in {frame.f_code.co_name}") - elif event == 'return': + elif event == "return": print(f" -> Returning from {frame.f_code.co_name} with value: {arg}") return trace_function @@ -46,6 +49,7 @@ def trace_function(frame, event, arg): # Test PDB adapter from debugpy.server.pdb_adapter import PdbAdapter + pdb = PdbAdapter() # Set some mock breakpoints @@ -54,6 +58,7 @@ def trace_function(frame, event, arg): # Test messaging from debugpy.common.messaging import JsonMessageChannel + print(" JsonMessageChannel available") print() @@ -64,5 +69,6 @@ def trace_function(frame, event, arg): print(" - Connect VS Code using the 'Attach to MicroPython' configuration") print(" - Set breakpoints and debug normally") + if __name__ == "__main__": main() diff --git a/python-ecosys/debugpy/test_vscode.py b/python-ecosys/debugpy/test_vscode.py index 9a5672822..1d24fac81 100644 --- a/python-ecosys/debugpy/test_vscode.py +++ b/python-ecosys/debugpy/test_vscode.py @@ -3,13 +3,14 @@ import sys -sys.path.insert(0, '.') +sys.path.insert(0, ".") import debugpy foo = 42 bar = "Hello, MicroPython!" + def fibonacci(n): """Calculate fibonacci number (iterative for efficiency).""" if n <= 1: @@ -19,6 +20,7 @@ def fibonacci(n): a, b = b, a + b return b + def debuggable_code(): """The actual code we want to debug - wrapped in a function so sys.settrace will trace it.""" global foo @@ -33,6 +35,7 @@ def debuggable_code(): print(f"fibonacci({num}) = {result}") print(sys.implementation) import machine + print(dir(machine)) # Test manual breakpoint @@ -42,6 +45,7 @@ def debuggable_code(): print("Test completed successfully!") + def main(): print("MicroPython VS Code Debugging Test") print("==================================") @@ -64,6 +68,7 @@ def main(): # Give VS Code a moment to set breakpoints after attach print("\nGiving VS Code time to set breakpoints...") import time + time.sleep(2) # Call the debuggable code function so it gets traced @@ -74,5 +79,6 @@ def main(): except Exception as e: print(f"Error: {e}") + if __name__ == "__main__": main() From 9fb263a65cdaa99166a59cae3efbf6f260c9a06d Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Tue, 1 Jul 2025 22:38:50 +0200 Subject: [PATCH 25/31] python-ecosys/debugpy: Code cleanup. Signed-off-by: Jos Verlinde --- .../debugpy/debugpy/server/debug_session.py | 39 ++++++++-------- .../debugpy/debugpy/server/pdb_adapter.py | 46 ++++++++----------- 2 files changed, 39 insertions(+), 46 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 6869c8790..17904fb49 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -1,38 +1,39 @@ """Main debug session handling DAP protocol communication.""" import sys -from ..common.messaging import JsonMessageChannel + from ..common.constants import ( - CMD_INITIALIZE, - CMD_LAUNCH, CMD_ATTACH, - CMD_SET_BREAKPOINTS, + CMD_CONFIGURATION_DONE, CMD_CONTINUE, + CMD_DISCONNECT, + CMD_EVALUATE, + CMD_INITIALIZE, + CMD_LAUNCH, CMD_NEXT, - CMD_STEP_IN, - CMD_STEP_OUT, CMD_PAUSE, - CMD_STACK_TRACE, CMD_SCOPES, - CMD_VARIABLES, + CMD_SET_BREAKPOINTS, CMD_SET_VARIABLE, - CMD_EVALUATE, - CMD_DISCONNECT, - CMD_CONFIGURATION_DONE, - CMD_THREADS, CMD_SOURCE, + CMD_STACK_TRACE, + CMD_STEP_IN, + CMD_STEP_OUT, + CMD_THREADS, + CMD_VARIABLES, + EVENT_CONTINUED, EVENT_INITIALIZED, EVENT_STOPPED, - EVENT_CONTINUED, EVENT_TERMINATED, STOP_REASON_BREAKPOINT, - STOP_REASON_STEP, STOP_REASON_PAUSE, + STOP_REASON_STEP, TRACE_CALL, + TRACE_EXCEPTION, TRACE_LINE, TRACE_RETURN, - TRACE_EXCEPTION, ) +from ..common.messaging import JsonMessageChannel from .pdb_adapter import PdbAdapter @@ -43,7 +44,7 @@ def __init__(self, client_socket): self.debug_logging = False # Initialize first self.channel = JsonMessageChannel(client_socket, self._debug_print) self.pdb = PdbAdapter() - self.pdb._debug_session = self # Allow PDB to process messages during wait # type: ignore + self.pdb._debug_session = self # Allow PDB to process messages during wait # type: ignore[assignment] self.initialized = False self.connected = True self.thread_id = 1 # Simple single-thread model @@ -393,7 +394,7 @@ def _handle_evaluate(self, seq, args): """Handle evaluate request.""" expression = args.get("expression", "") frame_id = args.get("frameId") - context = args.get("context", "watch") + # context = args.get("context", "watch") if not expression: self.channel.send_response( CMD_EVALUATE, seq, success=False, message="No expression provided" @@ -429,7 +430,7 @@ def _handle_source(self, seq, args): source = args.get("source", {}) source_path = source.get("path", "") if self._baremetal or not source_path: - # BUGBUG: unable to read the source on ESP32 + # BUG: unable to read the source on ESP32 # Possible an effect of the import / inialization sequence ? # Nothe that other source files ( other.py) do not seem to get requested in the same way self.channel.send_response(CMD_SOURCE, seq, success=False) @@ -470,7 +471,7 @@ def _trace_function(self, frame, event: str, arg): # The trace function is invoked (with event set to 'call') whenever a new local scope is entered; # it should return a reference to a local trace function to be used for the new scope, - # or None if the scope shouldn’t be traced. + # or None if the scope shouldn't be traced. return self._trace_function diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 4de655f4e..73d4c3ce6 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -1,20 +1,21 @@ """PDB adapter for integrating with MicroPython's trace system.""" +import os import sys import time -import os + from micropython import const # type: ignore[import-untyped] from ..common.constants import ( + SCOPE_GLOBALS, + SCOPE_LOCALS, STEP_INTO, STEP_OUT, STEP_OVER, TRACE_CALL, + TRACE_EXCEPTION, TRACE_LINE, TRACE_RETURN, - TRACE_EXCEPTION, - SCOPE_LOCALS, - SCOPE_GLOBALS, ) Any = object @@ -92,9 +93,8 @@ class PdbAdapter: """Adapter between DAP protocol and MicroPython's sys.settrace functionality.""" def __init__(self): - self.breakpoints: dict[ - str, dict[int, dict] - ] = {} # filename -> {line_no: breakpoint_info} # todo - simplify - reduce info stored + self.breakpoints: dict[str, dict[int, dict]] = {} + # filename -> {line_no: breakpoint_info} # todo - simplify self.current_frame = None self.step_mode = None # None, 'over', 'into', 'out' self.step_frame = None @@ -105,16 +105,14 @@ def __init__(self): self.variables_cache = {} # frameId -> variables self.var_cache = VariableReferenceCache() # Enhanced variable reference cache self.frame_id_counter = 1 - self.path_mappings: list[ - tuple[str, str] - ] = [] # runtime_path -> vscode_path mapping # todo: move to session level - self.file_mappings: dict[ - str, str - ] = {} # runtime_path -> vscode_path mapping # todo : merge with .breakpoints + self.path_mappings: list[tuple[str, str]] = [] + # list of [runtime_path -> vscode_path mapping] + self.file_mappings: dict[str, str] = {} + # runtime_path -> vscode_path mapping # todo : merge with .breakpoints def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" - if hasattr(self, "_debug_session") and self._debug_session.debug_logging: # type: ignore + if hasattr(self, "_debug_session") and self._debug_session.debug_logging: # type: ignore[attr-defined] print(message) def _normalize_path(self, path: str): @@ -208,7 +206,7 @@ def should_stop(self, frame, event: str, arg): filename = frame.f_code.co_filename lineno = frame.f_lineno # Check for exact filename match first - if self.paused or filename in self.breakpoints and lineno in self.breakpoints[filename]: + if self.paused or (filename in self.breakpoints and lineno in self.breakpoints[filename]): self._debug_print(f"[PDB] HIT BREAKPOINT (exact match) at {filename}:{lineno}") # Record the path mapping (in this case, they're already the same) # self.file_mappings[filename] = self._filename_as_debugger(filename) @@ -224,7 +222,7 @@ def should_stop(self, frame, event: str, arg): return True else: # file not (yet) matched - this is slow so we do not want to do this often. - # TODO: use builins - sys.path method to find the file + # TODO: use sys.path[] method to find the file, does not work for frozen .... # if we have a path match , but no breakpoints - add it to the file_mappings dict simplify this check if file_breakpoints is None: self.breakpoints[_filename] = {} # Ensure the filename is in the breakpoints dict @@ -294,7 +292,7 @@ def wait_for_continue(self): while not self.continue_event: # Process any pending DAP messages (scopes, variables, etc.) if hasattr(self, "_debug_session"): - self._debug_session.process_pending_messages() # type: ignore + self._debug_session.process_pending_messages() # type: ignore[arg-type] time.sleep(0.01) def get_stack_trace(self): @@ -306,12 +304,6 @@ def get_stack_trace(self): frame = self.current_frame frame_id = 0 - self._debug_print("=" * 40) - self._debug_print(f"[PDB] file mappings: {repr(self.file_mappings)} ") - self._debug_print(f"[PDB] path mappings: {repr(self.path_mappings)}") - self._debug_print(f"[PDB] breakpoints: {repr(self.breakpoints)}") - self._debug_print("=" * 40) - while frame: filename = frame.f_code.co_filename name = frame.f_code.co_name @@ -483,7 +475,7 @@ def _get_variable_info_fast(self, name: str, value: Any) -> dict[str, str | int] # Use pre-calculated length for better performance length = 0 try: - length = len(value) # type: ignore + length = len(value) # type: ignore[arg-type] except: pass @@ -670,7 +662,7 @@ def cleanup(self): if hasattr(sys, "settrace"): sys.settrace(None) - def _lightweight_serialize(self, value): + def _lightweight_serialize(self, value): # noqa: PLR0911 """Lightweight serialization optimized for MicroPython memory constraints.""" if value is None: return "None" @@ -792,7 +784,7 @@ def set_variable(self, variables_ref: int, name: str, value: str) -> dict[str, s except Exception as inner_e: # If frame.set_local fails, provide detailed error raise Exception( - f"Failed to modify local variable '{name}': {str(inner_e)}. " + f"Failed to modify local variable '{name}': {inner_e}. " f"Local variables in MicroPython are stored in internal code_state->state[] slots. " f"Consider using global variables for reliable modification during debugging." ) @@ -804,4 +796,4 @@ def set_variable(self, variables_ref: int, name: str, value: str) -> dict[str, s return self._get_variable_info(name, new_value) except Exception as e: - raise Exception(f"Failed to set variable '{name}': {str(e)}") + raise Exception(f"Failed to set variable '{name}': {e}") From 1b499923ba2be10bb470587c81751879203bdb2f Mon Sep 17 00:00:00 2001 From: Jos Verlinde Date: Mon, 14 Jul 2025 17:07:36 +0200 Subject: [PATCH 26/31] pdbadapeter: fixup "Special". Signed-off-by: Jos Verlinde --- python-ecosys/debugpy/debugpy/server/pdb_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 73d4c3ce6..b55772f6d 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -592,7 +592,7 @@ def _var_error(name: str): @staticmethod def _special_vars(varref: int): - return {"name": "special", "value": "", "variablesReference": varref} + return {"name": "Special", "value": "", "variablesReference": varref} def get_variables(self, variables_ref): """Get variables for a scope with enhanced complex variable support.""" From 61c89059ca42bada5c118d47a2aac97e746f3bc2 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Sun, 5 Jul 2026 21:33:47 +1000 Subject: [PATCH 27/31] debugpy: reassemble DAP messages split across recv() calls recv_message() stripped the header from the receive buffer as soon as the CRLF/CRLF terminator was found, but only persisted buffer state on some partial-read paths. When a message body arrived in a later read than its header, the parsed-header state was lost and framing desynchronised for the rest of the connection. Keep the header and body together in the buffer until the whole message (header + Content-Length bytes) is present, then slice it off. Treat an empty recv as a peer close and EAGAIN/EWOULDBLOCK as "try later". Claude-Session: https://claude.ai/code/session_013VDeuZRScEaKtvZzn2ehyq --- .../debugpy/debugpy/common/messaging.py | 131 +++++++----------- 1 file changed, 53 insertions(+), 78 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/common/messaging.py b/python-ecosys/debugpy/debugpy/common/messaging.py index eb7df4dd2..7d704f6a8 100644 --- a/python-ecosys/debugpy/debugpy/common/messaging.py +++ b/python-ecosys/debugpy/debugpy/common/messaging.py @@ -82,94 +82,69 @@ def send_event(self, event, **kwargs): self.send_message(MSG_TYPE_EVENT, event, **kwargs) def recv_message(self): - """Receive a DAP message.""" + """Receive a DAP message, or None if a full one isn't available yet. + + Called repeatedly against a socket with a short recv timeout (see + `DebugSession.process_pending_messages`), so a single message's + header and body routinely arrive across several calls. Everything + read so far - including an already-located header - is kept in + `self._recv_buffer` verbatim until the *entire* message (header + + `Content-Length` body bytes) is available, and only then is it + parsed and sliced off. Parsing the header again on each call is + cheap and avoids having to separately persist "header already + parsed, N body bytes still outstanding" state between calls: a + prior version stripped the header out of the buffer as soon as it + was found, which discarded that state and desynchronised framing + for the rest of the connection whenever the body arrived in a + later read than the header. + """ if self.closed: return None - # Quick bail-out: if buffer is empty, do a non-blocking peek to see if data is available - if not self._recv_buffer: - try: - # Try to read a small amount non-blocking to see if anything is available - peek_data = self.sock.recv(1) - if not peek_data: - return None # No data available - # Put the peeked data back into buffer - self._recv_buffer = peek_data - except OSError as e: - # Handle non-blocking socket errors (no data available) - if hasattr(e, "errno") and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK - return None # No data available, quick exit - # Other errors + # Non-blocking top-up: pull in whatever is available right now + # without blocking if there's nothing new yet. + try: + data = self.sock.recv(4096) + if not data: + # A truly empty read (as opposed to EAGAIN/EWOULDBLOCK, + # handled below) means the peer closed the connection. + self.closed = True + return None + self._recv_buffer += data + except OSError as e: + if not (hasattr(e, "errno") and e.errno in (11, 35)): # EAGAIN, EWOULDBLOCK self.closed = True return None + # No new data available right now - fall through and try to + # parse a complete message out of whatever is already buffered. - # Cache frequently accessed attributes recv_buffer = self._recv_buffer - sock_recv = self.sock.recv + header_end = recv_buffer.find(b"\r\n\r\n") + if header_end < 0: + return None # Header not fully received yet. - try: - # Read headers - while b"\r\n\r\n" not in recv_buffer: - try: - data = sock_recv(1024) - if not data: - self.closed = True - return None - recv_buffer += data - except OSError as e: - # Handle timeout and other socket errors - if hasattr(e, "errno") and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK - return None # No data available - self.closed = True - return None - - header_end = recv_buffer.find(b"\r\n\r\n") - header_str = recv_buffer[:header_end].decode("utf-8") - recv_buffer = recv_buffer[header_end + 4 :] - - # Parse Content-Length - content_length = 0 - for line in header_str.split("\r\n"): - if line.startswith("Content-Length:"): - content_length = int(line.split(":", 1)[1].strip()) - break - - if content_length == 0: - self._recv_buffer = recv_buffer - return None + header_str = recv_buffer[:header_end].decode("utf-8") + content_length = 0 + for line in header_str.split("\r\n"): + if line.startswith("Content-Length:"): + content_length = int(line.split(":", 1)[1].strip()) + break - # Read body - while len(recv_buffer) < content_length: - try: - data = sock_recv(content_length - len(recv_buffer)) - if not data: - self.closed = True - return None - recv_buffer += data - except OSError as e: - if hasattr(e, "errno") and e.errno in (11, 35): # EAGAIN, EWOULDBLOCK - self._recv_buffer = recv_buffer - return None - self.closed = True - return None - - body = recv_buffer[:content_length] - self._recv_buffer = recv_buffer[content_length:] - - # Parse JSON - try: - message = json.loads(body.decode("utf-8")) - self._debug_print( - f"[DAP] Successfully received message: {message.get('type')} {message.get('command', message.get('event', 'unknown'))}" - ) - return message - except (ValueError, UnicodeDecodeError) as e: - print(f"[DAP] JSON parse error: {e}") - return None + body_start = header_end + 4 + if len(recv_buffer) < body_start + content_length: + return None # Body not fully received yet. - except OSError as e: - print(f"[DAP] Socket error in recv_message: {e}") - self.closed = True + body = recv_buffer[body_start : body_start + content_length] + self._recv_buffer = recv_buffer[body_start + content_length :] + + try: + message = json.loads(body.decode("utf-8")) + self._debug_print( + f"[DAP] Successfully received message: {message.get('type')} {message.get('command', message.get('event', 'unknown'))}" + ) + return message + except (ValueError, UnicodeDecodeError) as e: + print(f"[DAP] JSON parse error: {e}") return None def close(self): From e359a95c00bc02bdbf8d330118b12b7004b71028 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Sun, 5 Jul 2026 21:33:47 +1000 Subject: [PATCH 28/31] debugpy: add wait_for_client, capability probe, read-only locals - wait_for_client() blocks until the DAP client sends configurationDone, draining the socket so breakpoints set beforehand are honoured; replaces a fixed sleep. Bounded timeout, logged rather than silent. - Runtime capability probe (settrace / save_names / set_local / f_back) derived by exercising the interpreter, never inferred from a build or variant name; exposed via get_capabilities(). - Local variables are marked read-only (DAP presentationHint) when the firmware lacks frame._set_local, so clients do not offer an edit that cannot work; globals stay editable. - listen() resolves the actually-bound port and never advertises port 0. Claude-Session: https://claude.ai/code/session_013VDeuZRScEaKtvZzn2ehyq --- python-ecosys/debugpy/debugpy/__init__.py | 13 ++- .../debugpy/debugpy/common/constants.py | 5 + python-ecosys/debugpy/debugpy/public_api.py | 54 ++++++++-- .../debugpy/debugpy/server/debug_session.py | 98 ++++++++++++++++++- .../debugpy/debugpy/server/pdb_adapter.py | 40 +++++--- 5 files changed, 186 insertions(+), 24 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/__init__.py b/python-ecosys/debugpy/debugpy/__init__.py index 3912a49a5..cce6cb870 100644 --- a/python-ecosys/debugpy/debugpy/__init__.py +++ b/python-ecosys/debugpy/debugpy/__init__.py @@ -7,7 +7,15 @@ __version__ = "0.1.0" -from .public_api import listen, wait_for_client, breakpoint, debug_this_thread +from .public_api import ( + breakpoint, + debug_this_thread, + disconnect, + get_capabilities, + is_client_connected, + listen, + wait_for_client, +) from .common.constants import DEFAULT_HOST, DEFAULT_PORT __all__ = [ @@ -15,6 +23,9 @@ "DEFAULT_PORT", "breakpoint", "debug_this_thread", + "disconnect", + "get_capabilities", + "is_client_connected", "listen", "wait_for_client", ] diff --git a/python-ecosys/debugpy/debugpy/common/constants.py b/python-ecosys/debugpy/debugpy/common/constants.py index 44c12334c..5d9a52204 100644 --- a/python-ecosys/debugpy/debugpy/common/constants.py +++ b/python-ecosys/debugpy/debugpy/common/constants.py @@ -67,3 +67,8 @@ # Scope types SCOPE_LOCALS = const("locals") SCOPE_GLOBALS = const("globals") + +# Bounded wait for the DAP client to send configurationDone (seconds). There is +# no server thread, so a hang here would spin forever with no diagnostic; a +# timeout with a clear message replaces a silent guessed delay. +WAIT_FOR_CLIENT_TIMEOUT_S = const(30) diff --git a/python-ecosys/debugpy/debugpy/public_api.py b/python-ecosys/debugpy/debugpy/public_api.py index 06b928965..59577a44e 100644 --- a/python-ecosys/debugpy/debugpy/public_api.py +++ b/python-ecosys/debugpy/debugpy/public_api.py @@ -37,7 +37,23 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): listener.bind(addr) listener.listen(1) - # getsockname not available in MicroPython, use original values + # Resolve the actual bound port (needed when the caller asked for port 0 / + # auto). Not every MicroPython port implements getsockname(). + requested_port = port + try: + bound_addr = listener.getsockname() + if isinstance(bound_addr, (tuple, list)) and len(bound_addr) >= 2: + port = bound_addr[1] + except Exception: + pass + if requested_port == 0 and port == 0: + # The caller asked for an OS-assigned port and this port has no way + # to report what the OS actually picked. Advertising port 0 in the + # handshake would tell the client to connect to a port that can + # never accept a connection, so fall back to the documented default + # instead - it is at least a real, well-known port to try. + port = DEFAULT_PORT + print(f"Debugpy listening on {host}:{port}") # Wait for connection @@ -69,7 +85,9 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): client_sock.close() _debug_session = None finally: - # Only close the listener, not the client connection + # The accepted client socket is independent of the listener; closing + # the listener does not affect it. This is a single-connection server, + # so stop listening once the client is accepted. listener.close() return (host, port) @@ -97,11 +115,35 @@ def format_client_addr(client_addr): return str(client_addr) -def wait_for_client(): - """Wait for the debugger client to connect and initialize.""" +def wait_for_client(timeout_s=None): + """Block until the DAP client has finished configuring (configurationDone). + + Replaces a fixed sleep after debug_this_thread(): breakpoints the client + sets before configurationDone are honoured because this drains the socket + the whole time it waits. Returns True once configurationDone arrives, + False after a bounded timeout (logged, not silent) or if no session is + listening. + """ global _debug_session - if _debug_session: - _debug_session.wait_for_client() + if _debug_session is None: + print("[DAP] wait_for_client: no debug session is listening, nothing to wait for") + return False + if timeout_s is None: + return _debug_session.wait_for_client() + return _debug_session.wait_for_client(timeout_s) + + +def get_capabilities(): + """Return the firmware capability dict (settrace/save_names/set_local/f_back). + + Uses the active session's probe result if a session exists, otherwise + probes directly. Values always come from probing the running + interpreter, never from a build/variant name. + """ + global _debug_session + if _debug_session is not None: + return _debug_session.capabilities + return DebugSession.probe_capabilities() def breakpoint(): diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 17904fb49..575faa794 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -1,6 +1,7 @@ """Main debug session handling DAP protocol communication.""" import sys +import time from ..common.constants import ( CMD_ATTACH, @@ -32,11 +33,24 @@ TRACE_EXCEPTION, TRACE_LINE, TRACE_RETURN, + WAIT_FOR_CLIENT_TIMEOUT_S, ) from ..common.messaging import JsonMessageChannel from .pdb_adapter import PdbAdapter +def _is_placeholder_local_name(name): + """True if `name` is a positional `local_N` placeholder, not a real name. + + Without MICROPY_PY_SYS_SETTRACE_SAVE_NAMES, frame.f_locals synthesizes + names as `local_1`, `local_2`, ... (see py/profile.c). This is the only + reliable signal that separates the two cases at runtime. + """ + if not name.startswith("local_"): + return False + return name[len("local_") :].isdigit() + + class DebugSession: """Manages a debugging session with a DAP client.""" @@ -50,6 +64,10 @@ def __init__(self, client_socket): self.thread_id = 1 # Simple single-thread model self.stepping = False self.paused = False + self.configuration_done = False + # Probed once at session start; never inferred from a build/variant name. + self.capabilities = self.probe_capabilities() + self.pdb.capabilities = self.capabilities def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" @@ -60,6 +78,53 @@ def _debug_print(self, message): def _baremetal(self) -> bool: return sys.platform not in ("linux") # to be expanded + @staticmethod + def probe_capabilities(): + """Probe what the running firmware actually supports. + + Returns a dict with at least `settrace`, `save_names`, `set_local` and + `f_back`, each derived by exercising the real interpreter - never by + reading a build/variant name, which does not reliably reflect what a + given firmware image supports (see BACKGROUND.md). Safe to call on + both the unix port and bare-metal builds; never raises. + """ + caps = { + "settrace": hasattr(sys, "settrace"), + "f_back": False, + "save_names": False, + "set_local": False, + } + if not caps["settrace"]: + return caps + + try: + frame = sys._getframe() + except Exception: + return caps + + try: + caps["f_back"] = hasattr(frame, "f_back") + except Exception: + pass + + try: + caps["set_local"] = hasattr(frame, "_set_local") + except Exception: + pass + + try: + local_names = list(frame.f_locals.keys()) + # An empty locals dict (e.g. probing from module scope) proves + # nothing either way; only trust the signal when there is at + # least one local name to inspect for the placeholder pattern. + caps["save_names"] = bool(local_names) and not any( + _is_placeholder_local_name(n) for n in local_names + ) + except Exception: + pass + + return caps + def start(self): """Start the debug session message loop.""" try: @@ -417,6 +482,7 @@ def _handle_configuration_done(self, seq, args): """Handle configurationDone request.""" # This indicates that the client has finished configuring breakpoints # and is ready to start debugging + self.configuration_done = True self.channel.send_response(CMD_CONFIGURATION_DONE, seq) def _handle_threads(self, seq, args): @@ -481,10 +547,34 @@ def _send_stopped_event(self, reason): EVENT_STOPPED, reason=reason, threadId=self.thread_id, allThreadsStopped=True ) - def wait_for_client(self): - """Wait for client to initialize.""" - # This is a simplified version - in a real implementation - # we might want to wait for specific initialization steps + def wait_for_client(self, timeout_s=WAIT_FOR_CLIENT_TIMEOUT_S): + """Block until the client has sent configurationDone, or time out. + + Same busy-poll shape as PdbAdapter.wait_for_continue(): there is no + server thread, so nothing services the socket unless this loop drains + it. Replaces a fixed sleep with a deterministic handshake - breakpoints + set before configurationDone are already applied by the time this + returns because process_pending_messages() has drained them too. + Returns True once configurationDone arrives, False if the bounded + timeout elapses first (a hard failure is worse than continuing with a + clear log message: a client that never configures is a client bug or + a dropped connection, not something to hang on forever). + """ + start = time.ticks_ms() + while not self.configuration_done: + self.process_pending_messages() + if not self.connected or self.channel.closed: + print("[DAP] wait_for_client: connection closed before configurationDone") + return False + if time.ticks_diff(time.ticks_ms(), start) > timeout_s * 1000: + print( + "[DAP] wait_for_client: timed out after {}s waiting for configurationDone".format( + timeout_s + ) + ) + return False + time.sleep(0.01) + return True def trigger_breakpoint(self): """Trigger a manual breakpoint.""" diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index b55772f6d..1988c83e8 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -109,6 +109,9 @@ def __init__(self): # list of [runtime_path -> vscode_path mapping] self.file_mappings: dict[str, str] = {} # runtime_path -> vscode_path mapping # todo : merge with .breakpoints + self.capabilities: dict = {} + # set by DebugSession at session start (see DebugSession.probe_capabilities); + # empty dict here means "not yet probed", treated as no set_local support def _debug_print(self, message): """Print debug message only if debug logging is enabled.""" @@ -358,7 +361,7 @@ def get_scopes(self, frame_id): ] return scopes - def _process_special_variables(self, var_dict): + def _process_special_variables(self, var_dict, read_only=False): """Process special variables (those starting and ending with __).""" variables = [] for name, value in var_dict.items(): @@ -367,19 +370,20 @@ def _process_special_variables(self, var_dict): # Use lightweight serialization instead of json.dumps value_str = self._lightweight_serialize(value) type_str = type(value).__name__ - variables.append( - { - "name": name, - "value": value_str, - "type": type_str, - "variablesReference": 0, - } - ) + info = { + "name": name, + "value": value_str, + "type": type_str, + "variablesReference": 0, + } + if read_only: + info["presentationHint"] = {"attributes": ["readOnly"]} + variables.append(info) except Exception: variables.append(self._var_error(name)) return variables - def _process_regular_variables(self, var_dict): + def _process_regular_variables(self, var_dict, read_only=False): """Process regular variables (excluding special ones) - optimized.""" variables = [] for name, value in var_dict.items(): @@ -387,7 +391,10 @@ def _process_regular_variables(self, var_dict): if name.startswith("__") and name.endswith("__"): continue # Use fast path for variable info generation - variables.append(self._get_variable_info_fast(name, value)) + info = self._get_variable_info_fast(name, value) + if read_only: + info["presentationHint"] = {"attributes": ["readOnly"]} + variables.append(info) return variables def _is_expandable(self, value: Any) -> bool: @@ -608,10 +615,16 @@ def get_variables(self, variables_ref): frame = self.variables_cache[frame_id] + # Locals are read-only in DAP when this firmware has no _set_local + # (STORY-1.3): the edit affordance is greyed out client-side instead + # of setVariable failing with an error after the fact. Globals always + # stay editable - global write-back works on every firmware. + locals_read_only = not self.capabilities.get("set_local", False) + # Handle special scope types first if scope_type == VARREF_LOCALS_SPECIAL: var_dict = frame.f_locals if hasattr(frame, "f_locals") else {} - return self._process_special_variables(var_dict) + return self._process_special_variables(var_dict, read_only=locals_read_only) elif scope_type == VARREF_GLOBALS_SPECIAL: var_dict = frame.f_globals if hasattr(frame, "f_globals") else {} return self._process_special_variables(var_dict) @@ -629,7 +642,8 @@ def get_variables(self, variables_ref): return [] # Add regular variables with enhanced processing - variables.extend(self._process_regular_variables(var_dict)) + read_only = locals_read_only if scope_type == VARREF_LOCALS else False + variables.extend(self._process_regular_variables(var_dict, read_only=read_only)) return variables def evaluate_expression(self, expression, frame_id=None): From 4fabcb390953dccd8b307c58cbaf96a464a5b085 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Wed, 15 Jul 2026 05:43:21 +1000 Subject: [PATCH 29/31] debugpy: Execute statements from repl and clipboard evaluate contexts. DAP `evaluate` requests carry a `context` field (`watch`, `hover`, `repl`, `clipboard`, ...) that `_handle_evaluate` read but discarded, so every request went through `eval()` only; a statement such as `x = 5` or `def f(): ...` typed into the Debug Console failed with a syntax error instead of running. `evaluate_expression` now dispatches on `context`: `watch`/`hover` (and any other or absent context) keep the original eval-only, read-only contract unchanged. `repl`/`clipboard` try `eval()` first, so a plain expression like `1 + 1` still returns a value, and only fall back to `exec(expression, globals_dict)` when `eval()` raises `SyntaxError`. The exec namespace is globals-only, on purpose: `exec(code, g, l)` binds a top-level assignment into `l`, and here `l` is a throwaway copy of the paused frame's `f_locals` snapshot handed back to the caller and then discarded, so the assignment would silently vanish instead of taking effect. Passing only `globals_dict` makes a statement's assignments land in the running module namespace, where they are visible to the target program after `continue`. That globals-only exec creates a shadowing hazard: assigning a name that is also a LOCAL of the paused frame changes the global but leaves the local exactly as it was, which looks like a no-op from the Debug Console's perspective. `_shadowed_local_warning` detects the common case (a simple `name = ...` or `name op= ...` at the start of the statement) and appends a warning to the result so the mismatch is visible rather than silently misleading; it does not attempt to parse multi-target assignment, unpacking, attribute/subscript targets, or `def`/`class`/`for` bindings, and a `None` result from `_assigned_name` means "not proven safe", never "proven no shadowing". --- .../debugpy/debugpy/server/debug_session.py | 12 ++- .../debugpy/debugpy/server/pdb_adapter.py | 101 +++++++++++++++++- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 575faa794..483864411 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -456,17 +456,23 @@ def _handle_set_variable(self, seq, args): self.channel.send_response(CMD_SET_VARIABLE, seq, success=False, message=str(e)) def _handle_evaluate(self, seq, args): - """Handle evaluate request.""" + """Handle evaluate request. + + `context` selects the contract PdbAdapter.evaluate_expression applies: + `repl`/`clipboard` (Debug Console, "Copy as Expression") may execute a + statement when `expression` isn't a valid expression; `watch`/`hover` + and any other or absent context stay read-only eval. + """ expression = args.get("expression", "") frame_id = args.get("frameId") - # context = args.get("context", "watch") + context = args.get("context", "watch") if not expression: self.channel.send_response( CMD_EVALUATE, seq, success=False, message="No expression provided" ) return try: - result = self.pdb.evaluate_expression(expression, frame_id) + result = self.pdb.evaluate_expression(expression, frame_id, context) self.channel.send_response( CMD_EVALUATE, seq, body={"result": str(result), "variablesReference": 0} ) diff --git a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py index 1988c83e8..da9144d06 100644 --- a/python-ecosys/debugpy/debugpy/server/pdb_adapter.py +++ b/python-ecosys/debugpy/debugpy/server/pdb_adapter.py @@ -89,6 +89,68 @@ def ends_with_path(full_path: str, relative_path: str): return full_parts[-len(rel_parts) :] == rel_parts +# Augmented-assignment operators checked longest-first so e.g. "**=" is not +# mistaken for "*=" followed by stray text. +_AUG_ASSIGN_OPS = ("**=", "//=", ">>=", "<<=", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "=") + + +def _is_ident_char(ch: str) -> bool: + """True for `[A-Za-z0-9_]` - MicroPython's `str` has no `.isalnum()`.""" + return ch.isalpha() or ch.isdigit() or ch == "_" + + +def _assigned_name(statement: str): + """Return the target name of a simple top-level assignment, or None. + + Recognises only `...` where the identifier is the very + first token and `` is `=` or an augmented-assignment operator. This + is a deliberately narrow, best-effort check - it does NOT catch: + multi-target assignment (`a = b = 1`, only `a` is seen), tuple/list + unpacking (`a, b = 1, 2`), attribute/subscript targets (`obj.x = 1`, + `d[k] = 1`), `def`/`class` statements (which also bind a name), a + `for`/`with ... as` binding, or an assignment that is not the first + statement on the line (e.g. after `;`). Those forms pass through + undetected; callers must treat a `None` result as "not proven safe", + never as "proven no shadowing". + """ + stripped = statement.strip() + if not stripped or stripped[0].isdigit() or not _is_ident_char(stripped[0]): + return None + i = 1 + n = len(stripped) + while i < n and _is_ident_char(stripped[i]): + i += 1 + name = stripped[:i] + rest = stripped[i:].lstrip() + for op in _AUG_ASSIGN_OPS: + if rest.startswith(op): + if op == "=" and rest[1:2] == "=": + return None # `==`, a comparison, not an assignment + return name + return None + + +def _shadowed_local_warning(statement: str, locals_dict): + """Build the honesty-rule warning for `statement`, or None if it doesn't apply. + + Fires when `_assigned_name` recognises a top-level assignment whose + target name is also a key in `locals_dict` (the paused frame's + `f_locals` snapshot): that name is about to be rebound in `f_globals` + only, so the LOCAL of the same name stays exactly as it was. On + firmware without local-name capture (`save_names` capability False), + `locals_dict` keys are synthetic `local_N` placeholders rather than + real identifiers, so a real name can never match and this warning + silently cannot fire there - a known limitation, not a bug. + """ + name = _assigned_name(statement) + if name and name in locals_dict: + return ( + f"Warning: '{name}' also exists as a LOCAL in this frame; " + "the local is unchanged (statement ran against globals only)." + ) + return None + + class PdbAdapter: """Adapter between DAP protocol and MicroPython's sys.settrace functionality.""" @@ -646,8 +708,28 @@ def get_variables(self, variables_ref): variables.extend(self._process_regular_variables(var_dict, read_only=read_only)) return variables - def evaluate_expression(self, expression, frame_id=None): - """Evaluate an expression in the context of a frame.""" + def evaluate_expression(self, expression, frame_id=None, context="watch"): + """Evaluate a DAP `evaluate` request in the context of a frame. + + `watch`/`hover` (and any other/absent `context`) keep the original, + read-only contract: `eval()` only - a statement is a `SyntaxError`, + surfaced as an evaluation error, exactly as before this method + gained statement support. + + `repl`/`clipboard` add statement execution: `eval()` is tried first + (so a plain expression like `1 + 1` still returns a value); a + `SyntaxError` falls back to `exec(expression, globals_dict)` against + the frame's live `f_globals` only. The locals snapshot is + deliberately never passed to `exec` as a namespace - `exec(code, g, + l)` binds a top-level assignment into `l`, and `l` here is a + disposable copy handed back to the caller and then discarded, so + the assignment would silently vanish instead of taking effect. Only + `globals_dict` is live, so a statement's top-level assignments land + in the running module namespace and are visible to the target + program after `continue`. See `_shadowed_local_warning` for the + honesty-rule warning this implies when the assigned name also + exists as a frame LOCAL. + """ if frame_id is not None and frame_id in self.variables_cache: frame = self.variables_cache[frame_id] globals_dict = frame.f_globals if hasattr(frame, "f_globals") else {} @@ -661,13 +743,26 @@ def evaluate_expression(self, expression, frame_id=None): else: globals_dict = globals() locals_dict = {} + try: - # Evaluate the expression result = eval(expression, globals_dict, locals_dict) return result + except SyntaxError as e: + if context not in ("repl", "clipboard"): + raise Exception(f"Evaluation error: {e}") except Exception as e: raise Exception(f"Evaluation error: {e}") + # Only repl/clipboard reach here, and only after eval() raised a + # SyntaxError - try `expression` as a statement instead. + try: + exec(expression, globals_dict) + except Exception as e: + raise Exception(f"Evaluation error: {e}") + + warning = _shadowed_local_warning(expression, locals_dict) + return warning if warning else "" + def cleanup(self): """Clean up resources with enhanced cache management.""" self.variables_cache.clear() From 0c13480457b8dc131c3b44fc2f7280852112bb6d Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Wed, 5 Aug 2026 18:06:34 +1000 Subject: [PATCH 30/31] debugpy: Return the bound endpoint from listen() before accepting. listen() bound the socket, blocked in accept() and handled the client's initialize request before returning, so a caller could only learn the endpoint after a client had already connected to it - unusable for any orchestration that has to read the address in order to attach. listen() now returns as soon as the socket is bound. The accept and the initialize handshake move into wait_for_client(), which creates the session. This matches CPython debugpy, where listen() reports the endpoint and wait_for_client() blocks. port=0 now raises instead of substituting DEFAULT_PORT when the target's getsockname() cannot report the assigned port: callers act on the returned endpoint, so naming an address the socket is not bound to sends them somewhere nothing is listening. Signed-off-by: Andrew Leech Claude-Session: https://claude.ai/code/session_01PxZTAGYHMm6i8CUF4tW885 --- python-ecosys/debugpy/debugpy/public_api.py | 80 ++++++++++++++------- 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/python-ecosys/debugpy/debugpy/public_api.py b/python-ecosys/debugpy/debugpy/public_api.py index 59577a44e..6379d4523 100644 --- a/python-ecosys/debugpy/debugpy/public_api.py +++ b/python-ecosys/debugpy/debugpy/public_api.py @@ -7,21 +7,31 @@ from .server.debug_session import DebugSession _debug_session = None +# Bound-but-not-yet-accepted socket, held between listen() and the accept that +# wait_for_client() performs. +_listener = None def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): - """Start listening for debugger connections. + """Bind a listening socket and return the address it is bound to. + + Returns as soon as the socket is bound, WITHOUT waiting for a client, so + the caller can publish the endpoint that a client then connects to. The + accept and the `initialize` handshake happen in `wait_for_client()`. This + matches CPython debugpy, where `listen()` reports the endpoint and + `wait_for_client()` blocks. Args: - port: Port number to listen on (default: 5678) + port: Port number to listen on, or 0 to let the system choose + (default: 5678) host: Host address to bind to (default: "127.0.0.1") Returns: - (host, port) tuple of the actual listening address + (host, port) tuple of the actual bound address """ - global _debug_session + global _listener - if _debug_session is not None: + if _listener is not None or _debug_session is not None: raise RuntimeError("Already listening for debugger") # Create listening socket @@ -47,25 +57,42 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): except Exception: pass if requested_port == 0 and port == 0: - # The caller asked for an OS-assigned port and this port has no way - # to report what the OS actually picked. Advertising port 0 in the - # handshake would tell the client to connect to a port that can - # never accept a connection, so fall back to the documented default - # instead - it is at least a real, well-known port to try. - port = DEFAULT_PORT + # Callers act on the endpoint this returns, so reporting a port + # nothing can connect to would be worse than refusing: substituting + # DEFAULT_PORT here would advertise an address the socket is not + # bound to. Ask for an explicit port on a target whose getsockname() + # cannot report the OS-assigned one. + listener.close() + raise OSError( + "port=0 needs getsockname() to report the assigned port, which " + "this target does not implement; pass an explicit port" + ) + _listener = listener print(f"Debugpy listening on {host}:{port}") + return (host, port) + + +def _accept_and_initialize(): + """Accept the pending connection and handle the client's `initialize`. + + Split out of `listen()` so the endpoint can be published before a client + exists. Returns True once a session is ready. + """ + global _debug_session, _listener + + if _listener is None: + print("[DAP] no listening socket; call listen() first") + return False - # Wait for connection + listener, _listener = _listener, None client_sock = None try: client_sock, client_addr = listener.accept() print(f"Debugger connected from {format_client_addr(client_addr)}") - # Create debug session _debug_session = DebugSession(client_sock) - # Handle just the initialize request, then return immediately print("[DAP] Waiting for initialize request...") init_message = _debug_session.channel.recv_message() if init_message and init_message.get("command") == "initialize": @@ -78,20 +105,20 @@ def listen(port=DEFAULT_PORT, host=DEFAULT_HOST): _debug_session.channel.sock.settimeout(0.001) print("[DAP] Debug session ready - all other messages will be handled in trace function") + return True except Exception as e: print(f"[DAP] Connection error: {e}") if client_sock: client_sock.close() - _debug_session = None + _debug_session = None + return False finally: # The accepted client socket is independent of the listener; closing # the listener does not affect it. This is a single-connection server, # so stop listening once the client is accepted. listener.close() - return (host, port) - def format_client_addr(client_addr): """Format client address using socket module methods""" @@ -116,17 +143,18 @@ def format_client_addr(client_addr): def wait_for_client(timeout_s=None): - """Block until the DAP client has finished configuring (configurationDone). - - Replaces a fixed sleep after debug_this_thread(): breakpoints the client - sets before configurationDone are honoured because this drains the socket - the whole time it waits. Returns True once configurationDone arrives, - False after a bounded timeout (logged, not silent) or if no session is - listening. + """Block until a client has attached and finished configuring. + + Accepts the connection and handles `initialize` (both deferred by + `listen()` so the endpoint can be published first), then waits for + `configurationDone`. Breakpoints the client sets before then are honoured + because this drains the socket the whole time it waits. Returns True once + configurationDone arrives, False after a bounded timeout (logged, not + silent) or if nothing is listening. """ global _debug_session - if _debug_session is None: - print("[DAP] wait_for_client: no debug session is listening, nothing to wait for") + if _debug_session is None and not _accept_and_initialize(): + print("[DAP] wait_for_client: nothing is listening, nothing to wait for") return False if timeout_s is None: return _debug_session.wait_for_client() From 3432190b88587c0567628f52ee06f3c9d8bd2bf8 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Thu, 6 Aug 2026 17:18:01 +1000 Subject: [PATCH 31/31] debugpy: Stop nested message pumps from clobbering the socket timeout. process_pending_messages() set a 1 ms socket timeout and restored blocking mode in its finally. The trace function calls it on entry to every new frame, so handling a message re-enters it, and the inner call's finally put the socket back into blocking mode underneath the outer loop. That loop's next recv() then waited for a message the client will not send until it has seen an event the loop itself is what produces - a deadlock between the two sides. It only bites when the clobber lands inside the window after configurationDone, which is why it presented as a load-sensitive flake: the session hangs before wait_for_client() returns, so the target never runs and no stopped event is ever produced. The nesting is tracked rather than the timeout saved and restored, because MicroPython sockets have no gettimeout(). Measured on the wrapper repo's harness: the previously worst-affected file went from 4 clean runs in 6 to 6 in 6, and the full suite from 0 clean in 3 to 3 in 4. Signed-off-by: Andrew Leech Claude-Session: https://claude.ai/code/session_01PxZTAGYHMm6i8CUF4tW885 --- .../debugpy/debugpy/server/debug_session.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/python-ecosys/debugpy/debugpy/server/debug_session.py b/python-ecosys/debugpy/debugpy/server/debug_session.py index 483864411..8a594a246 100644 --- a/python-ecosys/debugpy/debugpy/server/debug_session.py +++ b/python-ecosys/debugpy/debugpy/server/debug_session.py @@ -65,6 +65,7 @@ def __init__(self, client_socket): self.stepping = False self.paused = False self.configuration_done = False + self._pumping = False # Probed once at session start; never inferred from a build/variant name. self.capabilities = self.probe_capabilities() self.pdb.capabilities = self.capabilities @@ -201,7 +202,19 @@ def initialize_connection(self): print(f"[DAP] Initialization error: {e}") def process_pending_messages(self): - """Process any pending DAP messages without blocking.""" + """Process any pending DAP messages without blocking. + + Not re-entered: the trace function calls this on entry to every new + frame, so handling a message here can call it again. A nested call + must not touch the socket timeout, because its `finally` would put the + socket back into blocking mode underneath the outer loop, whose next + recv() then waits for a message the client will not send until it has + seen an event this loop is what produces. MicroPython sockets have no + gettimeout(), so the nesting is tracked rather than the timeout saved. + """ + if self._pumping: + return + self._pumping = True try: # Set socket to non-blocking mode for message processing self.channel.sock.settimeout(0.001) # Very short timeout @@ -218,6 +231,7 @@ def process_pending_messages(self): finally: # Reset to blocking mode self.channel.sock.settimeout(None) + self._pumping = False def _handle_message(self, message): """Handle incoming DAP messages."""