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 and location 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 what injectedScriptId 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 > 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!

m_frontend->paused(currentCallFrames(), m_breakReason, m_breakAuxData, hitBreakpointIds, currentAsyncStackTrace());

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.

PassRefPtr<Array<CallFrame> > InspectorDebuggerAgent::currentCallFrames() {
  InjectedScript injectedScript = m_injectedScriptManager->injectedScriptFor(m_pausedScriptState.get());
  return injectedScript.wrapCallFrames(m_currentCallStack, 0);
}

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.

function wrapCallFrames(callFrame, asyncOrdinal) {
  var result = [];
  var depth = 0;
  do {
    result[depth] = new InjectedScript.CallFrameProxy(depth, callFrame, asyncOrdinal);
    callFrame = callFrame.caller;
    ++depth;
  } while (callFrame);

  return result;
},

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.

RemoteObject = function(object, forceValueType, generatePreview) {
  this.type = typeof object;

  if (isPrimitiveValue(object) || forceValueType)
  this.value = object;

  var subtype = injectedScript._subtype(object);
  if (subtype) this.subtype = subtype;

  var className = internalConstructorName(object);
  this.className = className;

  this.description = _describe(object);

  if (generatePreview) this.preview = this._generatePreview(object);

  if (_customObjectFormatterEnabled) this.customPreview = this._customPreview(object);
}

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.

01 Mar 2015