From c7182b9d9c2691f0149fed8d0a1056f36957e1eb Mon Sep 17 00:00:00 2001 From: Eugene Ostroukhov Date: Mon, 12 Dec 2016 17:08:31 -0800 Subject: [PATCH] inspector: JavaScript bindings for the inspector PR-URL: https://github.com/nodejs/node/pull/12263 Reviewed-By: Anna Henningsen Reviewed-By: Sam Roberts Reviewed-By: Timothy Gu Reviewed-By: Josh Gavant Reviewed-By: Ben Noordhuis --- doc/api/_toc.md | 1 + doc/api/all.md | 1 + doc/api/inspector.md | 114 +++++++++++++++++++++++ lib/inspector.js | 87 ++++++++++++++++++ node.gyp | 1 + src/inspector_agent.cc | 154 +++++++++++++++++++++++++++++++- src/inspector_agent.h | 1 + test/inspector/test-bindings.js | 105 ++++++++++++++++++++++ 8 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 doc/api/inspector.md create mode 100644 lib/inspector.js create mode 100644 test/inspector/test-bindings.js diff --git a/doc/api/_toc.md b/doc/api/_toc.md index cc8b3a4ed0d540..29fffbc59fc1e2 100644 --- a/doc/api/_toc.md +++ b/doc/api/_toc.md @@ -24,6 +24,7 @@ * [Globals](globals.html) * [HTTP](http.html) * [HTTPS](https.html) +* [Inspector](inspector.html) * [Modules](modules.html) * [Net](net.html) * [OS](os.html) diff --git a/doc/api/all.md b/doc/api/all.md index f65b24587511a1..a4741e64274c7b 100644 --- a/doc/api/all.md +++ b/doc/api/all.md @@ -18,6 +18,7 @@ @include globals @include http @include https +@include inspector @include modules @include net @include os diff --git a/doc/api/inspector.md b/doc/api/inspector.md new file mode 100644 index 00000000000000..6eb7b721b1217b --- /dev/null +++ b/doc/api/inspector.md @@ -0,0 +1,114 @@ +# Inspector + +> Stability: 1 - Experimental + +The `inspector` module provides an API for interacting with the V8 inspector. + +It can be accessed using: + +```js +const inspector = require('inspector'); +``` + +## Class: inspector.Session + +The `inspector.Session` is used for dispatching messages to the V8 inspector +back-end and receiving message responses and notifications. + +### Constructor: new inspector.Session() + + +Create a new instance of the `inspector.Session` class. The inspector session +needs to be connected through [`session.connect()`][] before the messages +can be dispatched to the inspector backend. + +`inspector.Session` is an [`EventEmitter`][] with the following events: + +### Event: 'inspectorNotification' + + +* {Object} The notification message object + +Emitted when any notification from the V8 Inspector is received. + +```js +session.on('inspectorNotification', (message) => console.log(message.method)); +// Debugger.paused +// Debugger.resumed +``` + +It is also possible to subscribe only to notifications with specific method: + +### Event: <inspector-protocol-method> + + +* {Object} The notification message object + +Emitted when an inspector notification is received that has its method field set +to the `` value. + +The following snippet installs a listener on the [`Debugger.paused`][] +event, and prints the reason for program suspension whenever program +execution is suspended (through breakpoints, for example): + +```js +session.on('Debugger.paused', ({params}) => console.log(params.hitBreakpoints)); +// [ '/node/test/inspector/test-bindings.js:11:0' ] +``` + +### session.connect() + + +Connects a session to the inspector back-end. An exception will be thrown +if there is already a connected session established either through the API or by +a front-end connected to the Inspector WebSocket port. + +### session.post(method[, params][, callback]) + + +* method {string} +* params {Object} +* callback {Function} + +Posts a message to the inspector back-end. `callback` will be notified when +a response is received. `callback` is a function that accepts two optional +arguments - error and message-specific result. + +```js +session.post('Runtime.evaluate', {'expression': '2 + 2'}, + (error, {result}) => console.log(result.value)); +// Output: { type: 'number', value: 4, description: '4' } +``` + +The latest version of the V8 inspector protocol is published on the +[Chrome DevTools Protocol Viewer][]. + +Node inspector supports all the Chrome DevTools Protocol domains declared +by V8. Chrome DevTools Protocol domain provides an interface for interacting +with one of the runtime agents used to inspect the application state and listen +to the run-time events. + +### session.disconnect() + + +Immediately close the session. All pending message callbacks will be called +with an error. [`session.connect()`] will need to be called to be able to send +messages again. Reconnected session will lose all inspector state, such as +enabled agents or configured breakpoints. + +[`session.connect()`]: #sessionconnect +[`Debugger.paused`]: https://chromedevtools.github.io/devtools-protocol/v8/Debugger/#event-paused +[`EventEmitter`]: events.html#events_class_eventemitter +[Chrome DevTools Protocol Viewer]: https://chromedevtools.github.io/devtools-protocol/v8/ diff --git a/lib/inspector.js b/lib/inspector.js new file mode 100644 index 00000000000000..1edc9fc3beebeb --- /dev/null +++ b/lib/inspector.js @@ -0,0 +1,87 @@ +'use strict'; + +const connect = process.binding('inspector').connect; +const EventEmitter = require('events'); +const util = require('util'); + +if (!connect) + throw new Error('Inspector is not available'); + +const connectionSymbol = Symbol('connectionProperty'); +const messageCallbacksSymbol = Symbol('messageCallbacks'); +const nextIdSymbol = Symbol('nextId'); +const onMessageSymbol = Symbol('onMessage'); + +class Session extends EventEmitter { + constructor() { + super(); + this[connectionSymbol] = null; + this[nextIdSymbol] = 1; + this[messageCallbacksSymbol] = new Map(); + } + + connect() { + if (this[connectionSymbol]) + throw new Error('Already connected'); + this[connectionSymbol] = + connect((message) => this[onMessageSymbol](message)); + } + + [onMessageSymbol](message) { + const parsed = JSON.parse(message); + if (parsed.id) { + const callback = this[messageCallbacksSymbol].get(parsed.id); + this[messageCallbacksSymbol].delete(parsed.id); + if (callback) + callback(parsed.error || null, parsed.result || null); + } else { + this.emit(parsed.method, parsed); + this.emit('inspectorNotification', parsed); + } + } + + post(method, params, callback) { + if (typeof method !== 'string') + throw new TypeError( + `"method" must be a string, got ${typeof method} instead`); + if (!callback && util.isFunction(params)) { + callback = params; + params = null; + } + if (params && typeof params !== 'object') + throw new TypeError( + `"params" must be an object, got ${typeof params} instead`); + if (callback && typeof callback !== 'function') + throw new TypeError( + `"callback" must be a function, got ${typeof callback} instead`); + + if (!this[connectionSymbol]) + throw new Error('Session is not connected'); + const id = this[nextIdSymbol]++; + const message = {id, method}; + if (params) { + message['params'] = params; + } + if (callback) { + this[messageCallbacksSymbol].set(id, callback); + } + this[connectionSymbol].dispatch(JSON.stringify(message)); + } + + disconnect() { + if (!this[connectionSymbol]) + return; + this[connectionSymbol].disconnect(); + this[connectionSymbol] = null; + const remainingCallbacks = this[messageCallbacksSymbol].values(); + for (const callback of remainingCallbacks) { + process.nextTick(callback, new Error('Session was closed')); + } + this[messageCallbacksSymbol].clear(); + this[nextIdSymbol] = 1; + } +} + +module.exports = { + Session +}; diff --git a/node.gyp b/node.gyp index f50aea3475598d..c188072d0a2706 100644 --- a/node.gyp +++ b/node.gyp @@ -43,6 +43,7 @@ 'lib/_http_outgoing.js', 'lib/_http_server.js', 'lib/https.js', + 'lib/inspector.js', 'lib/module.js', 'lib/net.js', 'lib/os.js', diff --git a/src/inspector_agent.cc b/src/inspector_agent.cc index 1abd03f1f6cc77..c93aab4eafce04 100644 --- a/src/inspector_agent.cc +++ b/src/inspector_agent.cc @@ -22,12 +22,17 @@ namespace node { namespace inspector { namespace { using v8::Context; +using v8::External; using v8::Function; using v8::FunctionCallbackInfo; using v8::HandleScope; using v8::Isolate; using v8::Local; +using v8::Maybe; +using v8::MaybeLocal; +using v8::NewStringType; using v8::Object; +using v8::Persistent; using v8::String; using v8::Value; @@ -176,6 +181,143 @@ static int RegisterDebugSignalHandler() { } #endif // _WIN32 +class JsBindingsSessionDelegate : public InspectorSessionDelegate { + public: + JsBindingsSessionDelegate(Environment* env, + Local session, + Local receiver, + Local callback) + : env_(env), + session_(env->isolate(), session), + receiver_(env->isolate(), receiver), + callback_(env->isolate(), callback) { + session_.SetWeak(this, JsBindingsSessionDelegate::Release, + v8::WeakCallbackType::kParameter); + } + + virtual ~JsBindingsSessionDelegate() { + session_.Reset(); + receiver_.Reset(); + callback_.Reset(); + } + + bool WaitForFrontendMessage() override { + return false; + } + + void OnMessage(const v8_inspector::StringView& message) override { + Isolate* isolate = env_->isolate(); + v8::HandleScope handle_scope(isolate); + Context::Scope context_scope(env_->context()); + MaybeLocal v8string = + String::NewFromTwoByte(isolate, message.characters16(), + NewStringType::kNormal, message.length()); + Local argument = v8string.ToLocalChecked().As(); + Local callback = callback_.Get(isolate); + Local receiver = receiver_.Get(isolate); + callback->Call(env_->context(), receiver, 1, &argument); + } + + void Disconnect() { + Agent* agent = env_->inspector_agent(); + if (agent->delegate() == this) + agent->Disconnect(); + } + + private: + static void Release( + const v8::WeakCallbackInfo& info) { + info.SetSecondPassCallback(ReleaseSecondPass); + info.GetParameter()->session_.Reset(); + } + + static void ReleaseSecondPass( + const v8::WeakCallbackInfo& info) { + JsBindingsSessionDelegate* delegate = info.GetParameter(); + delegate->Disconnect(); + delete delegate; + } + + Environment* env_; + Persistent session_; + Persistent receiver_; + Persistent callback_; +}; + +void SetDelegate(Environment* env, Local inspector, + JsBindingsSessionDelegate* delegate) { + inspector->SetPrivate(env->context(), + env->inspector_delegate_private_symbol(), + v8::External::New(env->isolate(), delegate)); +} + +Maybe GetDelegate( + const FunctionCallbackInfo& info) { + Environment* env = Environment::GetCurrent(info); + Local delegate; + MaybeLocal maybe_delegate = + info.This()->GetPrivate(env->context(), + env->inspector_delegate_private_symbol()); + + if (maybe_delegate.ToLocal(&delegate)) { + CHECK(delegate->IsExternal()); + void* value = delegate.As()->Value(); + if (value != nullptr) { + return v8::Just(static_cast(value)); + } + } + env->ThrowError("Inspector is not connected"); + return v8::Nothing(); +} + +void Dispatch(const FunctionCallbackInfo& info) { + Environment* env = Environment::GetCurrent(info); + if (!info[0]->IsString()) { + env->ThrowError("Inspector message must be a string"); + return; + } + Maybe maybe_delegate = GetDelegate(info); + if (maybe_delegate.IsNothing()) + return; + Agent* inspector = env->inspector_agent(); + CHECK_EQ(maybe_delegate.ToChecked(), inspector->delegate()); + inspector->Dispatch(ToProtocolString(env->isolate(), info[0])->string()); +} + +void Disconnect(const FunctionCallbackInfo& info) { + Environment* env = Environment::GetCurrent(info); + Maybe delegate = GetDelegate(info); + if (delegate.IsNothing()) { + return; + } + delegate.ToChecked()->Disconnect(); + SetDelegate(env, info.This(), nullptr); + delete delegate.ToChecked(); +} + +void ConnectJSBindingsSession(const FunctionCallbackInfo& info) { + Environment* env = Environment::GetCurrent(info); + if (!info[0]->IsFunction()) { + env->ThrowError("Message callback is required"); + return; + } + Agent* inspector = env->inspector_agent(); + if (inspector->delegate() != nullptr) { + env->ThrowError("Session is already attached"); + return; + } + Local session = Object::New(env->isolate()); + env->SetMethod(session, "dispatch", Dispatch); + env->SetMethod(session, "disconnect", Disconnect); + info.GetReturnValue().Set(session); + + JsBindingsSessionDelegate* delegate = + new JsBindingsSessionDelegate(env, session, info.Holder(), + info[0].As()); + inspector->Connect(delegate); + SetDelegate(env, session, delegate); +} + void InspectorConsoleCall(const v8::FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); HandleScope handle_scope(isolate); @@ -229,7 +371,6 @@ void CallAndPauseOnStart( call_args.size(), call_args.data()); args.GetReturnValue().Set(retval.ToLocalChecked()); } -} // namespace // Used in NodeInspectorClient::currentTimeMS() below. const int NANOS_PER_MSEC = 1000000; @@ -284,6 +425,8 @@ class ChannelImpl final : public v8_inspector::V8Inspector::Channel { std::unique_ptr session_; }; +} // namespace + class NodeInspectorClient : public v8_inspector::V8InspectorClient { public: NodeInspectorClient(node::Environment* env, @@ -510,6 +653,14 @@ void Agent::RunMessageLoop() { inspector_->runMessageLoopOnPause(CONTEXT_GROUP_ID); } +InspectorSessionDelegate* Agent::delegate() { + CHECK_NE(inspector_, nullptr); + ChannelImpl* channel = inspector_->channel(); + if (channel == nullptr) + return nullptr; + return channel->delegate(); +} + void Agent::PauseOnNextJavascriptStatement(const std::string& reason) { ChannelImpl* channel = inspector_->channel(); if (channel != nullptr) @@ -524,6 +675,7 @@ void Agent::InitJSBindings(Local target, Local unused, env->SetMethod(target, "consoleCall", InspectorConsoleCall); if (agent->debug_options_.wait_for_connect()) env->SetMethod(target, "callAndPauseOnStart", CallAndPauseOnStart); + env->SetMethod(target, "connect", ConnectJSBindingsSession); } void Agent::RequestIoStart() { diff --git a/src/inspector_agent.h b/src/inspector_agent.h index c42a40772f37b4..08c4af9a9cc458 100644 --- a/src/inspector_agent.h +++ b/src/inspector_agent.h @@ -59,6 +59,7 @@ class Agent { void FatalException(v8::Local error, v8::Local message); void Connect(InspectorSessionDelegate* delegate); + InspectorSessionDelegate* delegate(); void Disconnect(); void Dispatch(const v8_inspector::StringView& message); void RunMessageLoop(); diff --git a/test/inspector/test-bindings.js b/test/inspector/test-bindings.js new file mode 100644 index 00000000000000..07f12cbee5b8cd --- /dev/null +++ b/test/inspector/test-bindings.js @@ -0,0 +1,105 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const inspector = require('inspector'); +const path = require('path'); + +// This test case will set a breakpoint 4 lines below +function debuggedFunction() { + let i; + let accum = 0; + for (i = 0; i < 5; i++) { + accum += i; + } + return accum; +} + +let scopeCallback = null; + +function checkScope(session, scopeId) { + session.post('Runtime.getProperties', { + 'objectId': scopeId, + 'ownProperties': false, + 'accessorPropertiesOnly': false, + 'generatePreview': true + }, scopeCallback); +} + +function debuggerPausedCallback(session, notification) { + const params = notification['params']; + const callFrame = params['callFrames'][0]; + const scopeId = callFrame['scopeChain'][0]['object']['objectId']; + checkScope(session, scopeId); +} + +function testNoCrashWithExceptionInCallback() { + // There is a deliberate exception in the callback + const session = new inspector.Session(); + session.connect(); + const error = new Error('We expect this'); + assert.throws(() => { + session.post('Console.enable', () => { throw error; }); + }, (e) => e === error); + session.disconnect(); +} + +function testSampleDebugSession() { + let cur = 0; + const failures = []; + const expects = { + i: [0, 1, 2, 3, 4], + accum: [0, 0, 1, 3, 6] + }; + scopeCallback = function(error, result) { + const i = cur++; + let v, actual, expected; + for (v of result['result']) { + actual = v['value']['value']; + expected = expects[v['name']][i]; + if (actual !== expected) { + failures.push('Iteration ' + i + ' variable: ' + v['name'] + + ' expected: ' + expected + ' actual: ' + actual); + } + } + }; + const session = new inspector.Session(); + session.connect(); + let secondSessionOpened = false; + const secondSession = new inspector.Session(); + try { + secondSession.connect(); + secondSessionOpened = true; + } catch (error) { + // expected as the session already exists + } + assert.strictEqual(secondSessionOpened, false); + session.on('Debugger.paused', + (notification) => debuggerPausedCallback(session, notification)); + let cbAsSecondArgCalled = false; + assert.throws(() => { + session.post('Debugger.enable', function() {}, function() {}); + }, TypeError); + session.post('Debugger.enable', () => cbAsSecondArgCalled = true); + session.post('Debugger.setBreakpointByUrl', { + 'lineNumber': 11, + 'url': path.resolve(__dirname, __filename), + 'columnNumber': 0, + 'condition': '' + }); + + debuggedFunction(); + assert.deepStrictEqual(cbAsSecondArgCalled, true); + assert.deepStrictEqual(failures, []); + assert.strictEqual(cur, 5); + scopeCallback = null; + session.disconnect(); + assert.throws(() => session.post('Debugger.enable'), (e) => !!e); +} + +testNoCrashWithExceptionInCallback(); +testSampleDebugSession(); +let breakpointHit = false; +scopeCallback = () => (breakpointHit = true); +debuggedFunction(); +assert.strictEqual(breakpointHit, false); +testSampleDebugSession();