From b6591e0bcf229b67816becc48dd9e4939c4a5f26 Mon Sep 17 00:00:00 2001 From: adelphes Date: Tue, 24 Jan 2017 01:05:04 +0000 Subject: [PATCH] added support for exception breakpoints --- src/debugMain.js | 71 +++++++++++++++++-- src/debugger.js | 180 +++++++++++++++++++++++++++++++++++++++++++++++ src/jdwp.js | 90 +++++++++++++++++++++++- 3 files changed, 332 insertions(+), 9 deletions(-) diff --git a/src/debugMain.js b/src/debugMain.js index 32c20ca..bc60687 100644 --- a/src/debugMain.js +++ b/src/debugMain.js @@ -139,6 +139,8 @@ class AndroidDebugSession extends DebugSession { this._locals_done = null; // the fifo queue of evaluations (watches, hover, etc) this._evals_queue = []; + // the last (current) exception info + this._last_exception = null; // since we want to send breakpoint events, we will assign an id to every event // so that the frontend can match events with breakpoints. @@ -165,6 +167,12 @@ class AndroidDebugSession extends DebugSession { // This debug adapter implements the configurationDoneRequest. response.body.supportsConfigurationDoneRequest = true; + + // we support some exception options + response.body.exceptionBreakpointFilters = [ + { label:'All Exceptions', filter:'all', default:false }, + { label:'Uncaught Exceptions', filter:'uncaught', default:true }, + ]; this.sendResponse(response); } @@ -276,6 +284,7 @@ class AndroidDebugSession extends DebugSession { this.dbgr.on('bpstatechange', this, this.onBreakpointStateChange) .on('bphit', this, this.onBreakpointHit) .on('step', this, this.onStep) + .on('exception', this, this.onException) .on('disconnect', this, this.onDebuggerDisconnect); this.waitForConfigurationDone = $.Deferred(); // - tell the client we're initialised and ready for breakpoint info, etc @@ -406,6 +415,7 @@ class AndroidDebugSession extends DebugSession { package: pkgname, package_path: fpn, srcroot: path.join(app_root,src_folder), + public_classes: subfiles.filter(sf => /^[a-zA-Z_$][a-zA-Z0-9_$]*\.java$/.test(sf)).map(sf => sf.match(/^(.*)\.java$/)[1]) } } // add the subfiles to the list to process @@ -566,6 +576,20 @@ class AndroidDebugSession extends DebugSession { this.sendResponse(response); } + setExceptionBreakPointsRequest(response /*: SetExceptionBreakpointsResponse*/, args /*: SetExceptionBreakpointsArguments*/) { + this.dbgr.clearBreakOnExceptions({response,args}) + .then(x => { + if (x.args.filters.includes('all')) { + x.set = this.dbgr.setBreakOnExceptions('both', x); + } else if (x.args.filters.includes('uncaught')) { + x.set = this.dbgr.setBreakOnExceptions('uncaught', x); + } else { + x.set = $.Deferred().resolveWith(this, [x]); + } + x.set.then(x => this.sendResponse(x.response)); + }); + } + threadsRequest(response/*: DebugProtocol.ThreadsResponse*/) { this.dbgr.allthreads(response) @@ -633,9 +657,13 @@ class AndroidDebugSession extends DebugSession { } scopesRequest(response/*: DebugProtocol.ScopesResponse*/, args/*: DebugProtocol.ScopesArguments*/) { + var scopes = [new Scope("Local", args.frameId, false)]; + if (this._last_exception) { + scopes.push(new Scope("Exception", this._last_exception.varref, false)); + } response.body = { - scopes: [new Scope("Local", args.frameId, false)] + scopes: scopes }; this.sendResponse(response); } @@ -826,6 +854,17 @@ class AndroidDebugSession extends DebugSession { }; this.sendResponse(response); } + else if (varinfo.exception) { + this.dbgr.getExceptionLocal(varinfo.exception, {varinfo,response}) + .then((ex_local,x) => { + x.ex_local = ex_local; + return this.dbgr.invokeToString(ex_local.value, x.varinfo.threadid, ex_local.type.signature, x); + }) + .then((call,x) => { + call.name = '{msg}'; + return_mapped_vars(x.varinfo.cached = [call,x.ex_local], x.response); + }); + } else { // frame locals request this.dbgr.getlocals(varinfo.frame.threadid, varinfo.frame, response) @@ -843,6 +882,7 @@ class AndroidDebugSession extends DebugSession { continueRequest(response/*: DebugProtocol.ContinueResponse*/, args/*: DebugProtocol.ContinueArguments*/) { D('Continue'); this._variableHandles = {}; + this._last_exception = null; // sometimes, the device is so quick that a breakpoint is hit // before we've completed the resume promise chain. // so tell the client that we've resumed now and just send a StoppedEvent @@ -879,13 +919,12 @@ class AndroidDebugSession extends DebugSession { doStep(which, response, args) { D('step '+which); this._variableHandles = {}; + this._last_exception = null; + this._running = true; + this._locals_done = $.Deferred(); var threadid = ('000000000000000' + args.threadId.toString(16)).slice(-16); - this.dbgr.step(which, threadid) - .then(() => { - this._running = true; - this._locals_done = $.Deferred(); - this.sendResponse(response); - }); + this.dbgr.step(which, threadid); + this.sendResponse(response); } stepInRequest(response/*: DebugProtocol.NextResponse*/, args/*: DebugProtocol.StepInArguments*/) { @@ -900,6 +939,24 @@ class AndroidDebugSession extends DebugSession { this.doStep('out', response, args); } + /** + * Called by the debugger if an exception event is triggered + */ + onException(e) { + D('exception hit: ' + JSON.stringify(e.throwlocation)); + // it's possible for the debugger to send multiple exception notifications, depending on the package filters + // , so just ignore them if we've already stopped + if (!this._running) return; + this._running = false; + this._last_exception = { + exception: e.event.exception, + threadid: e.throwlocation.threadid, + varref: ++this._nextObjVarRef, + }; + this._variableHandles[this._last_exception.varref] = this._last_exception; + this.sendEvent(new StoppedEvent("exception", parseInt(e.throwlocation.threadid,16))); + } + /** * Called by VSCode to perform watch, console and hover evaluations */ diff --git a/src/debugger.js b/src/debugger.js index a4a5921..7aed072 100644 --- a/src/debugger.js +++ b/src/debugger.js @@ -11,6 +11,7 @@ function Debugger() { this.connection = null; this.ons = {}; this.breakpoints = { all: [], enabled: {}, bysrcloc: {} }; + this.exception_ids = []; this.JDWP = new _JDWP(); this.session = null; this.globals = Debugger.globals; @@ -810,6 +811,100 @@ Debugger.prototype = { }); }, + getExceptionLocal: function (ex_ref_value, extra) { + var x = { + ex_ref_value: ex_ref_value, + extra: extra + }; + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.GetObjectType(ex_ref_value), + }) + .then((typeref, x) => this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.signature(typeref) + })) + .then((type, x) => { + x.type = type; + return this.gettypedebuginfo(type.signature, x) + }) + .then((dbgtype, x) => { + return this._ensurefields(dbgtype[x.type.signature], x) + }) + .then((typeinfo, x) => { + return this._mapvalues('exception', [{ name: '{ex}', type: x.type }], [x.ex_ref_value], {}, x); + }) + .then((res, x) => { + return $.Deferred().resolveWith(this, [res[0], x.extra]) + }); + }, + + invokeMethod: function (objectid, threadid, type_signature, method_name, method_sig, args, extra) { + var x = { objectid, threadid, type_signature, method_name, method_sig, args, extra }; + x.return_type_signature = method_sig.match(/\)(.*)/)[1]; + return this.gettypedebuginfo(x.return_type_signature) + .then(dbgtypes => { + x.return_type = dbgtypes[x.return_type_signature].type; + return this.gettypedebuginfo(x.type_signature); + }) + .then(dbgtype => this._ensuremethods(dbgtype[x.type_signature])) + .then(typeinfo => { + // resolving the methods only resolves the non-inherited methods + // if we can't find a matching method, we need to search the super types + var o = { + dbgr:this, + def:$.Deferred(), + x: x, + find_method(typeinfo) { + for (var mid in typeinfo.methods) { + var m = typeinfo.methods[mid]; + if ((m.name === this.x.method_name) && ((m.genericsig||m.sig) === this.x.method_sig)) { + this.def.resolveWith(this, [typeinfo, m, this.x]); + return; + } + } + // search the supertype + if (typeinfo.type.signature==='Ljava/lang/Object;') { + this.def.rejectWith(this, [new Error('No such method: ' + this.x.method_name + ' ' + this.x.method_sig)]); + return; + } + + this.dbgr._ensuresuper(typeinfo) + .then(typeinfo => { + return this.dbgr.gettypedebuginfo(typeinfo.super.signature, typeinfo.super.signature) + }) + .then((dbgtype, sig) => { + return this.dbgr._ensuremethods(dbgtype[sig]) + }) + .then(typeinfo => { + this.find_method(typeinfo) + }); + } + } + o.find_method(typeinfo); + return o.def; + }) + .then((typeinfo, method, x) => { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.InvokeMethod(x.objectid, x.threadid, typeinfo.info.typeid, method.methodid, x.args), + }); + }) + .then((res, x) => { + if (/^0+$/.test(res.exception)) + return this._mapvalues('return', [{ name:'{return}', type:x.return_type }], [res.return_value], {}, x); + // todo - handle reutrn exceptions + }) + .then((res, x) => $.Deferred().resolveWith(this, [res[0], x.extra])); // res = {return_value, exception} + }, + + invokeToString(objectid, threadid, type_signature, extra) { + return this.invokeMethod(objectid, threadid, type_signature || 'Ljava/lang/Object;', 'toString', '()Ljava/lang/String;', [], extra); + }, + getstringchars: function (stringref, extra) { return this.session.adbclient.jdwp_command({ ths: this, @@ -1343,6 +1438,89 @@ Debugger.prototype = { } }, + clearBreakOnExceptions: function(extra) { + var o = { + dbgr: this, + def: $.Deferred(), + extra: extra, + next() { + if (!this.dbgr.exception_ids.length) { + return this.def.resolveWith(this.dbgr, [this.extra]); // done + } + // clear next pattern + this.dbgr.session.adbclient.jdwp_command({ + cmd: this.dbgr.JDWP.Commands.ClearExceptionBreak(this.dbgr.exception_ids.pop()) + }) + .then(() => this.next()) + .fail(e => this.def.rejectWith(this, [e])) + } + }; + o.next(); + return o.def; + }, + + setBreakOnExceptions: function(which, extra) { + var onevent = { + data: { + dbgr: this, + }, + fn: function (e) { + this._findcmllocation(this.session.classes, e.event.throwlocation) + .then(tloc => { + this._findcmllocation(this.session.classes, e.event.catchlocation) + .then(cloc => { + var eventdata = { + event: e.event, + throwlocation: Object.assign({ threadid: e.event.threadid }, tloc), + catchlocation: Object.assign({ threadid: e.event.threadid }, cloc), + }; + this.session.stoppedlocation = Object.assign({}, eventdata.throwlocation); + this._trigger('exception', eventdata); + }) + }) + }.bind(this) + }; + + var c = false, u = false; + switch (which) { + case 'caught': c = true; break; + case 'uncaught': u = true; break; + case 'both': c = u = true; break; + default: throw new Error('Invalid exception option'); + } + // when setting up the exceptions, we filter by packages containing public classes in the current session + // - each filter needs a separate call (I think), so we do this as an asynchronous list + var pkgs = this.session.build.packages; + var pkgs_to_monitor = Object.keys(pkgs).filter(pkgname => pkgs[pkgname].public_classes.length); + var o = { + dbgr: this, + pkgs: pkgs_to_monitor, + caught: c, + uncaught: u, + onevent: onevent, + cmds:[], + def: $.Deferred(), + extra: extra, + next() { + if (!this.pkgs.length) { + this.def.resolveWith(this.dbgr, [this.extra]); // done + return; + } + // setup next pattern + this.dbgr.session.adbclient.jdwp_command({ + cmd: this.dbgr.JDWP.Commands.SetExceptionBreak(this.pkgs.shift() + '.*', this.caught, this.uncaught, this.onevent), + }) + .then(x => { + this.dbgr.exception_ids.push(x.id); + this.next(); + }) + .fail(e => this.def.rejectWith(this, [e])) + } + }; + o.next(); + return o.def; + }, + _loadclzinfo: function (signature) { return this.gettypedebuginfo(signature) .then(function (classes) { @@ -1429,6 +1607,8 @@ Debugger.prototype = { }, _findmethodasync: function (classes, location) { + // some locations are null (which causes the jdwp command to fail) + if (/^0+$/.test(location.cid)) return $.Deferred().resolveWith(this, [null]); var m = this._findmethod(classes, location.cid, location.mid); if (m) return $.Deferred().resolveWith(this, [m]); // convert the classid to a type signature diff --git a/src/jdwp.js b/src/jdwp.js index 7ab36f8..8570ba1 100644 --- a/src/jdwp.js +++ b/src/jdwp.js @@ -97,7 +97,7 @@ function _JDWP() { } if (this.errorcode != 0) { - console.error("Command failed: error " + this.errorcode); + console.error("Command failed: error " + this.errorcode, this); } if (!this.errorcode && this.command && this.command.replydecodefn) { @@ -131,6 +131,15 @@ function _JDWP() { var DataCoder = { _idsizes:null, + nullRefValue: function() { + if (!this._idsizes._nullreftypeid) { + var x = '00', len = this._idsizes.reftypeidsize * 2; // each byte needs 2 chars + while (x.length < len) x += x; + this._idsizes._nullreftypeid = x.slice(0, len); // should be power of 2, but just in case... + } + return this._idsizes._nullreftypeid; + }, + decodeString: function(o) { var rd = o.data; var utf8len=(rd[o.idx++]<<24)+(rd[o.idx++]<<16)+(rd[o.idx++]<<8)+(rd[o.idx++]); @@ -144,7 +153,7 @@ function _JDWP() { var rd = o.data; var res1=(rd[o.idx++]<<24)+(rd[o.idx++]<<16)+(rd[o.idx++]<<8)+(rd[o.idx++]); var res2=(rd[o.idx++]<<24)+(rd[o.idx++]<<16)+(rd[o.idx++]<<8)+(rd[o.idx++]); - return intToHex(res1,8)+intToHex(res2,8); + return intToHex(res1>>>0,8)+intToHex(res2>>>0,8); // >>> 0 ensures +ve value }, decodeInt: function(o) { var rd = o.data; @@ -241,6 +250,9 @@ function _JDWP() { decodeStatus : function(o) { return this.mapflags(this.decodeInt(o), ['verified','prepared','initialized','error']); }, + decodeTaggedObjectID : function(o) { + return this.decodeValue(o); + }, decodeValue : function(o) { var rd = o.data; return this.tagtodecoder(rd[o.idx++]).call(this, o); @@ -346,6 +358,13 @@ function _JDWP() { event.threadid = this.decodeORef(o); event.location = this.decodeLocation(o); break; + case 4: // exception + event.reqid = this.decodeInt(o); + event.threadid = this.decodeORef(o); + event.throwlocation = this.decodeLocation(o); + event.exception = this.decodeTaggedObjectID(o); + event.catchlocation = this.decodeLocation(o); // 0 = uncaught + break; case 8: // classprepare event.reqid = this.decodeInt(o); event.threadid = this.decodeORef(o); @@ -804,6 +823,19 @@ function _JDWP() { } ); }, + GetObjectType:function(objectid) { + return new Command('GetObjectType:'+objectid, 9, 1, + function() { + var res=[]; + DataCoder.encodeRef(res, objectid); + return res; + }, + function(o) { + DataCoder.decodeRefType(o); + return DataCoder.decodeTRef(o); + } + ); + }, GetFieldValues:function(objectid, fields) { return new Command('GetFieldValues:'+objectid, 9, 2, function() { @@ -842,6 +874,27 @@ function _JDWP() { } ); }, + InvokeMethod:function(objectid, threadid, classid, methodid, args) { + return new Command('InvokeMethod:'+[objectid, threadid, classid, methodid, args].join(','), 9, 6, + function() { + var res=[]; + DataCoder.encodeRef(res, objectid); + DataCoder.encodeRef(res, threadid); + DataCoder.encodeRef(res, classid); + DataCoder.encodeRef(res, methodid); + DataCoder.encodeInt(res, args.length); + args.forEach(arg => DataCoder.encodeValue(res, arg.type, arg.value)); + DataCoder.encodeInt(res, 1); // INVOKE_SINGLE_THREADED + return res; + }, + function(o) { + return { + return_value: DataCoder.decodeValue(o), + exception: DataCoder.decodeTaggedObjectID(o), + } + } + ); + }, GetArrayLength:function(arrobjid) { return new Command('GetArrayLength:'+arrobjid, 13, 1, function() { @@ -1015,6 +1068,39 @@ function _JDWP() { onevent ); }, + ClearExceptionBreak:function(requestid) { + // kind(4=exception) + return this.ClearEvent("exception",4,requestid); + }, + SetExceptionBreak:function(pattern, caught, uncaught, onevent) { + // a wrapper around SetEventRequest + var mods = [{ + modkind:8, // exceptiononly + reftypeid: DataCoder.nullRefValue(), // exception class + caught: caught, + uncaught: uncaught, + }]; + pattern && mods.unshift({ + modkind:5, // classmatch + pattern: pattern, + }); + // kind(4=exception) + // suspendpolicy(0=none,1=event-thread,2=all) + return this.SetEventRequest("exception",4,2,mods, + function(m, i, res) { + res.push(m.modkind); + switch(m.modkind) { + case 5: DataCoder.encodeString(res, m.pattern); break; + case 8: + DataCoder.encodeRef(res, m.reftypeid); + DataCoder.encodeBoolean(res, m.caught); + DataCoder.encodeBoolean(res, m.uncaught); + break; + } + }, + onevent + ); + }, allclasses:function() { // not supported by android },