Skip to content

Instantly share code, notes, and snippets.

@juliandescottes
Last active April 26, 2024 22:52
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save juliandescottes/9b73bf53712413b29e75683788a8ecab to your computer and use it in GitHub Desktop.
Save juliandescottes/9b73bf53712413b29e75683788a8ecab to your computer and use it in GitHub Desktop.

DevTools Server Architecture and JsWindowActors

I realize it is hard/impossible to follow the discussions around the JsWindowActor work that is currently ongoing in DevTools. I think a small overview of the DevTools architecture before and after will help.

First of all a warning, I am going to mostly talk about the "easy" scenario, where you have one toolbox that debugs one tab. Things can get really complicated if you start mixing local toolboxes and remote toolboxes, plus a few exotic clients such as aboutdebugging. If things seem overly complicated for no reason, it's probably because of one of those scenarios (although it's always good to challenge it!)

Simple DevTools Architecture

Let's start with the current state.

DevTools uses a client/server architecture. Roughly, the client is responsible for the UI, and the server provides actors to interact with the browser, with the debugged tab etc...

┌────────────────┐       ┌────────────────┐
│ DebuggerClient │       │ DebuggerServer │
│                │◀─────▶│                │
│                │       │                │
└────────────────┘       └────────────────┘

But if we think about the server, we need to take into account that Firefox uses several processes. Some data will be available only in the parent process (for instance browser or device information), and some will be available only in the content process (for instance the DOM of the content page).

To support this, we don't have just one server, but two:

  • one server in the parent process, with global actors (eg Device actor)
  • one server in the content process, with target scoped actors (eg Inspector actor)

Knowing the client also runs in the parent process, let's add process boundaries to our diagram:

┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐   ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
  Parent process                                    Content process    │
│                                             │   │
  ┌────────────────┐       ┌────────────────┐       ┌────────────────┐ │
│ │ DebuggerClient │       │ DebuggerServer │ │   │ │ DebuggerServer │
  │                │       │                │       │                │ │
│ │                │◀─────▶│                │◀┼───┼▶│                │
  │                │       │                │       │                │ │
│ │                │       │                │ │   │ │                │
  └────────────────┘       └────────────────┘       └────────────────┘ │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘   └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─

You might wonder why we are splitting the Client and the DebuggerServer living in the ParentProcess. Just to get this one out of the way: let's do the same thing in remote debugging:

┌──────────────────────┐┌────────────────────────────────────────────────┐
│ Client runtime       ││ Target runtime                                 │
│                      ││                                                │
│┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─    ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│  Parent process     │││   Parent process     │     Content process    ││
││                     ││ │                        │                     │
│  ┌────────────────┐ │││   ┌────────────────┐ │     ┌────────────────┐ ││
││ │ DebuggerClient │  ││ │ │ DebuggerServer │     │ │ DebuggerServer │  │
│  │                │ │││   │                │ │     │                │ ││
││ │                │◀─┼┼─┼▶│                │◀────┼▶│                │  │
│  │                │ │││   │                │ │     │                │ ││
││ │                │  ││ │ │                │     │ │                │  │
│  └────────────────┘ │││   └────────────────┘ │     └────────────────┘ ││
│└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─    └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
└──────────────────────┘└────────────────────────────────────────────────┘

Even though in the basic toolbox scenario, the Client and the parent DebuggerServer are in the same process, in theory they can be in different applications (or even different machines, different networks!).

Here, for the JS Window Actors work, we will focus on the right handside of the diagram, how the parent DebuggerServer and the content DebuggerServer communicate, because that's where we are going to use the JS Window Actors. That means I won't go in too much details about the Client to Debugger communication, and we will mostly treat the client as a black box.

DebuggerServerConnection

Before we start juggling Message Managers and Js Window Actors, we need to go over two important concepts/classes DebuggerServerConnection and Transport.

First DebuggerServerConnection. The diagrams of the first section are overly simplified and borderline wrong. DebuggerServer's don't really talk to each other. The DebuggerServer is a singleton (normally one per process, but actually one per Loader) which is kind of the server-side starting point for DevTools. It doesn't have a lot of state, except which actor classes are available, and which DebuggerServerConnections are currently "using" this DebuggerServer.

If the DebuggerServer is "just" a singleton, then what is the DebuggerServerConnection, and do we care about it? When we think about the server, we usually are interested in DevTools Actors. And Actors are organized in Pools. Pools deserve their own documentation, so let's just say they are Trees with a notion of lifecycle (when a parent node is destroyed, all its children are destroyed...). And the root node of this tree is the DebuggerServerConnection. This means that the parent DebuggerServerConnection is the root node of all global Actor instances. And the content DebuggerServerConnection is the root node of all target scoped Actor instances.

For this example we can forget a bit about DebuggerServers and just reason with DebuggerServerConnections, because each server only has one connection here. Remember it can easily become more complex than that, but for now we will just swap the DebuggerServer boxes with DebuggerServerConnection boxes.

┌────────┐   ┌────────────────────────────────────────────────┐
│ Client │   │ Target runtime                                 │
│        │   │                                                │
│        │   │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─    ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│        │   │   Parent process     │     Content process    ││
│        │   │ │                        │                     │
│        │   │   ┌────────────────┐ │     ┌────────────────┐ ││
│        │   │ │ │ DebuggerServer │     │ │ DebuggerServer │  │
│        │   │   │   Connection   │ │     │   Сonnection   │ ││
│        │◀──┼─┼▶│                │◀────┼▶│                │  │
│        │   │   │                │ │     │                │ ││
│        │   │ │ │                │     │ │                │  │
│        │   │   └────────────────┘ │     └────────────────┘ ││
│        │   │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─    └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
└────────┘   └────────────────────────────────────────────────┘

Transports

Let's talk about Transports now and see how the DebuggerServerConnection instances communicate together and with the "Client". They communicate using Transport instances. A Transport is a class that allows to send and receive packets, and we have implementation that use various technical solutions: websocket, direct method calls, message manager, worker messages etc... Depending on the nature of the boundary between the DebuggerServerConnection and the recipient of the communication, a specific implementation will be used. Transports are always used as a "pair", with one instance on each side of the "wire".

Local Transport

With our simple scenario of a local toolbox debugging a content page, the DebuggerClient and the parent process DebuggerServerConnection actually live in the same process. So they can talk directly by calling JavaScript methods. For this, the "local-transport" will be used. And so we will have one LocalTransport instance for the Client and another one for the parent process DebuggerServerConnection.

┌───────────┐   ┌───────────────────────────────────────────────────────────────┐
│Client     │   │ Target runtime                                                │
│           │   │                                                               │
│           │   │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐       ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│           │   │   Parent process                          Content process    ││
│           │   │ │                               │       │                     │
│┌─────────┐│   │   ┌─────────┐┌────────────────┐           ┌────────────────┐ ││
││  Local  ││   │ │ │  Local  ││ DebuggerServer │ │       │ │ DebuggerServer │  │
││Transport││   │   │Transport││   Connection   │           │   Сonnection   │ ││
││         │◀───┼─┼▶│        ◀┼┼▶               │◀┼───────┼▶│                │  │
││         ││   │   │         ││                │           │                │ ││
││         ││   │ │ │         ││                │ │       │ │                │  │
│└─────────┘│   │   └─────────┘└────────────────┘           └────────────────┘ ││
│           │   │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘       └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
└───────────┘   └───────────────────────────────────────────────────────────────┘

From the point of view of the parent process DebuggerServerConnection, the LocalTransport is its "main" transport, the transport that connects it to its client. It would be more accurate to say that the transport is owned by the DebuggerServerConnection, so let's alias LocalTransport to LT and make it a part of the DebuggerServerConnection.

┌────────┐   ┌───────────────────────────────────────────────┐
│ Client │   │ Target runtime                                │
│        │   │                                               │
│        │   │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
│        │   │   Parent process     │   Content process    │ │
│        │   │ │                      │                      │
│        │   │   ┌────────────────┐ │   ┌────────────────┐ │ │
│        │   │ │ │ DebuggerServer │   │ │ DebuggerServer │   │
│        │   │   │   Connection   │ │   │   Сonnection   │ │ │
│ ┌────┐ │   │ │ │ ┌────┐         │◀──┼▶│                │   │
│ │ LT │◀┼───┼───┼▶│ LT │         │ │   │                │ │ │
│ └────┘ │   │ │ │ └────┘         │   │ │                │   │
│        │   │   └────────────────┘ │   └────────────────┘ │ │
│        │   │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
└────────┘   └───────────────────────────────────────────────┘

ChildTransport and (finally!) Message Manager

Now how does the parent process DebuggerServerConnection talk to the content process one. Here we need to send messages across a process boundary so LocalTransport can't be used. Instead we rely on a Message Manager based Transport, called ChildTransport.

So we are finally reaching the "Message Manager" layer! We need a way to send messages across the process boundaries and using the Message Manager was the way to do that. We will go in more details to see how the Message Manager is retrieved and used later, but for now let's just keep in mind that ChildTransport uses Message Manager, and that's how our parent process DebuggerServerConnection can talk to the content process DebuggerServerConnection.

Again we will have one ChildTransport instance on each side. We will represent those ChildTransport instances as "CT" boxes.

┌────────┐   ┌───────────────────────────────────────────────┐
│ Client │   │ Target runtime                                │
│        │   │                                               │
│        │   │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
│        │   │   Parent process     │   Content process    │ │
│        │   │ │                      │                      │
│        │   │   ┌────────────────┐ │   ┌────────────────┐ │ │
│        │   │ │ │ DebuggerServer │   │ │ DebuggerServer │   │
│        │   │   │   Connection   │ │   │   Сonnection   │ │ │
│ ┌────┐ │   │ │ │ ┌────┐  ┌────┐ │   │ │ ┌────┐         │   │
│ │ LT │◀┼───┼───┼▶│ LT │  │ CT │◀┼─┼───┼▶│ CT │         │ │ │
│ └────┘ │   │ │ │ └────┘  └────┘ │   │ │ └────┘         │   │
│        │   │   └────────────────┘ │   └────────────────┘ │ │
│        │   │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
└────────┘   └───────────────────────────────────────────────┘

For the content process DebuggerServerConnection, the ChildTransport is its main transport. In a sense, the parent process DebuggerServerConnection acts as the "client" of the content process one so that's fine.

So why do we have two transports in the parent process DebuggerServerConnection? The LocalTransport, we mentioned earlier, is the main train transport of this DebuggerServerConnection, it is linked to its client. There is a 1..1 relationship between the connection and this transport. The other one, the ChildTransport, will be used it to forward messages from the client to the content process DebuggerServerConnection. This is what we call a "forwarding" transport. And a single DebuggerServerConnection can have many forwarding transport, as many as it has targets to talk to.

┌────────┐   ┌───────────────────────────────────────────────────┐
│ Client │   │ Target runtime                                    │
│        │   │                                                   │
│        │   │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐    ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
│        │   │   Parent process             Content process    │ │
│        │   │ │                     │    │                      │
│        │   │   ┌────────────────┐         ┌────────────────┐ │ │
│        │   │ │ │ DebuggerServer │  │    │ │ DebuggerServer │   │
│        │   │   │   Connection   │         │   Сonnection   │ │ │
│        │   │ │ └─┬───────────┬──┘  │    │ └─┬──────────────┘   │
│        │   │     │1..1       │1..n          │1..1            │ │
│        │   │ │   │           │     │    │   │                  │
│ ┌────┐ │   │   ┌─┴──┐      ┌─┴──┐         ┌─┴──┐             │ │
│ │ LT │◀┼───┼─┼▶│ LT │      │ CT │◀─┼────┼▶│ CT │               │
│ └────┘ │   │   └────┘      └────┘         └────┘             │ │
│        │   │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘    └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
└────────┘   └───────────────────────────────────────────────────┘

Recap

The goal of this introduction was to clarify where (and why) the Message Manager was used in the DevTools infrastructure: it is what enables the ChildTransports, which are used to communicate between parent process and content process DebuggerServerConnections.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment