What happens when DevTools Pauses
Over the past couple months, I’ve become increasingly curious about how chrome devtools works. In this post, I’ve tried to convert some of my notes into a question answer format. Some of this is probably glorified call stacks and grepping. Other parts, I’ve included simplified code-snippets to capture the gist.
What happens when a breakpoint is hit in DevTools
The frontend receives a “Debugger.paused” method
- The method contains four params: callFrames, reason, data, hitBreakpoints, asyncStackTrace.
- The callFrame has fields: callFrameId, functionName, location, scopeChain, this
There are several reasons execution could stop:
- XHR, DOM, EventListener, exception, assert, CSPViolation, debugCommand, promiseRejection, AsyncOperation, other
- When you set a breakpoint in the source panel, the reason is other. At this point, the hitBreakpoints field will show the script, line number and column number of the hit breakpoint (e.g. http://localhost:3000/bundle.js:26641:0)
A sample callFrame
result looks something like this:
- the
functionName
andlocation
are useful for seeing where the execution stopped - the
scopeChain
will show the list of closures at that frame (local, closure…, global) - the
this
field shows the current context at that frame. callFrameId
is really cool.ordinal
refers to the frame number or depth. I’m not sure whatinjectedScriptId
refers to.- Don’t forget, there’s an array of callFrames
{
"callFrameId": "{\"ordinal\":0,\"injectedScriptId\":2}",
"functionName": "module.exports.Router.extend.index",
"location": {
"scriptId": "34",
"lineNumber": 26642,
"columnNumber": 23
},
"scopeChain": [
{
"object": {
"type": "object",
"objectId": "{\"injectedScriptId\":2,\"id\":1}",
"className": "Object",
"description": "Object"
},
"type": "local"
},
{
"object": {
"type": "object",
"objectId": "{\"injectedScriptId\":2,\"id\":2}",
"className": "Object",
"description": "Object"
},
"type": "closure"
},
{
"object": {
"type": "object",
"objectId": "{\"injectedScriptId\":2,\"id\":3}",
"className": "Window",
"description": "Window"
},
"type": "global"
}
],
"this": {
"type": "object",
"objectId": "{\"injectedScriptId\":2,\"id\":4}",
"className": "child",
"description": "child"
}
}
How does the “Scope Variables” field update?
I clicked on the “this” field in the local scope section and tracked what happened:
1. The front-end makes a Runtime.getProperties request
{
"id":28,
"method":"Runtime.getProperties",
"params":{
"objectId":"{\"injectedScriptId\":2,\"id\":4}",
"ownProperties":true,
"accessorPropertiesOnly":false
}
}
2. The server replies with a result response with an id
that matches the request
{
"id": 28,
"result": {
"result": ["properties..."]
}
}
A typical property looks something like this:
{
"writable": true,
"enumerable": true,
"configurable": true,
"name": "channel",
"isOwn": true,
"value": {
"type": "object",
"objectId": "{\"injectedScriptId\":2,\"id\":63}",
"className": "Radio.Channel",
"description": "Radio.Channel"
}
}
Unpacking this a bit, the name
field and value description
are especially cool.
Note that when we look at the variables in the scope variables section we’re really looking
at a tree with leaves that look like (name: value.description). The writable
, isOwn
, enumerable
fields are also useful but are less interesting for the UI.
3. A sequence of actions occur and finally the new this
properties are shown.
There’s a lot here to unpack here, but first just skim the call stack. It’s interesting by itself.
InspectorBackend.js
- InspectorBackendClass.WebSocketConnection.onMessage
- InspectorBackendClass.Connection.dispatch
- InspectorBackendClass.AgentPrototype.dispatchResponse
RemoteObject.js
- remoteObjectBinder
- ownPropertiesCallback
- processCallback
ObjectPropertiesSection.js
- callback
- WebInspector.ObjectPropertyTreeElement.populateWithProperties
treeoutline.js
- TreeElement.appendChild
- TreeElement.insertChild
ObjectPropertiesSection.js
- WebInspector.ObjectPropertyTreeElement.onattach
-
WebInspector.ObjectPropertyTreeElement.update
this
is a Remote Object.
The remote object, requested its own properties and when the request came back, the object handles processing the response and converting it into reasonable data.
Here’s a simplified version of RemoteObject.remoteObjectBinder
function remoteObjectBinder(error, properties, internalProperties) {
var result = [];
for (var i = 0; properties && i < properties.length; ++i) {
var property = properties[i];
var propertyValue = createRemoteObject(property.value)
result.push(new RemoteObjectProperty(property.name, propertyValue));
}
var internalPropertiesResult = [];
for (var i = 0; i < internalProperties.length; i++) {
var property = internalProperties[i];
var propertyValue = createRemoteObject(property.value);
internalPropertiesResult.push(new RemoteObjectProperty(property.name, propertyValue));
}
callback(result, internalPropertiesResult);
}
Once the remote object knows about its properties and internal properties, it passes
the data onto to the object property tree element populateWithProperties
. You can guess,
what this guy will do.
The job of populateWithProperties
is to create an object property tree element for each property.
There are tons of cases here, but the simplified version looks something like this:
function(treeNode, properties, internalProperties, skipProto, value, emptyPlaceholder) {
for (var i = 0; i < properties.length; ++i) {
var property = properties[i];
if (skipProto && property.name === "__proto__") continue;
treeNode.appendChild(new ObjectPropertyTreeElement(property));
}
for (var i = 0; i < internalProperties.length; i++) {
treeNode.appendChild(new ObjectPropertyTreeElement(internalProperties[i]));
}
}
We’re ready to show all of this
’s properties as child nodes of this
.
In this example, this
has 9 properties:
channel, _events, _listeningTo, routes, container, collection, options, active, __proto__
If you remember from above, populateWithProperties
creates one Tree Element for each property.
When each element is created, it’s update
function is called and that’s where the magic happens:
function update() {
this.nameElement = createNameElement(this.property.name);
this.valueElement = createValueElement(this.property.value, this.listItemElement);
var separatorElement = createElementWithClass("span", "separator").textContent = ": ";
this.listItemElement.appendChildren(this.nameElement, separatorElement, this.valueElement);
}
Voila, each property is now an element with a name and value and is shown below the this
variable.
How did the DevTools backend send the “Debugger.paused” message to the Inspector?
This story begins with V8 and the running javascript VM which detects the pause. While, this is cool, for our purposes I’m going to skip to the part of the story where the backend actually sends the message and associated data, trust me, this story is good enough!
Here’s an overview, that we’ll unpack below.
InspectorDebuggerAgent.cpp
- InspectorDebuggerAgent::didPause
InspectorFrontend.cpp
- m_frontend->paused(currentCallFrames(), m_breakReason, m_breakAuxData, hitBreakpointIds, currentAsyncStackTrace());
InspectorDebuggerAgent.cpp
- PassRefPtr<Array
> InspectorDebuggerAgent::currentCallFrames
InjectedScript.cpp
- injectedScript.wrapCallFrames(m_currentCallStack, 0);
InjectedScriptSource.js
- ScriptFunctionCall function(injectedScriptObject(), “wrapCallFrames”);
- InjectedScript.CallFrameProxy(depth, callFrame, asyncOrdinal);
- InjectedScript.wrapObject(callFrame.thisObject, “backtrace”)
- new RemoteObject(callFrame.thisObject)
The Inspector Debugger Agent, is a good enough place for the story to start. One of the interesting devTools patterns is that when there’s a data object that’s shared between the frontend and backend, there’s often a model on the frontend and agent on the backend. Think of it like the agent doing the work to populate the model.
In this case, the InspectorDebuggerAgent reacts to the pause event and calls the frontend paused method. The fact that the paused method matches up to the “Debugger.paused” message is not a coincidence. When you investigate the frontend object, you discover it’s type descends from InspectorFrontend, which is a special type of object defined by generated InspectorFrontend header and cpp files. These files are built by a python file called CodeGeneratorInspector.py, which reads the protocol json configuration file and builds the cpp files at build time!
So, the first part of the answer is that backend sends the “Debugger.paused” message through a specially built class called InspectorFrontend.cpp. The second question, we want to answer is how was the data for the message constructed. This data has super interesting information about the location and reason of the pause, the call frames, and context.
Determining how the call frame data is constructed is a little bit of a rabbit hole, but it’s super interesting. So lets get started.
1. currentCallFrames
CurrentCallFrames is a simple helper method for querying injected scripts. Note, we pass the call stack down to script to serialize the call frame data.
2. wrapCallFrames
The injected script manager does something crazy cool. Something so cool, that if you don’t
stop doing what you’re doing and pause for 10 seconds, you’ve got no pulse. The script manager
asks javascript to serialize the call frames! This happens here with the new scriptFunctionCall and
callFunctionWithEvalEnabled
. In hindsight, asking v8 to serialize it’s own call frames is a brilliant
strategy, what business does cpp have knowing all of javascript’s dirty secrets?
PassRefPtr<Array<CallFrame> > InjectedScript::wrapCallFrames(const ScriptValue& callFrames, int asyncOrdinal) {
ScriptFunctionCall function(injectedScriptObject(), "wrapCallFrames");
function.appendArgument(callFrames);
function.appendArgument(asyncOrdinal);
ScriptValue callFramesValue = callFunctionWithEvalEnabled(function);
return Array<CallFrame>::runtimeCast(callFramesValue);
}
3. wrapCallFrames (part 2)
On the JS side of the isle, things become simple again. Here we create a couple CallFrameProxy objects. Why, because we like our data objects in devTools land.
4. CallFrameProxy
The call frame should have an id, location, scope chain, and context.
- The scope chain will be our closures (local, …, global).
- The context will be what shows up in
this
in the scope variables section - and if the pause is right after the return statement has been evaluated,
isAtReturn
will be true and we’ll serialize the return value aswell.
InjectedScript.CallFrameProxy = function(ordinal, callFrame, asyncOrdinal) {
this.callFrameId = {
ordinal: ordinal,
injectedScriptId: injectedScriptId,
asyncOrdinal: asyncOrdinal
};
this.location = {
scriptId: callFrame.sourceID,
lineNumber: callFrame.line,
columnNumber: callFrame.column,
};
this.scopeChain = this._wrapScopeChain(callFrame);
this.this = injectedScript._wrapObject(callFrame.thisObject, "backtrace");
if (callFrame.isAtReturn) {
this.returnValue = injectedScript._wrapObject(callFrame.returnValue, "backtrace");
}
}
5. wrapObject
Did you see all of the _wrapX
calls above? Well this is what it basically looks like.
Don’t be afraid, RemoteObjects is actually a really fundamental piece of DevTools. Remember how we
had RemoteObjects on the frontend side, it turns out that they have a 1:1 sibling on the backend as well.
_wrapObject = function(object, forceValueType, generatePreview) {
return new InjectedScript.RemoteObject(object, forceValueType, generatePreview);
},
6. RemoteObject
RemoteObject takes an object and serializes. Almost every object shown in chrome devtools has been serialized by RemoteObject and all of it’s data shipped up the inspector. When you look at the constructor, you’ll see a bunch of things you might expect (type, value, preview).
One feature, which is still experimental, but should be totally awesome is the custom previewer. The feature is still experimental, but hopefully someday it’ll be easy to register custom formatter for your favorite application and framework objects. These previews will help make the objects prettier in the console and source panel.
Closing Thoughts
I hope you enjoyed diving into chrome DevTools. The people who work on DevTools have done a great job at making the project as accessible as possible. I’ll try and put together a couple more deep dives going forward, I’ve just begun exploring the project the past couple months, but already I’ve learned so much.