UNPKG

devil-windows

Version:

Debugger, profiler and runtime with embedded WebKit DevTools client (for Windows).

742 lines (617 loc) 23.1 kB
var util = require('util'), spawn = require('child_process').spawn, EventEmitter = require('events').EventEmitter, freeport = require('freeport'), async = require('async'), Debugger = require('./Debugger'), FakeAgent = require('./Agent'), agents = require('./agents/index'); var statusCodes = { 1: { name: 'Uncaught Fatal Exception', message: 'There was an uncaught exception, and it was not handled by a domain or an uncaughtException event handler.' }, 2: { name: 'Bash Misuse', message: 'Bash for builtin misuse' }, 3: { name: 'Internal JavaScript Parse Error', message: 'The JavaScript source code internal in Node\'s bootstrapping process caused a parse error.This is extremely rare, and generally can only happen during development of Node itself.' }, 4: { name: 'Internal JavaScript Evaluation Failure', message: 'The JavaScript source code internal in Node\'s bootstrapping process failed to return a function value when evaluated.This is extremely rare, and generally can only happen during development of Node itself.' }, 5: { name: 'Fatal Error', message: 'There was a fatal unrecoverable error in V8. Typically a message will be printed to stderr with the prefix FATAL ERROR.' }, 6: { name: 'Non-function Internal Exception Handler', message: 'There was an uncaught exception, but the internal fatal exception handler function was somehow set to a non-function, and could not be called.' }, 7: { name: 'Internal Exception Handler Run-Time Failure', message: 'There was an uncaught exception, and the internal fatal exception handler function itself threw an error while attempting to handle it. This can happen, for example, if a process.on(\'uncaughtException\') or domain.on(\'error\') handler throws an error.' }, 8: { name: 'Unknown', message: 'In previous versions of Node, exit code 8 sometimes indicated an uncaught exception.' }, 9: { name: 'Invalid Argument', message: 'Either an unknown option was specified, or an option requiring a value was provided without a value.' }, 10: { name: 'Internal JavaScript Run-Time Failure', message: 'The JavaScript source code internal in Node\'s bootstrapping process threw an error when the bootstrapping function was called.' }, 11: { name: 'Unknown', message: 'Unknown error' }, 12: { name: 'Invalid Debug Argument', message: 'The --debug and/or --debug-brk options were set, but an invalid port number was chosen.' }, default: { name: 'Signal Exit', message: 'Killed with signal.' } }; function Session (file, args, options) { var $this = this; // // Private stuff var _file = file; var _args = args; var _debuggerPort = 5858; var _config = { breakFirst: false, stackTraceLimit: 10, hidden: [ /.+\/devil\/lib\/v8\-profiler\/.+/, /.+\/devil\/lib\/heapdump\/.+/, /.+\/devil\/src\/server\/Debuggee\.js/ ], preload: true, saveLiveEdit: true, mute: false }; /** * Debugger client * * @type {Debugger} * @private */ var _debugger = null; /** * WebSockets client * * @type {Object} * @private */ var _client = null; /** * The child process * * @type {ChildProcess} * @private */ var _process = null; /** * Is the process running or not. Refers ONLY to the process. * * @type {boolean} * @private */ var _running = false; /** * Weather the process is already started once. Used only inside the process handlers. * * @type {boolean} * @private */ var _started = false; /** * Notifications queue. This is used only when notifications are paused (in the beginning of every client connection). * * @type {Array} * @private */ var _queue = []; /** * Events paused or not * * @type {boolean} * @private */ var _paused = true; /** * Debugger is connected or not * * @type {boolean} * @private */ var _connected = false; /** * If we already handled the initial break. * * @type {boolean} * @private */ var _breakHandled = false; /** * Count of current sending requests * * @type {number} * @private */ var _sending = 0; /** * Collection of agents * * @type {Object} * @private */ var _agents = {}; // Parse options options = options || {}; if (options.mute) _config.mute = true; if (options.breakFirst) _config.breakFirst = true; //if (options.preload) _config.preload = true; if (options.hasOwnProperty('saveLiveEdit') && (typeof options.saveLiveEdit !== 'undefined') && !options.saveLiveEdit) _config.saveLiveEdit = false; if (options.stackTraceLimit && !isNaN(parseInt(options.stackTraceLimit)) && isFinite(parseInt(options.stackTraceLimit))) _config.stackTraceLimit = parseInt(options.stackTraceLimit); if (options.hidden && ((typeof options.hidden === 'array') || (options.hidden instanceof Array))) { for (var i = 0; i < options.hidden.length; i ++) { if (typeof options.hidden[i] === 'string') { try { var r = new RegExp(options.hidden[i]); } catch (e) { continue; } _config.hidden.push(r); } } } if (options.debuggerPort && !isNaN(parseInt(options.debuggerPort)) && isFinite(parseInt(options.debuggerPort))) _debuggerPort = parseInt(options.debuggerPort); /** * Pause notifications * * @private */ var _pauseNotifications = function () { if (_paused) return; _paused = true; }; /** * Resume notifications * * @private */ var _resumeNotifications = function () { if (!_paused) return; _paused = false; while (_queue.length) _send(_queue.shift()); }; /** * Send something to the client * * @param message * @private */ var _send = function (message) { var payload = typeof message == 'string' ? message : JSON.stringify(message); console.log('Backend: ' + payload); if (message.id) { // Response if (!_client) return; if (message.error) { if (typeof message.error === 'string') message.error = { message: message.error, name: 'DebuggerError' }; // TODO: display the error somehow. if (message.error.name == 'NoConnection') { // Legacy stuff. This wont happen now, since there is a handler for closed connection. return _stop(message.error); } } } else { // Notification if (!_client || _paused) { _queue.push(message); return; } } _sending ++; _client.send(payload, function () { _sending --; if (_sending <= 0) { _sending = 0; console.log("Session: Emitting sent"); $this.emit('sent'); } }); }; /** * Check if port is available * * @param port * @param fn * @private */ var _isPortAvailable = function (port, fn) { var net = require('net'); var tester = net.createServer().once('error', function (err) { if (err.code != 'EADDRINUSE') return fn(err); fn(null, false); }).once('listening', function () { tester.once('close', function () { fn(null, true); }).close(); }).listen(port); }; /** * If debugger port is not available, we use this function to get an available one. * * @param callback * @private */ var _fixDebuggerPort = function (callback) { _isPortAvailable(_debuggerPort, function (err, free) { if (err) return callback(err); if (!free) { // For debugger port, we find the first available port to use. freeport(function (err, port) { if (err) { console.demonicLog("[DEBUGGER] Could not find a free port."); return callback(err); } _debuggerPort = port; callback(); }); return; } callback(); }); }; /** * Start the debugged process * * @param callback * @returns {*} * @private */ var _startDebuggee = function (callback) { // Check the file first var f = null; try {f = require.resolve(_file);} catch (e) {} if (!f) return callback(new Error("Cannot resolve file: " + _file)); _file = f; // So we start the new node instance with a debugger attached and we always break first, because we need a break to inject. _args.unshift(_file); _args.unshift('--debug-brk'); _args.unshift('--debug=' + _debuggerPort); try { _process = spawn(process.execPath, _args); _process.stdout.setEncoding('utf8'); _process.stderr.setEncoding('utf8'); _running = true; } catch (err) { // This is usually not supposed to happen. Paranoia. return callback(err); } // At this point it's better maybe to call the callback and then continue with the rest. // We will still wait for a lot of stuff after this, but the client needs time to connect too, // so we give it ahead start. callback(); // Error events may or may not be sent before exit events. // We have to listen for this event for sure, but never rely on it. // Let's just send the errors to the user but not kill the process or something. _process.on('error', function (err) { console.demonicLog("[ERROR] Process returned an error. " + err.message); $this.emit('error', err); }); // The process always gives exit before close, because close is called when // the stdio is closed. We need the stdio to finish all event handling so // it's better to use the close event for real stuff and the exit only to notify. _process.on('exit', _exitHandler); _process.on('close', function (code, signal) { if (code === null || code > 128) _unexpectedCloseHandler(signal); else if (code == 0) _normalCloseHandler(); else _errorCloseHandler(code); }); // For us the process is started when we first receive data of any kind. // The normal case will send "Debugger listening on port ..." var dataCb = function (data) { if (!_started) { if (_running) setTimeout(function () { _debugger.connect(_debuggerPort); }, 10); _started = true; } }; _process.stdout.once('data', dataCb); _process.stderr.once('data', dataCb); // And finally give that process to the debugger _debugger.attachProcess(_process); }; var _disconnectClient = function (reason, callback) { console.demonicLog("[INFO] Disconnecting client...", reason); if (!_client) return callback ? callback() : 0; // Send all pending stuff if (_paused && _queue.length) { _resumeNotifications(); } $this.once('sent', function disconnect() { // Everything is sent now. Just close the connection. _client.removeAllListeners('message'); _client.close(); _client = null; if (callback) callback(); }); _send({method: 'Inspector.detached', params: {reason: reason}}); _pauseNotifications(); }; var _stopCallback = null; /** * Stop the debugged process and everything behind it. * * @param error * @param callback * @private */ var _stop = function (error, callback) { if (callback) _stopCallback = callback; // If process is running, stop it. // Now, if this method is called and the process is still running, this means that // this function is called intentionally by the user (the process handlers would call it after it's not running anymore). // So maybe a good way is to kill the process and return and leave the handlers to call this function again. if (_process) { // But remove all event listeners anyway. if (_running) { // Here is the trick. Process is still running. Kill it. try { _process.kill('SIGKILL'); } catch (e) { } return; } // Just delete the process now. Whatever happened above, we don't need this instance anymore. _process.removeAllListeners(); _process = null; _running = false; } if (!callback) callback = _stopCallback; _connected = false; _queue = []; _paused = false; // Generate the stop reason var reason = ''; if (error) { if (typeof error === 'string') reason = error; else if (typeof error === 'object') { reason = error.name ? error.name + ': ' : ''; reason += error.message ? error.message : (error.description ? error.description : 'No message'); } else reason = 'Unknown reason'; } else { reason = 'The processed finished. No errors detected.'; } $this.emit('stop', reason); // Destroy the debugger instance. if (_debugger) { // Destroying is async because we have to wait for all events to get handled // by the debugger and sent to the agents before we can destroy everything. // Otherwise, some of the final console messages (for example) don't arrive on the client. _debugger.destroy(function () { // Destroy all agents now if any exist still. if (_agents) Object.keys(_agents).forEach(function (agent) { _agents[agent].destroy(); delete _agents[agent]; }); // After destruction is ready, we are also ready to disconnect the client finally. _disconnectClient(reason, callback); }); _debugger = null; } else { // Destroy all agents now if any exist still. if (_agents) Object.keys(_agents).forEach(function (agent) { _agents[agent].destroy(); delete _agents[agent]; }); // There is no debugger to destroy. // Finally disconnect the client (if there is one) and call the callback. _disconnectClient(reason, callback); } }; /** * Handles all kinds of exits, stops everything but waits for the close event to disconnect the client. * * @private */ var _exitHandler = function () { _running = false; }; /** * Normal close, without any errors. * * @private */ var _normalCloseHandler = function () { _process = null; _running = false; console.demonicLog("\n\n------\n[INFO] Execution finished without errors."); _stop(null); }; /** * Killed with a signal or something like that. * * @param signal * @private */ var _unexpectedCloseHandler = function (signal) { _process = null; _running = false; console.demonicLog("\n\n------\n[INFO] Killed with signal: " + signal); _stop({ name: 'Killed', message: 'Killed with signal: ' + signal }); }; /** * Error close handler. Error codes are described above. * * @param code * @private */ var _errorCloseHandler = function (code) { _process = null; _running = false; console.demonicLog("\n\n------\n[INFO] Exited with status: " + code); if (statusCodes[code]) { _stop(statusCodes[code]); } else { // Unknown status _stop({ name: 'Unknown Error', message: 'Unknown status code: ' + code }); } }; // // Public stuff /** * Handle all requests from the client * * @private */ this.request = function (request) { var tmp = request.method.split('.'); // Hack for profiler tmp[0] = tmp[0] === 'HeapProfiler' ? 'Profiler' : tmp[0]; request.fullMethod = request.method; request.agent = tmp[0]; request.method = tmp[1]; if (!(request && request.id)) { // Totally wrong stuff. Ignore. console.demonicLog("[ERROR] Wrong request format.", request); return; } // No request if there no debugger. if (!_debugger) return; if (_agents && request && request.method && _agents[request.agent] && _agents[request.agent].hasMethod(request.method)) { // Handle the request _agents[request.agent][request.method](request.params, function (err, result) { _send({id: request.id, error: err, result: result}); }); } else if (request && request.id) { _send({id: request.id, error: 'Wrong request or not implemented method.'}); } }; this.attachClient = function (client) { // Start serving but don't send anything before debugger connects. _client = client; _pauseNotifications(); }; this.detachClient = function () { _client = null; }; this.start = function (callback) { // Start the debuggee and wait for the debugger to connect async.waterfall([_fixDebuggerPort, _startDebuggee], callback); }; this.stop = function (callback) { _stop(null, function () { callback(); }); }; this.pause = function (callback) { if (_connected) _debugger.pause(callback); else callback(new Error("Not connected to the debugger.")); }; this.resume = function (callback) { if (_connected) _debugger.resume(callback); else callback(new Error("Not connected to the debugger.")); }; this.isConnected = function () { return _connected; }; this.isRunning = function () { return _running; }; this.isStarted = function () { return _started; }; // // Initialize // Create the debugger _debugger = new Debugger(_debuggerPort, _config); _debugger.once('connect', function () { console.demonicLog('[INFO] Debugger connected.'); _connected = true; }); _debugger.on('ready', function () { // This is called when the inject is ready. // For now we don't need this event but let's leave it as a reminder. $this.emit('ready'); }); _debugger.on('pause', function () { $this.emit('pause'); }); _debugger.on('resume', function () { $this.emit('resume'); }); _debugger.once('close', function () { // When the debugger closes we have to close all connections and disable everything // from now on. First let's check if debugger closed because the process closed. if (!_running) return; setTimeout(function () { if (!_running) return; console.demonicLog('[ERROR] Debugger closed unexpectedly.'); // Ok, so if running, we have to stop! GO ahead... first emit an error and stop. $this.emit('error', new Error('Debugger closed unexpectedly.')); _stop({ name: 'Debugger Error', message: 'Debugger closed unexpectedly.' }); }, 10); }); _debugger.on('error', function (err) { if (!_running) return; setTimeout(function () { if (!_running) return; console.trace('[ERROR] Debugger returned error:', err); // On debugger error it's best to return the error to the user and do nothing. $this.emit('error', new Error('Debugger error: ' + JSON.stringify(err))); }, 0); }); _debugger.on('appTree', function () { // Resume notifications when resource tree is ready _resumeNotifications(); setTimeout(function () { // Also resume the script if the user didn't want a break. if (!_config.breakFirst && !_breakHandled) { console.demonicLog("[INFO] Executing script...\n------\n\n"); _breakHandled = true; _debugger.resume(function (error) { if (error) return $this.emit('error', error); }); } else { console.demonicLog("[INFO] Executing script (paused)...\n------\n\n"); _debugger.breakEventHandler.emitFirstBreak(); } }, 5); }); // Init agents first so they can start listening from now. for (var a in agents) { if (!agents.hasOwnProperty(a)) continue; _agents[a] = (typeof agents[a] === 'function') ? new agents[a](_debugger) : new FakeAgent(_debugger, agents[a]); _agents[a].on('notification', function (method, params) { _send({method: method, params: params}); }); } } util.inherits(Session, EventEmitter); module.exports = Session;