/* Debugger: thin wrapper around other classes to manage debug connections */ const { EventEmitter }= require('events'); const { JDWP } = require('./jdwp'); const { ADBClient } = require('./adbclient'); const { D } = require('./utils/print'); const { sleep } = require('./utils/thread'); const { decodeJavaStringLiteral } = require('./utils/char-decode'); const { BreakpointLocation, DebuggerBreakpoint, DebuggerFrameInfo, DebuggerMethodInfo, DebuggerTypeInfo, DebuggerValue, DebugSession, JavaArrayType, JavaBreakpointEvent, JavaClassType, JavaExceptionEvent, JavaTaggedValue, JavaThreadInfo, JavaType, LaunchBuildInfo, MethodInvokeArgs, SourceLocation, TypeNotAvailable, } = require('./debugger-types'); /** * @typedef {import('./debugger-types').AttachBuildInfo} AttachBuildInfo * @typedef {import('./debugger-types').BreakpointOptions} BreakpointOptions */ class Debugger extends EventEmitter { constructor () { super(); this.connection = null; this.breakpoints = { /** @type {DebuggerBreakpoint[]} */ all: [], /** @type {Map} */ byID: new Map(), }; /** @type {JDWPRequestID[]} */ this.exception_ids = []; /** @type {DebugSession} */ this.session = null; } static portManager = { portrange: { lowest: 31000, highest: 31099 }, fixedport: 0, inuseports: new Set(), debuggers: {}, reserveport: function () { if (this.fixedport > 0 && this.fixedport < 65536) { this.inuseports.add(this.fixedport); return this.fixedport; } // choose a random port to use each time for (let i = 0; i < 10000; i++) { const portidx = this.portrange.lowest + ((Math.random() * 100) | 0); if (this.inuseports.has(portidx)) { continue; // try again } this.inuseports.add(portidx); return portidx; } throw new Error('Failed to reserve debugger port'); }, freeport: function (port) { this.inuseports.delete(port); } }; /** * @param {LaunchBuildInfo} build * @param {string} deviceid */ async startDebugSession(build, deviceid) { if (this.status() !== 'disconnected') { throw new Error('startDebugSession: session already active'); } this.session = new DebugSession(build, deviceid); const stdout = await Debugger.runApp(deviceid, build.startCommandArgs, build.postLaunchPause); // retrieve the list of debuggable processes const named_pids = await Debugger.getDebuggableProcesses(deviceid, 10e3); if (named_pids.length === 0) { throw new Error(`startDebugSession: No debuggable processes after app launch.`); } // we assume the newly launched app is the last pid in the list, but try and // validate using the process names const matched_named_pids = build.pkgname ? named_pids.filter(np => np.name === build.pkgname) : []; let pid; switch (matched_named_pids.length) { case 0: // no name match - warn, but choose the last entry anyway D('No process name match - choosing last jdwp pid'); pid = named_pids[named_pids.length - 1].pid; break; case 1: pid = matched_named_pids[0].pid; break; default: // more than one choice - warn, but choose we'll use the last one anyway D('Multiple process names match - choosing last matching entry'); pid = matched_named_pids[matched_named_pids.length - 1].pid; break; } // after connect(), the caller must call resume() to begin await this.connect(pid); return stdout; } /** * @param {AttachBuildInfo} build * @param {number} pid process ID to connect to * @param {string} deviceid device ID to connect to */ async attachToProcess(build, pid, deviceid) { if (this.status() !== 'disconnected') { throw new Error('attachToProcess: session already active') } this.session = new DebugSession(build, deviceid); // after connect(), the caller must call resume() to begin await this.connect(pid); } /** * @param {string} deviceid Device ID to connect to * @param {string[]} launch_cmd_args Array of arguments to pass to 'am start' * @param {number} post_launch_pause amount of time (in ms) to wait after each launch attempt */ static async runApp(deviceid, launch_cmd_args, post_launch_pause) { // older (<3) versions of Android only allow target components to be specified with -n const shell_cmd = { command: `am start ${launch_cmd_args.join(' ')}`, }; let retries = 10 for (;;) { D(shell_cmd.command); const stdout = await new ADBClient(deviceid).shell_cmd(shell_cmd); // running the JDWP command so soon after launching hangs, so give it a breather before continuing await sleep(post_launch_pause); // failures: // Error: Activity not started... // /system/bin/sh: syntax error: unexpected EOF - this happens with invalid am command arguments const m = stdout.match(/Error:.*|syntax error:/gi); if (!m) { // return the stdout from am (it shows the fully qualified component name) return stdout.toString().trim(); } else if (retries <= 0){ throw new Error(stdout.toString().trim()); } retries -= 1; } } /** * return a list of deviceids available for debugging */ listConnectedDevices() { return new ADBClient().list_devices(); } /** * Retrieve a list of debuggable process IDs from a device * @param {string} deviceid * @param {number} timeout_ms */ static getDebuggablePIDs(deviceid, timeout_ms) { return new ADBClient(deviceid).jdwp_list(timeout_ms); } /** * Retrieve a list of debuggable process IDs with process names from a device. * For Android, the process name is usually the package name. * @param {string} deviceid * @param {number} timeout_ms */ static getDebuggableProcesses(deviceid, timeout_ms) { return new ADBClient(deviceid).named_jdwp_list(timeout_ms); } /** * Attach to the debuggable pid * Quite a lot happens in this - we setup port forwarding, complete the JDWP handshake, * setup class loader notifications and call anyone waiting for us. * If anything fails, we call disconnect() to return to a sense of normality. * @param {number|null} jdwpid */ async connect(jdwpid) { switch (this.status()) { case 'connected': // already connected return; case 'connecting': // wait for the connection to complete (or fail) return this.connection.connectingpromise; default: if (!jdwpid) throw new Error('Debugger not connected'); break; } // from this point on, we are in the "connecting" state until the JDWP handshake is complete // (and we mark as connected) or we fail and return to the disconnected state this.connection = { /** pid of the debuggable process to connect to (on the device) */ jdwp: jdwpid, /** the local port number to use for ADB port-forwarding */ localport: Debugger.portManager.reserveport(), /** set to true once ADB port-forwarding is completed */ portforwarding: false, /** set to true after the JDWP handshake has completed */ connected: false, /** @type {Promise} fulfilled once the connection tasks have completed */ connectingpromise: null, }; try { await (this.connection.connectingpromise = this.performConnectionTasks()); // at this point, we are ready to go - all the caller needs to do is call resume(). this.emit('connected'); } catch(err) { this.connection.err = err; // force a return to the disconnected state this.disconnect(); throw err; } } async performConnectionTasks() { // setup port forwarding // note that this call generally succeeds - even if the JDWP pid is invalid await new ADBClient(this.session.deviceid).jdwp_forward({ localport: this.connection.localport, jdwp: this.connection.jdwp, }); this.connection.portforwarding = true; // after this, the client keeps an open connection until // jdwp_disconnect() is called this.session.adbclient = new ADBClient(this.session.deviceid); try { // if the JDWP pid is invalid (doesn't exist, not debuggable, etc) ,this // is where it will fail... await this.session.adbclient.jdwp_connect({ localport: this.connection.localport, onreply: data => this._onJDWPMessage(data), ondisconnect: () => this._onJDWPDisconnect(), }); } catch (e) { // provide a slightly more meaningful message than a socket error throw new Error(`A debugger connection to pid ${this.connection.jdwp} could not be established. ${e.message}`) } // handshake has completed this.connection.connected = true; // call suspend first - we shouldn't really need to do this (as the debugger // is already suspended and will not resume until we tell it), but if we // don't do this, it logs a complaint... await this.suspend(); // retrieve the JRE reference ID sizes, so we can decode JDWP messages const idsizes = await this.session.adbclient .jdwp_command({ cmd: JDWP.Commands.idsizes(), }); JDWP.initDataCoder(idsizes); // set the class loader event notifier so we can enable breakpoints when the // runtime loads the classes await this.initClassPrepareForBreakpoints(); // some types have already been loaded (so we won't receive class-prepare notifications). // we can't map breakpoint source locations to already-loaded anonymous types, so we just retrieve // a list of all classes for now. const all_classes = await this.getAllClasses(); this.session.loadedClasses = new Set(all_classes.map(x => x.signature)); } /** * @param {Buffer} data */ _onJDWPMessage(data) { // decodeReply will resolve the promise associated with // any command this reply is in response to. return JDWP.decodeReply(data); } _onJDWPDisconnect() { // the JDWP socket has disconnected - terminate the debugger this.disconnect(); } /** * Returns a resolved Promise if (and when) a debugger connection is established. * The promise is rejected if the device has disconnected. */ ensureConnected() { // passing null as the jdwpid will cause a fail if the client is not connected (or connecting) return this.connect(null); } /** * @returns {'connected'|'connecting'|'disconnected'} */ status() { if (!this.connection) return "disconnected"; if (this.connection.connected) return "connected"; return "connecting"; } /** * Force stop the app running in the current session */ async forceStop() { if (!this.session) { return; } if (this.session.build instanceof LaunchBuildInfo) { return Debugger.forceStopApp(this.session.deviceid, this.session.build.pkgname); } } /** * Sends a 'am force-stop' command to the given device * @param {string} deviceid * @param {string} pkgname * @param {boolean} [throw_on_error] */ static async forceStopApp(deviceid, pkgname, throw_on_error = false) { try { await new ADBClient(deviceid).shell_cmd({ command: 'am force-stop ' + pkgname, }); } catch(e) { if (throw_on_error) { throw e; } } } /** * Perform disconnect tasks and cleanup * @return previous state */ async disconnect() { // disconnect is called from a variety of failure scenarios // so it must be fairly robust in how it undoes stuff const previous_state = this.status(); const connection = this.connection; if (!connection) return previous_state; // from here on in, this instance is in the disconnected state this.connection = null; // reset the breakpoint states this.resetBreakpoints(); // clear the session const adbclient = this.session.adbclient; this.session = null; // perform the JDWP disconnect if (connection.connected) { await adbclient.jdwp_disconnect(); } // undo the portforwarding // todo: replace remove_all with remove_port if (connection.portforwarding) { await adbclient.forward_remove_all(); } // mark the port as freed if (connection.portforwarding) { Debugger.portManager.freeport(connection.localport); } this.emit('disconnect'); return previous_state; } /** * Retrieve all the thread IDs from the running app. */ async getJavaThreadIDs() { await this.ensureConnected(); /** @type {JavaThreadID[]} */ const threads = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.allthreads(), }); return threads; } /** * * @param {JavaThreadID[]} thread_ids */ async getJavaThreadInfos(thread_ids) { const threadinfos = []; for (let i=0; i < thread_ids.length; i++) { const threadid = thread_ids[i]; try { const name = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.threadname(threadid) }); const status = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.threadstatus(threadid) }) threadinfos.push(new JavaThreadInfo(threadid, name, status)); } catch(e) {} } return threadinfos; } /** * Increments or decrements the suspend count for a given thread * @param {JavaThreadID} threadid * @param {number} inc */ updateThreadSuspendCount(threadid, inc) { const count = this.session.threadSuspends.get(threadid); this.session.threadSuspends.set(threadid, (count | 0) + inc); } /** * Sends a JDWP command to suspend execution */ async suspend() { await this.ensureConnected() this.emit('suspending'); await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.suspend(), }); this.emit('suspended'); } /** * Sends a JDWP command to suspend execution of a single thread * @param {JavaThreadID} threadid */ async suspendThread(threadid) { await this.ensureConnected(); try { this.updateThreadSuspendCount(threadid, +1); await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.suspendthread(threadid), }); } catch(e) { this.updateThreadSuspendCount(threadid, -1); throw e; } } /** * Sends a JDWP command to resume execution * @param {boolean} triggers true if 'resuming' and 'resumed' events should be invoked, false if this is a silent resume */ async _resume(triggers) { await this.ensureConnected(); if (triggers) { this.emit('resuming'); } this.session.stoppedLocation = null; await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.resume(), }); if (triggers) { this.emit('resumed'); } } /** * Resume execution of a suspended app */ resume() { return this._resume(true); } /** * Resume execution of a suspended app without triggering resume events */ _resumesilent() { return this._resume(false); } /** * Sends a JDWP command to resume execution of a single thread * @param {JavaThreadID} thread_id */ async resumeThread(thread_id) { await this.ensureConnected(); this.updateThreadSuspendCount(thread_id, -1); await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.resumethread(thread_id), }); } /** * Performs a single step of the given type * @param {DebuggerStepType} step_type * @param {JavaThreadID} thread_id */ async step(step_type, thread_id) { await this.ensureConnected(); this.emit('stepping'); await this._setupStepEvent(step_type, thread_id); await this.resumeThread(thread_id); } /** * Returns the DebuggerBreakpoint at the given location, or null if none exists * @param {string} srcfpn * @param {number} line */ getBreakpointAt(srcfpn, line) { const bp_id = DebuggerBreakpoint.makeBreakpointID(srcfpn, line); return this.breakpoints.byID.get(bp_id); } /** * Returns the breakpoints that meet the condition specified in a callback function. * @param {(value:DebuggerBreakpoint,idx:number,array:DebuggerBreakpoint[]) => boolean} filter_fn */ findBreakpoints(filter_fn) { return this.breakpoints.all.filter(filter_fn); } /** * Sets a breakpoint at the given location * @param {string} srcfpn * @param {number} line * @param {BreakpointOptions} options */ async setBreakpoint(srcfpn, line, options) { const existing_bp = this.getBreakpointAt(srcfpn, line); if (existing_bp) { return existing_bp; } const newbp = new DebuggerBreakpoint(srcfpn, line, options, 'set'); this.breakpoints.all.push(newbp); this.breakpoints.byID.set(newbp.id, newbp); // what happens next depends upon what state we are in switch (this.status()) { case 'connected': newbp.state = 'notloaded'; await this.initialiseBreakpoint(newbp); break; case 'connecting': case 'disconnected': default: newbp.state = 'set'; break; } return newbp; } /** * * @param {DebuggerBreakpoint} bp */ async initialiseBreakpoint(bp) { // try and load the class - if the runtime hasn't loaded it yet, this will just return a TypeNotAvailable instance let classes = await Promise.all( [...this.session.loadedClasses] .filter(signature => bp.sigpattern.test(signature)) .map(signature => this.loadClassInfo(signature)) ); let bploc = Debugger.findBreakpointLocation(classes, bp); if (!bploc) { // we couldn't identify a matching location - either the class is not yet loaded or the // location doesn't correspond to any code. In case it's the former, make sure we are notified // when classes in this package are loaded await this._ensureClassPrepareForPackage(bp.pkg); return; } // we found a matching location - set the breakpoint event await this._setupBreakpointsEvent([bploc]); } /** * Deletes a set of breakpoints. * @param {DebuggerBreakpoint[]} breakpoints */ removeBreakpoints(breakpoints) { // sanitise first to remove duplicates, non-existants, nulls, etc const bps_to_clear = [...new Set(breakpoints)].filter(bp => bp && this.breakpoints.all.includes(bp)); bps_to_clear.forEach(bp => { this.breakpoints.byID.delete(bp.id); this.breakpoints.all.splice(this.breakpoints.all.indexOf(bp), 1); }); switch (this.status()) { case 'connected': this.disableBreakpoints(bps_to_clear, 'removed'); break; case 'connecting': case 'disconnected': default: this._changeBPState(bps_to_clear, 'removed'); break; } return bps_to_clear; } /** * Retrieve call-stack frames for a thread * @param {JavaThreadID} threadid */ async getFrames(threadid) { /** @type {JavaFrame[]} */ const frames = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.Frames(threadid), }) const methods = await Promise.all( frames.map(frame => this._findMethodAsync(this.session.classList, frame.location)) ); return frames.map((frame,i) => new DebuggerFrameInfo(frame, methods[i], threadid)); } /** * Retrieve the list of local variables for a given fram * @param {DebuggerFrameInfo} frame */ async getLocals(frame) { const method = this.findMethod(this.session.classList, frame.location.cid, frame.location.mid); if (!method) { D(`getLocals: No method in frame location: ${JSON.stringify(frame.location)}`) return []; } await this._ensureMethodVars(method); const location_idx = parseInt(frame.location.idx, 16); const tags = { '[': 76, B: 66, C: 67, L: 76, F: 70, D: 68, I: 73, J: 74, S: 83, V: 86, Z: 90 }; const slots = method.vartable.vars.map(v => { const tag = tags[v.type.signature[0]]; if (!tag) { return null; } const code_idx = parseInt(v.codeidx, 16); const withincodebounds = (location_idx >= code_idx) && (location_idx < (code_idx + v.length)); return { v, slot: v.slot, tag, valid: withincodebounds, }; }); const validslots = slots.filter(s => s && s.valid); if (!validslots.length) { return []; } return this._getStackValues(frame, validslots); } /** * @param {DebuggerFrameInfo} frame * @param {*} slotinfo * @param {JavaTaggedValue} data */ async setLocalVariableValue(frame, slotinfo, data) { await this.ensureConnected(); await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.SetStackValue(frame.threadid, frame.frameid, slotinfo.slot, data), }); const res = await this._getStackValues(frame, [slotinfo]); return res[0]; } /** * * @param {DebuggerFrameInfo} frame * @param {*[]} validslots */ async _getStackValues(frame, validslots) { try { const values = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.GetStackValues(frame.threadid, frame.frameid, validslots), }); const res = await this._makeValues( 'local', validslots.map(x => x.v), values, { frame, slotinfo: null } ); for (let i = 0; i < res.length; i++) res[i].data.slotinfo = validslots[i];// slots[slots.indexOf(validslots[i])]; return res; } catch (e) { D(`_getStackValues: failed to retrieve stack values: ${e.message}`); return []; } } /** * @param {string} signature */ async getSuperType(signature) { if (signature === JavaType.Object.signature) throw new Error('java.lang.Object has no super type'); const typeinfo = await this.getTypeInfo(signature); await this._ensureSuperType(typeinfo); return typeinfo.super; } /** * @param {DebuggerValue} value */ async getSuperInstance(value) { const supertype = await this.getSuperType(value.type.signature); if (value.vtype === 'class') { return this.getTypeValue(supertype.signature); } return new DebuggerValue(value.vtype, supertype, value.value, value.valid, value.hasnullvalue, value.name, value.data); } async getTypeValue(signature) { const typeinfo = await this.getTypeInfo(signature); const valid = !(typeinfo instanceof TypeNotAvailable); return new DebuggerValue('class', typeinfo.type, typeinfo.info.typeid, valid, false, typeinfo.type.typename, null); } /** * * @param {string} s Java quoted or literal (raw) string * @param {{israw:boolean}} [opts] */ async createJavaStringLiteral(s, opts) { const string = (opts && opts.israw) ? s : decodeJavaStringLiteral(s); await this.ensureConnected(); const string_ref = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.CreateStringObject(string), }); const keys = [{ name: '', type: JavaType.String, }]; const vars = await this._makeValues('literal', keys, [string_ref], null); return vars[0]; } /** * @param {DebuggerValue} instance * @param {JavaField} field * @param {JavaTaggedValue} new_value */ async setFieldValue(instance, field, new_value) { await this.ensureConnected(); await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.SetFieldValue(instance.value, field, new_value), }); return this.getFieldValue(instance, field.name, true); } /** * @param {DebuggerValue} object_value */ async getFieldValues(object_value) { const type = await this.getTypeInfo(object_value.type.signature); await this._ensureFields(type); return this.fetchFieldValues(object_value, type.info.typeid, type.fields); } /** * @param {DebuggerValue} object_value * @param {JavaTypeID} typeid * @param {JavaField[]} field_list */ async fetchFieldValues(object_value, typeid, field_list) { // the Android runtime now pointlessly barfs into logcat if an instance value is used // to retrieve a static field. So, we now split into two calls... const splitfields = field_list.reduce((z, f) => { if (f.modbits & 8) { z.static.push(f); } else { z.instance.push(f); } return z; }, { instance: [], static: [] }); // we cannot retrieve instance fields with a class type if (object_value.vtype === 'class') { splitfields.instance = []; } // first, the instance values... let instance_fieldvalues = []; if (splitfields.instance.length) { instance_fieldvalues = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.GetFieldValues(object_value.value, splitfields.instance), }); } // and now the statics (with a type reference) let static_fieldvalues = []; if (splitfields.static.length) { static_fieldvalues = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.GetStaticFieldValues(typeid, splitfields.static), }); } // make sure the fields and values match up... const fields = [...splitfields.instance, ...splitfields.static]; const values = [...instance_fieldvalues, ...static_fieldvalues]; const res = await this._makeValues('field', fields, values, { objvar: object_value }); res.forEach((value,i) => { value.data.field = fields[i]; value.fqname = `${object_value.fqname || object_value.name}.${value.name}`; }); return res; } /** * @param {DebuggerValue} object_value * @param {string} fieldname * @param {boolean} includeInherited true if we should search up the super instances, false to only search the current instance */ async getFieldValue(object_value, fieldname, includeInherited) { if (!(object_value.type instanceof JavaClassType)) { return null; } // retrieving field values is expensive, so we search through the class // fields (which will be cached) until we find a match let field, object_type = object_value.type, typeinfo; for (;;) { typeinfo = await this.getTypeInfo(object_type.signature); const fields = await this._ensureFields(typeinfo); field = fields.find(f => f.name === fieldname); if (field) { break; } if (!includeInherited || object_type.signature === JavaType.Object.signature) { const fully_qualified_typename = `${object_value.type.package}.${object_value.type.typename}`; throw new Error(`No such field '${fieldname}' in type ${fully_qualified_typename}`); } object_type = await this.getSuperType(object_type.signature); } const values = await this.fetchFieldValues(object_value, typeinfo.info.typeid, [field]); return values[0]; } /** * Retrieve a list of signatures for all classes making up the inheritence tree for the given type. * The last entry is always `"Ljava/lang/Object;"` * @param {string} signature */ async getClassInheritanceList(signature) { const signatures = []; for (;;) { const typeinfo = await this.getTypeInfo(signature); signatures.push(typeinfo.type.signature); await this._ensureSuperType(typeinfo); if (typeinfo.super === null) { return signatures; } signature = typeinfo.super.signature; } } /** * @param {JavaThreadID} thread_id * @param {JavaObjectID} exception_object_id */ async getExceptionValue(thread_id, exception_object_id) { const typeref = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.GetObjectType(exception_object_id), }); /** @type {JavaType} */ const type = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.signature(typeref) }); const typeinfo = await this.getTypeInfo(type.signature); await this._ensureFields(typeinfo); const msg = await this.invokeToString(exception_object_id, thread_id, type.signature); const res = await this._makeValues('exception', [{ name: '{ex}', type }], [exception_object_id], { msg }); return res[0]; } /** * @param {JavaObjectID} objectid * @param {JavaThreadID} threadid * @param {DebuggerMethodInfo} method * @param {DebuggerValue[]} args * @returns {Promise} */ async invokeMethod(objectid, threadid, method, args) { const x = new MethodInvokeArgs(objectid, threadid, method, args); // method invokes must be handled sequentially on a per-thread basis, so we add the info // to a list and execute them one at a time let list = this.session.methodInvokeQueues.get(threadid); if (!list) { this.session.methodInvokeQueues.set(threadid, list = []); } // create a new promise to be fulfilled with the result of the invoke const result_promise = new Promise( (resolve, reject) => x.promise = {resolve, reject} ); // if this is the only item, start the loop to perform the invokes if (list.push(x) === 1) { while (list.length) { try { const result = await this.performMethodInvoke(list[0]); list[0].promise.resolve(result); } catch (e) { list[0].promise.reject(e); } list.shift(); } } return result_promise; } /** * @param {MethodInvokeArgs} x */ async performMethodInvoke({ objectid, threadid, method, args }) { // convert the arguments to JDWP-compatible values const jdwp_args = args.map(arg => JavaTaggedValue.from(arg)); // invoke the method const res = await this.session.adbclient.jdwp_command({ cmd: method.isStatic ? JDWP.Commands.InvokeStaticMethod(threadid, method.owningclass.info.typeid, method.methodid, jdwp_args) : JDWP.Commands.InvokeMethod(objectid, threadid, method.owningclass.info.typeid, method.methodid, jdwp_args) }) // res = {return_value, exception} if (!/^0+$/.test(res.exception)) { // todo - handle reutrn exceptions throw new Error('Exception thrown from method invoke'); } const return_typeinfo = await this.getTypeInfo(method.returnTypeSignature); const values = await this._makeValues('return', [{ name: '{return}', type: return_typeinfo.type }], [res.return_value], {}); return values[0]; } /** * @param {JavaObjectID} objectid * @param {JavaThreadID} threadid * @param {string} type_signature */ async invokeToString(objectid, threadid, type_signature) { const methods = await this.findNamedMethods(type_signature, 'toString', '()Ljava/lang/String;', true); return this.invokeMethod(objectid, threadid, methods[0], []); } /** * @param {string} type_signature * @param {string|RegExp} method_name * @param {string|RegExp} method_signature * @param {boolean} first */ async findNamedMethods(type_signature, method_name, method_signature, first) { function ismatch (x, y) { if (!x || (x === y)) return true; return (x instanceof RegExp) && x.test(y); } let typeinfo = await this.getTypeInfo(type_signature); // resolving the methods only resolves the non-inherited methods // if we can't find a matching method, we need to search the super types /** @type {DebuggerMethodInfo[]} */ let matches = []; for (;;) { await this._ensureMethods(typeinfo); matches = [ ...matches, ...typeinfo.methods.filter( m => ismatch(method_name, m.name) && ismatch(method_signature, m.genericsig || m.sig) ) ] if (first && matches.length) { return [matches[0]]; } if (typeinfo.super === null) { return matches; } // search the supertype await this._ensureSuperType(typeinfo); typeinfo = await this.getTypeInfo(typeinfo.super.signature); } } /** * @param {string} type_signature * @param {string|RegExp} field_name * @param {boolean} first */ async findNamedFields(type_signature, field_name, first) { function ismatch (x, y) { if (!x || (x === y)) return true; return (x instanceof RegExp) && x.test(y); } let typeinfo = await this.getTypeInfo(type_signature); // resolving the methods only resolves the non-inherited methods // if we can't find a matching method, we need to search the super types /** @type {JavaField[]} */ let matches = []; for (;;) { await this._ensureFields(typeinfo); matches = [ ...matches, ...typeinfo.fields.filter(f => ismatch(field_name, f.name)) ] if (first && matches.length) { return [matches[0]]; } if (typeinfo.super === null) { return matches; } // search the supertype await this._ensureSuperType(typeinfo); typeinfo = await this.getTypeInfo(typeinfo.super.signature); } } /** * Retrieve the UTF8 text of a String object * @param {JavaObjectID} string_ref * @returns {Promise} */ getStringText(string_ref) { return this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.GetStringValue(string_ref), }); } /** * Retrieve the text length of a String object * @param {JavaObjectID} stringref */ async getStringLength(stringref) { const typeinfo = await this.getTypeInfo(JavaType.String.signature); await this._ensureFields(typeinfo); const countfield = typeinfo.fields.find(f => f.name === 'count'); const count_values = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.GetFieldValues(stringref, [countfield]), }); return count_values[0]; } /** * Retrieve a range of array element values * @param {DebuggerValue} array * @param {number} start first element index * @param {number} count number of elements to retrieve */ async getArrayElementValues(array, start, count) { if (!(array.type instanceof JavaArrayType)) { throw new Error(`getArrayElementValues: object is not an array type`); } const values = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.GetArrayValues(array.value, start, count), }); const typeinfo = await this.getTypeInfo(array.type.elementType.signature); // generate some dummy keys to map against const keys = values.map((_,i) => ({ name: `${start + i}`, type: typeinfo.type, }) ); const elements = await this._makeValues('arrelem', keys, values, { array }); // assign fully qualified names for the elements elements.forEach(element => element.fqname = `${array.fqname||array.name}[${element.name}]`); return elements; } /** * Set (fill) an array range with the specified value * @param {DebuggerValue} array * @param {number} start * @param {number} count * @param {JavaTaggedValue} value */ async setArrayElements(array, start, count, value) { if (!Number.isInteger(start)) { throw new Error('setArrayElementValues: Array start index is not an integer'); } if (!Number.isInteger(count)) { throw new Error('setArrayElementValues: Array element count is not an integer'); } await this.ensureConnected(); await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.SetArrayElements(array.value, start, count, value), }) return this.getArrayElementValues(array, start, count); } /** * Create a new array of DebuggerValues from a set of keys and values * @param {DebuggerValueType} vtype * @param {{name:string,type:JavaType}[]} keys * @param {*[]} values * @param {*} data */ async _makeValues(vtype, keys, values, data) { if (!values || !Array.isArray(values)) { return []; } let res = values.map((v,i) => new DebuggerValue( vtype, keys[i].type, v, v !== null, /^0+$/.test(v), keys[i].name, {...data} )); const fetch_values = []; // for those fields that are (non-null) arrays, retrieve the length res.filter(v => JavaType.isArray(v.type)) .forEach(f => { if (f.hasnullvalue || !f.valid) { return; } const promise = this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.GetArrayLength(f.value), }) .then(arrlen => f.arraylen = arrlen); fetch_values.push(promise); }); // for those fields that are strings, retrieve the string text res.filter(v => JavaType.isString(v.type)) .forEach(f => { if (f.hasnullvalue || !f.valid) { return; } const promise = this.getStringLength(f.value) .then(async len => { if (len > 10000) { f.string = '{string exceeds maximum display length}'; f.biglen = len; } else { f.string = await this.getStringText(f.value); } }); fetch_values.push(promise); }); await Promise.all(fetch_values); return res; } /** * Convert a JRE signature to a DebuggerTypeInfo instance * @param {string} signature */ getTypeInfo(signature) { // see if we've already retrieved the type for this session const cached = this.session.classCache.get(signature); if (cached) { // return the cached entry // - this will either be the DebuggerTypeInfo instance or a promise resolving with the DebuggerTypeInfo instance return cached; } // while we're retrieving it, set a promise in it's place // - this prevents multiple requests from being forwarded over JDWP const promise = this.fetchTypeInfo(signature); this.session.classCache.set(signature, promise); return promise; } /** * @param {string} signature */ async fetchTypeInfo(signature) { await this.ensureConnected(); /** @type {JavaClassInfo[]} */ const class_infos = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.classinfo(signature), }); // if the runtime has not loaded the type yet, return a dummy class if (!class_infos || !class_infos.length) { if (this.session) { // delete the entry in the cache so that any future requests will // perform a new fetch. this.session.classCache.delete(signature); } return new TypeNotAvailable(JavaType.from(signature)); } const typeinfo = new DebuggerTypeInfo(class_infos[0], JavaType.from(signature)); /** @type {JavaSource[]} */ let srcinfoarr = [null]; // querying the source file for array or primitive types causes the app to crash if (/^L/.test(signature)) { srcinfoarr = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.sourcefile(typeinfo), }); } typeinfo.src = srcinfoarr[0]; if (this.session) { this.session.classList.push(typeinfo); this.session.classCache.set(signature, typeinfo); } return typeinfo; } /** * Ensure any 'super' type information is retrieved * @param {DebuggerTypeInfo} typeinfo */ async _ensureSuperType(typeinfo) { // a null value implies no super type is valid (eg. Object) if (typeinfo.super === null) { return null; } if (typeinfo.super) { return typeinfo.super; } const fetchSuperType = async (typeinfo) => { const supertyperef = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.superclass(typeinfo), }); typeinfo.super = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.signature(supertyperef), }); return typeinfo.super; } // to ensure we don't perform multiple redundant JDWP requests, set the field to the Promise // @ts-ignore return typeinfo.super = fetchSuperType(typeinfo); } /** * Ensure any type fields information is retrieved * @param {DebuggerTypeInfo} typeinfo */ _ensureFields(typeinfo) { if (typeinfo.fields) { return typeinfo.fields; } const fetchFields = async (typeinfo) => { /** @type {JavaField[]} */ const fields = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.fieldsWithGeneric(typeinfo), }) return typeinfo.fields = fields; } // to ensure we don't perform multiple redundant JDWP requests, set the field to the Promise // @ts-ignore return typeinfo.fields = fetchFields(typeinfo); } /** * Ensure any type methods information is retrieved * @param {DebuggerTypeInfo} typeinfo */ async _ensureMethods(typeinfo) { if (typeinfo.methods) { return typeinfo.methods; } const fetchMethods = async (typeinfo) => { const methods = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.methodsWithGeneric(typeinfo), }) return typeinfo.methods = methods.map(m => new DebuggerMethodInfo(m, typeinfo)); } // to ensure we don't perform multiple redundant JDWP requests, set the field to the Promise // @ts-ignore return typeinfo.methods = fetchMethods(typeinfo); } /** * Ensure any method variables information is retrieved * @param {DebuggerMethodInfo} methodinfo */ _ensureMethodVars(methodinfo) { if (methodinfo.vartable) { return methodinfo.vartable; } const fetchMethodVarTable = async (methodinfo) => { /** @type {JavaVarTable} */ const vartable = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.VariableTableWithGeneric(methodinfo.owningclass, methodinfo), }) return methodinfo.setVarTable(vartable); } // to ensure we don't perform multiple redundant JDWP requests, set the field to the Promise // @ts-ignore return methodinfo.vartable = fetchMethodVarTable(methodinfo); } /** * Ensure any method code lines information is retrieved * @param {DebuggerMethodInfo} methodinfo */ async _ensureMethodLines(methodinfo) { if (methodinfo.linetable) { return methodinfo.linetable; } const fetchMethodLines = async (methodinfo) => { /** @type {JavaLineTable} */ const linetable = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.lineTable(methodinfo.owningclass, methodinfo), }) // if the request failed, just return a blank table .catch(() => DebuggerMethodInfo.NullLineTable); // the linetable does not correlate code indexes with line numbers // - location searching relies on the table being ordered by code indexes linetable.lines.sort(function (a, b) { return (a.linecodeidx === b.linecodeidx) ? 0 : ((a.linecodeidx < b.linecodeidx) ? -1 : +1); }); return methodinfo.setLineTable(linetable); } // to ensure we don't perform multiple redundant JDWP requests, set the field to the Promise // @ts-ignore return methodinfo.linetable = fetchMethodLines(methodinfo); } /** * Sends a JDWP command to register for class-prepare events * @param {string} pattern signature pattern to match against prepared classes. Only those matching the pattern will cause an event trigger. * @param {(event) => void} onprepare */ _setupClassPrepareEvent(pattern, onprepare) { const onevent = { fn: (e) => { onprepare(e.event); } }; return this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.OnClassPrepare(pattern, onevent), }); } /** * Sends a JDWP command to clear any outstanding step requests for the given thread * @param {JavaThreadID} threadid */ async clearLastStepRequest(threadid) { if (!this.session || !this.session.stepIDs.has(threadid)) return; const stepid = this.session.stepIDs.get(threadid); this.session.stepIDs.set(threadid, 0); return this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.ClearStep(stepid), }); } /** * * @param {DebuggerStepType} steptype * @param {JavaThreadID} threadid */ async _setupStepEvent(steptype, threadid) { const onevent = { fn: async (e) => { await this.clearLastStepRequest(e.event.threadid); // search the cached classes for a matching source location const sloc = await this.javaLocationToSourceLocation(e.event.location, e.event.threadid); const stoppedLocation = sloc || new SourceLocation(null, null, false, e.event.threadid); const eventdata = { event: e.event, stoppedLocation, }; this.session.stoppedLocation = stoppedLocation; this.emit('step', eventdata); } }; const res = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.SetSingleStep(steptype, threadid, onevent), }); // save the step id so we can manually clear it if an exception break occurs if (this.session && res && res.id) { this.session.stepIDs.set(threadid, res.id); } } /** * Send SetBreakpoint command to the connected device and register a handler for the event * @param {BreakpointLocation[]} locations */ async _setupBreakpointsEvent(locations) { const onevent = { data: { dbgr: this, }, fn: async (e) => { const loc = e.event.location; const cmlkey = `${loc.cid}:${loc.mid}:${loc.idx}`; // find the DebuggerBreakpoint matching the location const bp = this.breakpoints.all.find(bp => bp.enabled && bp.enabled.cml === cmlkey); const stoppedLocation = new SourceLocation(bp.qtype, bp.linenum, true, e.event.threadid); this.session.stoppedLocation = stoppedLocation; const eventdata = new JavaBreakpointEvent(e.event, stoppedLocation, bp); // if this was a conditional breakpoint, it will have been automatically cleared // - set a new (unconditional) breakpoint in it's place if (bp.options.hitcount) { bp.hitcount += bp.options.hitcount; bp.options.hitcount = null; const { bploc } = bp.enabled; const res = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.SetBreakpoint(bploc.c, bploc.m, bploc.l, null, onevent), }); bp.enabled.requestid = res.id; } else { bp.hitcount++; } bp.stopcount++; this.emit('bphit', eventdata); } }; const enabled_breakpoints = []; for (let bploc of locations) { const { bp } = bploc; const res = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.SetBreakpoint(bploc.c, bploc.m, bploc.l, bp.options.hitcount, onevent), }); // save the JDWP request IDs from the SetBreakpoint command so we can disable the breakpoint later bp.setEnabled(bploc, res.id); enabled_breakpoints.push(bp); } this._changeBPState(enabled_breakpoints, 'enabled'); } /** * @param {DebuggerBreakpoint[]} breakpoints * @param {BreakpointState} [new_state] */ async disableBreakpoints(breakpoints, new_state = 'notloaded') { const enabled_bps = breakpoints.filter(bp => bp.enabled); for (let bp of enabled_bps) { await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.ClearBreakpoint(bp.enabled.requestid), }); bp.setDisabled(); } this._changeBPState(enabled_bps, new_state); } /** * Set the internal state of the breakpoints and trigger the 'bpstatechange' event * @param {DebuggerBreakpoint[]} breakpoints * @param {BreakpointState} new_state */ _changeBPState(breakpoints, new_state) { if (!breakpoints || !breakpoints.length || !new_state) { return; } breakpoints.forEach(bp => bp.state = new_state); this.emit('bpstatechange', { breakpoints: breakpoints.slice(), newstate: new_state, }); } /** * Setup class-prepare events for the classes we have breakpoints set for. */ initClassPrepareForBreakpoints() { // set all the breakpoints to the notloaded state this._changeBPState(this.breakpoints.all, 'notloaded'); // setup class prepare notifications for all the packages associated with breakpoints // when each class is prepared (loaded by the runtime), we initialise any breakpoints for it const class_prepare_promises = this.breakpoints.all.map( bp => this._ensureClassPrepareForPackage(bp.pkg) ); return Promise.all(class_prepare_promises); } /** * Reset all breakpoints back to disabled set */ resetBreakpoints() { this._changeBPState(this.breakpoints.all, 'set'); this.breakpoints.all.forEach(bp => bp.setDisabled()); } /** * Setup a class-prepare event for the given package name * @param {string} package_name */ _ensureClassPrepareForPackage(package_name) { const filter = `${package_name}.*`; if (this.session.classPrepareFilters.has(filter)) { return; // already setup } this.session.classPrepareFilters.add(filter); return this._setupClassPrepareEvent(filter, async clz => { try { await this._onClassPrepared(clz); } catch (e) { D(`_onClassPrepared failed. ${e.message}`) } // when the class-prepare event triggers, JDWP automatically suspends the app // - we must always manually resume to continue... this._resumesilent(); }); } /** * Callback when the JDWP class-prepare event triggers * @param {JavaClassInfo} prepared_class */ async _onClassPrepared(prepared_class) { // if the class prepare events have overlapping packages (mypackage.*, mypackage.another.*), we will get // multiple notifications (which duplicates breakpoints, etc) const signature = prepared_class.type.signature; if (this.session.loadedClasses.has(signature)) { return; // we already know about this } this.session.loadedClasses.add(signature); D('Prepared: ' + signature); if (!/^L(.*);$/.test(signature)) { // unrecognised type signature - ignore it return; } const classes = [await this.loadClassInfo(signature)]; const bplocs = this.breakpoints.all .map(bp => Debugger.findBreakpointLocation(classes, bp)) .filter(x => x); if (bplocs.length) { // set all the breakpoints in one go... await this._setupBreakpointsEvent(bplocs); } } /** * Send JDWP commands to clear break-on-exception options */ async clearBreakOnExceptions() { while (this.exception_ids.length) { // clear next pattern await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.ClearExceptionBreak(this.exception_ids.pop()) }) } } /** * Enable break-on-exceptions. JDWP will send an event when an exception is thrown. * @param {ExceptionBreakMode} which */ async setBreakOnExceptions(which) { const onevent = { data: { }, fn: async e => { // if this exception break occurred during a step request, we must manually clear the event // or the (device-side) debugger will crash on next step await this.clearLastStepRequest(e.event.threadid); // retrieve the catch and throw locations const tloc = await this.javaLocationToSourceLocation(e.event.throwlocation, e.event.threadid); const cloc = await this.javaLocationToSourceLocation(e.event.catchlocation, e.event.threadid); const eventdata = new JavaExceptionEvent(e.event, tloc, cloc); this.session.stoppedLocation = eventdata.throwlocation; this.emit('exception', eventdata); } }; let caught = false, uncaught = false; switch (which) { case 'caught': caught = true; break; case 'uncaught': uncaught = true; break; case 'both': caught = uncaught = true; break; default: throw new Error(`Invalid exception option: ${which}`); } // 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 const pkgs = this.session.build.packages; const pkgs_to_monitor = caught ? [...pkgs.keys()].filter(name => pkgs.get(name).public_classes.length) : []; let filters = pkgs_to_monitor.map(pkg => `${pkg}.*`); if (uncaught) { // setup the uncaught exception break - with no filter filters.push(null); } for (let filter of filters) { // we only enable 'caught' with a package filter // - otherwise we end up stopping on every exception in the Android framework // (and there are a lot of exceptions thrown) const c = !!filter && caught; const u = filter !== null; const res = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.SetExceptionBreak(filter, c, u, onevent), }) this.exception_ids.push(res.id); } } /** * Setup notifiers for thread start and ends */ async setThreadNotify() { const onevent = { data: {}, fn: (e) => { // the thread notifiers don't give any location information this.emit('threadchange', { state: e.event.state, threadid: e.event.threadid, }); }, }; await this.ensureConnected(); await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.ThreadStartNotify(onevent), }); await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.ThreadEndNotify(onevent), }); } /** * Return a DebuggerTypeInfo for the given type signature with methods and code lines retrieved * @param {string} signature */ async loadClassInfo(signature) { const typeinfo = await this.getTypeInfo(signature); // load the methods await this._ensureMethods(typeinfo); // load the method lines await Promise.all(typeinfo.methods.map(m => this._ensureMethodLines(m))); return typeinfo; } /** * Search the list of classes for a location matching the breakpoint * @param {DebuggerTypeInfo[]} classes * @param {DebuggerBreakpoint} bp * @returns {BreakpointLocation} */ static findBreakpointLocation(classes, bp) { // search the classes for a method containing the line let bploc = null; classes.find(c => bp.sigpattern.test(c.type.signature) && c.methods.find(m => { const line = m.linetable.lines.find(line => line.linenum === bp.linenum); if (line) { bploc = new BreakpointLocation(bp, c, m, line.linecodeidx); return true; } }) ) return bploc; } /** * Returns a SourceLocation instance for the given frame or null if the location cannot be determined * @param {DebuggerFrameInfo} frame */ frameToSourceLocation(frame) { return this.lineIndexToSourceLocation(frame.method, frame.location.idx, frame.threadid); } /** * Converts the specified method and code index to a SourceLocation * @param {DebuggerMethodInfo} method * @param {hex64} idx * @param {JavaThreadID} threadid */ lineIndexToSourceLocation(method, idx, threadid) { if (!method || !method.linetable || !method.linetable.lines || !method.linetable.lines.length) { return null; } const m = method.owningclass.type.signature.match(/^L([^;$]+)[$a-zA-Z0-9_]*;$/); if (!m) { return null; } const qualified_type_name = m[1]; const lines = method.linetable.lines; let prevk = 0; for (let k=0; k < lines.length; k++) { if (lines[k].linecodeidx < idx) { prevk = k; continue; } // multi-part expressions can return intermediate idx's // - if the idx is not an exact match, use the previous value if (lines[k].linecodeidx > idx) k = prevk; // convert to a file location return new SourceLocation(qualified_type_name, lines[k].linenum, lines[k].linecodeidx === idx, threadid); } // just return the last location in the list return new SourceLocation(qualified_type_name, lines[lines.length - 1].linenum, false, threadid); } /** * * @param {JavaLocation} location * @param {JavaThreadID} threadid */ async javaLocationToSourceLocation(location, threadid) { // search the classes for a method containing the line const method = await this._findMethodAsync(this.session.classList, location); if (!method) return null; await this._ensureMethodLines(method); return this.lineIndexToSourceLocation(method, location.idx, threadid); } /** * @param {DebuggerTypeInfo[]} classes * @param {JavaLocation} location */ async _findMethodAsync(classes, location) { // some locations are null (which causes the jdwp command to fail) if (/^0+$/.test(location.cid)) { return null; } const m = this.findMethod(classes, location.cid, location.mid); if (m) { return m; } return this._findMethodFromLocation(location); } /** * Sends a JDWP command to retrieve the method at the given location * @param {JavaLocation} location */ async _findMethodFromLocation(location) { // convert the location classid to a type signature /** @type {JavaType} */ const type = await this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.signature(location.cid), }); // retrieve the type info with methods const typeinfo = await this.getTypeInfo(type.signature); await this._ensureMethods(typeinfo); // search for the method matching the method ID return this.findMethod([typeinfo], location.cid, location.mid); } /** * Search the list of classes for a particular method in a class * @param {DebuggerTypeInfo[]} classes * @param {JavaClassID} classid * @param {JavaMethodID} methodid */ findMethod(classes, classid, methodid) { const clz = classes.find(c => c.info.typeid === classid); const method = clz && clz.methods.find(m => m.methodid === methodid); return method || null; } /** * Retrieve a list of class signatures loaded by the runtime. * (note that this method is slow - there are usually thousands of classes in the list) */ getAllClasses() { return this.session.adbclient.jdwp_command({ cmd: JDWP.Commands.AllClassesWithGeneric(), }); } } module.exports = { Debugger, }