Permalink
Browse files

Node wrapper around Python debugger.

Summary:
This introduces a new Python debugger with a JavaScript wrapper that is tailored
for Nuclide. As noted in the `README.md`, it may not be as full-featured as
other Python debuggers out there, but having our own makes it easier for us to
ensure that it works with both local and remote debugging with minimal setup in
Nuclide.

Reviewed By: johnislarry

Differential Revision: D3941993

fbshipit-source-id: dd1a48e8c44c44f82d83ff11a7b00bd9b14f08e3
  • Loading branch information...
bolinfest authored and Facebook Github Bot committed Oct 1, 2016
1 parent b1ed705 commit 694d3c504a32f788ca28f4c044f8d65d6cdcbf67
@@ -0,0 +1,67 @@
+'use babel';
+/* @flow */
+
+/*
+ * Copyright (c) 2015-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the license found in the LICENSE file in
+ * the root directory of this source tree.
+ */
+
+import type {Breakpoint, Message} from './types';
+import type {Observable} from 'rxjs';
+
+import {Subject} from 'rxjs';
+
+/**
+ * DebuggerCommander is used to take debugger commands from the user and stream them to a debugger.
+ * The debugger can consume the commands by calling `commander.asObservable().subscribe()`.
+ *
+ * Exposing the DebuggerCommander as an Observable makes it easier to use with Nuclide's RPC
+ * framework.
+ */
+export class DebuggerCommander {
+ _subject: Subject<Message>;
+
+ constructor() {
+ this._subject = new Subject();
+ }
+
+ // Ideally, we would just expose subscribe(), but this is easier with our RPC framework.
+ asObservable(): Observable<Message> {
+ return this._subject.asObservable();
+ }
+
+ addBreakpoint(breakpoint: Breakpoint): void {
+ this._subject.next({method: 'add_breakpoint', breakpoint});
+ }
+
+ clearBreakpoint(breakpoint: Breakpoint): void {
+ this._subject.next({method: 'clear_breakpoint', breakpoint});
+ }
+
+ continue(): void {
+ this._subject.next({method: 'continue'});
+ }
+
+ jump(line: number): void {
+ this._subject.next({method: 'jump', line});
+ }
+
+ next(): void {
+ this._subject.next({method: 'next'});
+ }
+
+ quit(): void {
+ this._subject.next({method: 'quit'});
+ }
+
+ return(): void {
+ this._subject.next({method: 'return'});
+ }
+
+ step(): void {
+ this._subject.next({method: 'step'});
+ }
+}
@@ -0,0 +1,22 @@
+# Python debugger
+
+This directory contains an implementation of a JavaScript API for a Python debugger.
+The interface is asynchronous, and uses `Observable`s from [RxJS](http://reactivex.io/rxjs/) as its
+primary form of communication.
+
+Although this debugger is not as feature-rich as
+[PyDev.Debugger](https://github.com/fabioz/PyDev.Debugger), the primary goal is to provide a Python
+debugger that works both locally and remotely with Atom with minimal setup.
+
+## Design
+
+The Python debugger communicates with the JavaScript via a UNIX domain socket. The Python debugger
+is written in pure Python 2, so it does not use any fancy async libraries. As such, it synchronously
+writes to the socket when it has something to broadcast. It only performs blocking reads from the
+socket when the debugger has reached a stopping point. This is by no means ideal, but it is a place
+to start.
+
+## Known Limitations
+
+The Python logic is built on top of [`bdb.Bdb`](https://docs.python.org/2/library/bdb.html).
+As it is currently implemented, it only supports debugging single-threaded Python programs.
@@ -0,0 +1,114 @@
+'use babel';
+/* @flow */
+
+/*
+ * Copyright (c) 2015-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the license found in the LICENSE file in
+ * the root directory of this source tree.
+ */
+
+import type {Observer} from 'rxjs';
+import type {DebuggerEvent, Message} from './types';
+
+import {Observable} from 'rxjs';
+import child_process from 'child_process';
+import net from 'net';
+import pathUtil from '../../commons-node/nuclideUri';
+import split from 'split';
+import uuid from 'uuid';
+
+const METHOD_CONNECT = 'connect';
+const METHOD_EXIT = 'exit';
+const METHOD_INIT = 'init';
+const METHOD_START = 'start';
+const METHOD_STOP = 'stop';
+
+const PARAM_BREAKPOINTS = 'breakpoints';
+const PARAM_METHOD = 'method';
+
+export function launchDebugger(
+ commander: Observable<Message>,
+ initialBreakpoints: Array<Object>,
+ pathToPythonExecutable: string,
+ pythonArgs: Array<string>,
+): Observable<DebuggerEvent> {
+ return Observable.create((observer: Observer<DebuggerEvent>) => {
+ function log(message: string) {
+ observer.next({event: 'log', message});
+ }
+
+ const server = net.createServer((connection: net$Socket) => {
+ // For simplicity, we use newline-delimited-JSON as our wire protocol.
+ function write(dict: mixed) {
+ connection.write(JSON.stringify(dict) + '\n');
+ }
+
+ // Listen to events broadcast from the Python debugger.
+ connection
+ .pipe(split(JSON.parse, /* mapper */ null, {trailing: false}))
+ .on('data', (args: Object) => {
+ const method = args[PARAM_METHOD];
+ if (method === METHOD_CONNECT) {
+ // On initial connection, we should send the breakpoints over.
+ write({[PARAM_METHOD]: METHOD_INIT, [PARAM_BREAKPOINTS]: initialBreakpoints});
+ observer.next({event: 'connected'});
+ } else if (method === METHOD_STOP) {
+ const {file, line} = args;
+ observer.next({event: 'stop', file, line});
+ } else if (method === METHOD_EXIT) {
+ observer.next({event: 'exit'});
+ connection.end();
+ } else if (method === METHOD_START) {
+ observer.next({event: 'start'});
+ } else {
+ const error = new Error(`Unrecognized message: ${JSON.stringify(args)}`);
+ observer.error(error);
+ }
+ });
+
+ // Take requests from the input commander and pass them through to the Python debugger.
+ // TODO(mbolin): If a `quit` message comes in, we should tear down everything from here
+ // because the Python code may be locked up such that it won't get the message.
+ commander.subscribe(
+ write,
+ (error: Error) => log(`Unexpected error from commander: ${String(error)}`),
+ () => log('Apparently the commander is done.'),
+ );
+
+ connection.on('end', () => {
+ // In the current design, we only expect there to be one connection ever, so when it
+ // disconnects, we can shut down the server.
+ server.close();
+ observer.complete();
+ });
+ });
+
+ server.on('error', err => {
+ observer.error(err);
+ throw err;
+ });
+
+ const socketPath = createSocketPath();
+ server.listen({path: socketPath}, () => {
+ log(`listening for connections on ${socketPath}. About to run python.`);
+
+ // The connection is set up, so now we can launch our Python program.
+ const pythonDebugger = pathUtil.join(__dirname, 'main.py');
+ const args = [pythonDebugger, socketPath].concat(pythonArgs);
+ const python = child_process.spawn(pathToPythonExecutable, args);
+
+ /* eslint-disable no-console */
+ // TODO(mbolin): These do not seem to be fired until the debugger finishes.
+ // Probably need to handle things differently in debugger.py.
+ python.stdout.on('data', data => console.log(`python stdout: ${data}`));
+ python.stderr.on('data', data => console.log(`python stderr: ${data}`));
+ /* eslint-enable no-console */
+ });
+ });
+}
+
+function createSocketPath(): string {
+ return pathUtil.join(require('os').tmpdir(), `${uuid.v4()}.sock`);
+}
@@ -0,0 +1,157 @@
+# Copyright (c) 2015-present, Facebook, Inc.
+# All rights reserved.
+#
+# This source code is licensed under the license found in the LICENSE file in
+# the root directory of this source tree.
+
+import bdb
+import json
+import socket
+import sys
+
+METHOD_CONNECT = 'connect'
+METHOD_CONTINUE = 'continue'
+METHOD_EXIT = 'exit'
+METHOD_INIT = 'init'
+METHOD_JUMP = 'jump'
+METHOD_NEXT = 'next'
+METHOD_QUIT = 'quit'
+METHOD_RETURN = 'return'
+METHOD_START = 'start'
+METHOD_STEP = 'step'
+METHOD_STOP = 'stop'
+
+
+def debug(path_to_socket):
+ '''Creates a new Debugger that will publish events to path_to_socket.
+
+ Note that this debugger is only designed for single-threaded Python code
+ right now. Check out threading.set_trace() and
+ https://github.com/fabioz/PyDev.Debugger for a multi-threaded debugger.
+
+ Args:
+ path_to_socket (string): absolute path to .sock file
+ Returns:
+ Debugger once debugging has finished.
+ '''
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ io = SocketIO(sock)
+ sock.connect(path_to_socket)
+ io.send({'method': METHOD_CONNECT})
+ init_message = io.next()
+ if init_message['method'] != METHOD_INIT:
+ raise Exception('Received method other than init: ' + str(init_message))
+
+ # We wait until we have the initial breakpoints before proceeding.
+ debugger = Debugger(sock, io)
+ for bp in init_message['breakpoints']:
+ debugger.set_break(bp['file'], bp['line'])
+ cmd = 'execfile(%r)' % sys.argv[0]
+ debugger.run(cmd)
+ return debugger
+
+
+class SocketIO(bdb.Bdb):
+ '''Reads and writes newline-delimited-JSON via a socket using blocking I/O.
+ '''
+ def __init__(self, sock):
+ self._sock = sock
+ self._data = ''
+
+ def send(self, payload):
+ '''
+ Args:
+ payload: Some sort of JSON-serializable object.
+ '''
+ self._sock.send(json.dumps(payload) + '\n')
+
+ def next(self):
+ '''Returns the next parsed JSON value.
+
+ WARNING: This method is blocking.
+ '''
+ while True:
+ index = self._data.find('\n')
+ if index != -1:
+ data = self._data[:index]
+ out = json.loads(data)
+ self._data = self._data[index + 1:]
+ return out
+ else:
+ self._data += self._sock.recv(4096)
+
+
+class Debugger(bdb.Bdb):
+ def __init__(self, sock, io):
+ '''Assumes sys.argv is in the proper state.
+ '''
+ bdb.Bdb.__init__(self)
+ self._sock = sock
+ self._io = io
+
+ def run(self, cmd, globals=None, locals=None):
+ bdb.Bdb.run(self, cmd, globals, locals)
+ self._send_message({'method': METHOD_EXIT})
+ self._sock.close()
+
+ def user_line(self, frame):
+ '''Override abstract method.
+
+ One of self.stop_here(frame) or self.break_here(frame) is True.
+ '''
+ # If we are at a breakpoint, notify the client and block until we
+ # are ready to yield control back to the running program.
+ filename = frame.f_code.co_filename
+ line = frame.f_lineno
+ if filename == '<string>' and line == 1:
+ # This is a special call that happens when the debugger starts that
+ # gives clients a chance to perform any setup before continuing.
+ self._send_message({
+ 'method': METHOD_START,
+ })
+ else:
+ self._send_message({
+ 'method': METHOD_STOP,
+ 'file': filename,
+ 'line': line,
+ })
+ self._process_events_until_resume(frame, traceback=None)
+
+ def _process_events_until_resume(self, frame, traceback):
+ self.stack, self.curindex = self.get_stack(frame, traceback)
+ self.curframe = self.stack[self.curindex][0]
+ while True:
+ message = self._io.next()
+ method = message['method']
+ if method == METHOD_CONTINUE:
+ self.set_continue()
+ return
+ elif method == METHOD_NEXT:
+ self.set_next(self.curframe)
+ return
+ elif method == METHOD_STEP:
+ self.set_step()
+ return
+ elif method == METHOD_RETURN:
+ self.set_return(self.curframe)
+ return
+ elif method == METHOD_JUMP:
+ if self.curindex + 1 != len(self.stack):
+ # You can only jump within the bottom frame.
+ return
+ line = message['line']
+ self.curframe.f_lineno = line
+ self.stack[self.curindex] = self.stack[self.curindex][0], line
+ # self.print_stack_entry(self.stack[self.curindex])
+ return
+ elif method == METHOD_QUIT:
+ self.set_quit()
+ return
+
+ # User may be trying to do something while the debugger is
+ # suspended, such as setting or removing a breakpoint.
+ # TODO(mbolin): Handle other events.
+ pass
+
+ def _send_message(self, payload):
+ self._io.send(payload)
@@ -0,0 +1,18 @@
+# Copyright (c) 2015-present, Facebook, Inc.
+# All rights reserved.
+#
+# This source code is licensed under the license found in the LICENSE file in
+# the root directory of this source tree.
+
+'''There is no shebang for this file: it must be run as an argument to `python`.
+'''
+
+if __name__ == '__main__':
+ '''This should be run via `python main.py <unix.sock> <prog.py> <arg1> ...`.
+ '''
+ import debugger
+ import sys
+ path_to_socket = sys.argv[1]
+ del sys.argv[0] # Hide "main.py" from argument list.
+ del sys.argv[0] # Hide path_to_socket from argument list.
+ debugger.debug(path_to_socket)
Oops, something went wrong.

0 comments on commit 694d3c5

Please sign in to comment.