Skip to content
This repository has been archived by the owner on Dec 13, 2018. It is now read-only.

Commit

Permalink
Browse files Browse the repository at this point in the history
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 694d3c5
Show file tree
Hide file tree
Showing 9 changed files with 578 additions and 0 deletions.
67 changes: 67 additions & 0 deletions pkg/nuclide-debugger-python-rpc/debugger/DebuggerCommander.js
@@ -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'});
}
}
22 changes: 22 additions & 0 deletions pkg/nuclide-debugger-python-rpc/debugger/README.md
@@ -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.
114 changes: 114 additions & 0 deletions pkg/nuclide-debugger-python-rpc/debugger/debugger.js
@@ -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`);
}
157 changes: 157 additions & 0 deletions pkg/nuclide-debugger-python-rpc/debugger/debugger.py
@@ -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)
18 changes: 18 additions & 0 deletions pkg/nuclide-debugger-python-rpc/debugger/main.py
@@ -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)

0 comments on commit 694d3c5

Please sign in to comment.