Servo is a parallel, memory-safe browser engine written in Rust, and
its single defining idea is decomposition by ownership: every
subsystem is a component crate with private state, reachable from the
others only through typed message channels β and the message layer
is built so the same engine runs as cooperating threads or as sandboxed
processes with zero code changes. At the center sits the
Constellation, a supervisor thread that owns no page content: it
only creates, routes, and reaps Pipelines (its view of one
document), each living in an EventLoop (a script thread) that may
be shared by same-site documents or isolated into its own
operating-system process and gaol sandbox. Within a script thread,
Rust's hardest problem in this codebase β making SpiderMonkey's
garbage-collected DOM and Rust's borrow checker coexist β is solved
with a vocabulary of pointer types (Dom, DomRoot, LayoutDom,
Trusted) that each encode one legal access pattern, and with a
layout engine that runs inside the script thread, reading the DOM
through unsafe wrappers made sound by the fact that the single
script thread is, by construction, paused. The lesson this codebase
teaches better than any other: how to carve a legacy-shaped
gigasystem (the web platform) into communicating components with hard
boundaries, and what Rust's ownership model buys β and costs β when
data races are the historical bug class.
π§ Explain like I'm 10
An office building where each website gets a workroom, run by a
building manager who never does any of the tenants' work herself.
The building manager (the Constellation thread) assigns rooms
(Pipelines), connects intercoms (message channels), and when a room
misbehaves or catches fire, she seals that one room and reroutes β
the building stands (constellation.rs:778 run). Each room has one
worker (a script thread) who opens her mail one envelope at a time
(the task queue in handle_msgs), so nobody can edit her papers
while she thinks. When it's time to redecorate (a reflow), the worker
doesn't leave: a measurement crew (rayon style workers) floods in and
reads her papers directly β safe only because she stands still, and
every door is labeled with who may enter (the thread_state checks,
assert.rs:12). Their measurements become blueprints (display lists)
mailed to the projector room (Paint), which layers slides from every
room into one picture on a magic overhead projector (WebRender on the
GPU, painter.rs:997). Rooms in other buildings (content processes)
are reachable only by official city mail (ipc-channel), are locked in
a filing cabinet (the gaol sandbox, sandboxing.rs:190) β and because
every intercom can be rewired at runtime between in-building wiring
(crossbeam channels) and city mail (generic_channel/mod.rs:42
use_ipc), nobody in the building even notices which kind is
installed.
Why the grown-ups did it this way: a browser runs untrusted, concurrently-updating, garbage-collected worlds side by side, so correctness has to come from architecture β supervised message passing makes every boundary auditable, and Rust makes every boundary-crossing pointer a compile-time or debug-time event.
1. Architecture overview
flowchart TD
subgraph embedder["Embedder - any process"]
SHELL["servoshell / ffi capi<br/>(ports/servoshell)"]
subgraph libservo["Servo library - components/servo"]
SERVO["Servo + WebView<br/>(servo.rs:909 Servo::new)"]
PAINT["Paint + Painter<br/>(paint/paint.rs:121)<br/>WebRender instance per window"]
REFRESH["RefreshDriver<br/>(vsync or timer)"]
end
end
subgraph engine["Engine threads / processes"]
CONST["Constellation supervisor thread<br/>(constellation.rs:778 run)<br/>Pipelines + BrowsingContexts<br/>+ session history"]
subgraph content["Content process - optional"]
ST1["Script thread<br/>(script_thread.rs:483 create)<br/>DOM + SpiderMonkey"]
ST2["Script thread<br/>(same-site shares one loop)"]
L1["Layout - in-thread<br/>(layout_impl.rs:126 LayoutThread)<br/>stylo + rayon pool"]
L2["Layout - in-thread"]
ST1 --- L1
ST2 --- L2
end
NET["Resource threads<br/>(resource_thread.rs:88)<br/>tokio fetch, cookies, HSTS"]
end
SHELL --> SERVO
SERVO -->|"generic channels"| CONST
SERVO --> PAINT
CONST -->|"ScriptThreadMessage"| ST1
CONST -->|"ScriptThreadMessage"| ST2
ST1 & ST2 -->|"ReflowResult, display lists"| PAINT
ST1 & ST2 -->|"CoreResourceMsg::Fetch"| NET
NET -->|"response chunks"| ST1 & ST2
REFRESH -.->|"wake update_the_rendering"| ST1
PAINT -->|"frame ready"| SERVO
Diagram source β enable JavaScript to render the diagram.- The embedder owns the window; the engine owns the web. Modern
Servo is a library, not an application:
Servo::new(components/servo/servo.rs:909) wires up every internal thread and hands the embedder proxies βConstellationProxy,PaintProxy, event receivers β plus aWebViewper browsing surface (components/servo/webview.rs).ports/servoshellis just a windowing shell around it;ffi/capiexposes the same API to C. The embedder callsspin_event_loop(servo.rs) to pump delegate callbacks and presentation. This is the CEF shape, arrived at from the opposite direction: engine-first, shell-later. - The Constellation is the supervisor and nothing else. It owns
the maps of
Pipelines andBrowsingContexts, session history, and the policy that decides which script thread a new page goes to β but zero page content. Its loop (constellation.rs:778 run) doeshandle_request(route one message), reap finished event loops, and β under a debug flag β randomly kill pipelines to harden itself (maybe_close_random_pipeline, constellation.rs:6061, an in-tree chaos test of the supervisor). - A Pipeline is a view, deliberately starved. "A
Pipelineis the constellation's view of aWindow" (pipeline.rs:21) β id, event-loop handle, URL, child iframe contexts, animation state: exactly what a supervisor needs to route and reap, and no DOM pointer at all. TheEventLoop(event_loop.rs:31) is the constellation's handle to one script thread: aGenericSenderofScriptThreadMessages whoseDropis the shutdown message (event_loop.rs:55sendsExitScriptThreadwhen dropped) β the ownership graph implements the lifecycle protocol. - Script threads are per site-instance, not per page. A new
pipeline first tries to join the event loop of its registered
domain (eTLD+1) within the same WebView;
about:blankmust join its creator's loop; sandboxed origins never join anything (get_event_loop_for_new_pipeline, constellation.rs:~963 β the same-site rule, in code). Otherwise the constellation spawns a new script thread (event_loop.rs:159 spawn_in_process) β or, with--multiprocess, re-executes the binary as a sandboxed child and passes the boot payload over a one-shot IPC token (sandboxing.rs:190 spawn_multiprocess). - Layout is not a thread anymore. The Layout 2020 project moved
layout from its own message-passed thread into the script thread
itself: each
Windowowns aBox<dyn Layout>(components/shared/layout/lib.rs:293) created through the injectedLayoutFactorywhen the window is born (script_thread.rs:3502). A reflow is a synchronous call,self.layout.borrow_mut().reflow(...)insideWindow::reflow(window.rs:2659), and parallelism comes from a shared rayon pool (STYLE_THREAD_POOL, layout_impl.rs:107) that work-steals style and box-tree construction across all script threads in the process. - The spine is one navigation to first paint: embedder β
constellation (
load_urlβnew_pipelineβ event loop) βScriptThreadMessage::SpawnPipelineβ fetch on the resource thread (tokio) βParserContextβ html5ever builds the DOM β firstupdate_the_renderingβWindow::reflowβ restyle traversal β damage-driven box/fragment/stacking-context trees β display list βPaintβ WebRender transaction β frame. The Walkthrough traces exactly this.
Walkthrough: one navigation, from URL to first paint
sequenceDiagram
autonumber
participant E as Embedder
participant C as Constellation
participant S as ScriptThread
participant N as Resource threads (tokio)
participant L as LayoutThread (in-thread)
participant P as Paint + WebRender
E->>C: load_url (EmbedderToConstellationMessage)
C->>C: new_pipeline + pick EventLoop
C->>S: SpawnPipeline(NewPipelineInfo)
S->>N: NavigationListener fetch (CoreResourceMsg::Fetch)
N-->>S: response chunks (ParserContext)
S->>S: load_document: Window + Document + ServoParser
S->>S: html5ever TreeSink builds DOM
S->>S: update_the_rendering
S->>L: Window::reflow (synchronous)
L->>L: traverse_dom restyle (rayon)
L->>L: damage, box/fragment/stacking trees
L->>P: send_display_list (generic channel)
P->>P: WebRender transaction, generate_frame
P-->>E: frame ready, present
Diagram source β enable JavaScript to render the diagram.The embedder asks, the constellation plans
A navigation
request arrives as EmbedderToConstellationMessage
(constellation.rs:1237 handle_request_from_embedder) and lands in
load_url (constellation.rs:4106): the constellation walks session
history, builds LoadData, and calls new_pipeline
(constellation.rs:955). Two decisions happen here that shape
everything after: which EventLoop the pipeline joins β same
registered domain in the same WebView reuses the existing script
thread, about:blank/about:srcdoc join their creator, sandboxed
origins get a fresh one (get_or_create_event_loop_for_new_pipeline,
constellation.rs:921) β and whether that event loop needs to be
spawned now, in-thread or in a new process.
A pipeline is born as one message
Pipeline::spawn
(pipeline.rs:91) does not construct a document; it sends
ScriptThreadMessage::SpawnPipeline(NewPipelineInfo) into the chosen
event loop and immediately records a skeletal Pipeline in the
constellation's map. If the event loop is new, EventLoop::spawn
(event_loop.rs:66) either starts a thread
(spawn_in_threadβScriptThreadFactory::create,
script_thread.rs:483, an 8 MiB stack "to be consistent with other
browsers") or, in multiprocess mode, re-execs the binary with
--content-process <token> and hands over an
UnprivilegedContent payload through an IpcOneShotServer handshake
(sandboxing.rs:190) β the child then installs a gaol sandbox profile
that allows little more than font files and /dev/urandom
(content_process_sandbox_profile, sandboxing.rs:52).
The script thread goes shopping
The script thread's
event loop (handle_msgs, script_thread.rs:1373) receives
SpawnPipeline and calls pre_page_load (script_thread.rs:4068):
it parks a ParserContext (servoparser/mod.rs:953) in
incomplete_parser_contexts and starts the fetch itself via
NavigationListener::initiate_fetch toward the resource threads.
There, CoreResourceMsg::Fetch (resource_thread.rs:409) runs the
Fetch spec as a Rust async fn on a tokio runtime
(fetch/methods.rs:180 fetch β :401 main_fetch) β redirects,
CORS, HSTS, cookies all live on that side of the channel.
Bytes come home and a document appears
Response chunks
flow back and land in ParserContext::process_response_chunk
(servoparser/mod.rs:1599), which first buffers 1445 bytes to sniff
the MIME type/encoding (the mimesniff resource-header rule), then
calls load_document: here the Window and Document DOM objects
are created, with the injected layout engine attached to the window
(layout_factory.create(layout_config), script_thread.rs:3502), and
a ServoParser (servoparser/mod.rs:128) is born holding two input
streams β script input (for document.write) and network input β
around one html5ever tokenizer.
html5ever builds the tree, script suspends it
The parser module's Sink implements html5ever's TreeSink trait
(servoparser/mod.rs:1819), so the spec-exact tokenizer/tree-builder
drives DOM mutations directly: append, create_element,
set_quirks_mode. A classic
<script> suspends the parser (suspended cell) until execution
finishes; the async tokenizer variant (async_html.rs) instead runs
html5ever on a dedicated parser thread against a lightweight
ParseNode tree and streams ParseOperations back β the DOM
mutator and the spec state machine are decoupled by a message log,
so parsing can overlap script-thread work. Either way, when the
document finishes loading, process_response_eof
(servoparser/mod.rs:1629) runs a final synchronous parse and fires
load events.
The first rendering opportunity
Animation-frame
scheduling or a script-triggered reflow need brings the script
thread to update_the_rendering (script_thread.rs:1094), which
walks its documents in tree order and calls Window::reflow
(window.rs:2659). Reflow first drains RestyleReasons β the
document's dirty_root, pending restyles, changed stylesheets β
packs a ReflowRequest carrying a TrustedNodeAddress (a raw DOM
pointer plus a promise that script stays still), and makes the
synchronous call self.layout.borrow_mut().reflow(reflow).
Style and layout as parallel visitors on a paused DOM
LayoutThread::handle_reflow (layout_impl.rs:1060) runs the stylo
cascade as a parallel top-down traversal β
driver::traverse_dom(&recalc_style_traversal, token, rayon_pool)
(layout_impl.rs:1340) β where the RecalcStyle traversal
(traversal.rs:21) walks the DOM through ServoLayoutNode wrappers.
Computed damage (RestyleDamage, LayoutDamage) then decides how
much of the cached BoxTree/FragmentTree/StackingContextTree
must be rebuilt: if nothing needs it, layout phases are skipped
entirely (can_skip_reflow_request_entirely). When a display list
is due, build_display_list (layout_impl.rs:1545) walks the
stacking-context tree and hands a WebRender BuiltDisplayList to
paint_api.send_display_list (components/shared/paint/lib.rs:330).
Pixels, and a supervisable system
Paint receives
the display list on the embedder's side
(Painter::handle_new_display_list, painter.rs:997), installs the
new scroll tree, wraps the list in a WebRender Transaction, and
generate_frame (painter.rs:603) asks the GPU-side scene builder
for a frame; the notifier wakes the embedder to present. Every
component on the trip β constellation, script thread, resource
thread, paint β held only channels to its collaborators, so any of
them can crash, be killed (or be chaos-killed) without the others
losing more than the messages still in flight.
2. Core data structures β and why these
Pipeline+EventLoopβ supervision as starved records. What: pipeline.rs:26 and event_loop.rs:31 β the pipeline record carries ids, the event-loop handle, URL, iframe children, animation state; theEventLoopcarries oneGenericSenderand aDropimpl that sendsExitScriptThread. Why: the supervisor deliberately sees less than the supervised: no DOM pointers, no layout state, nothing to corrupt or leak. Lifecycle is encoded in ownership (drop = exit message), so the constellation cannot leak a thread even by forgetting cleanup code β compare Kubernetes's informers, where the controller's view is also a projection rather than the truth.The DOM pointer zoo β one type per legal access. What:
Dom<T>(root.rs:153) β a bareNonNull<T>allowed only as a field of another DOM object, traced by the GC;DomRoot<T> = Root<Dom<T>>(root.rs:297) β a stack root registered in the thread-localSTACK_ROOTS(root.rs:440) that the SpiderMonkey GC scans;LayoutDom<'dom, T>(script/dom/bindings/root.rs:50) β a lifetime-scoped, debug-asserted "layout is on the DOM now" view;Trusted<T>(refcounted.rs:66) β anArc-counted pin for another thread, backed by theLIVE_DOM_REFERENCESweak-table the GC treats as roots (refcounted.rs:34). Why: GC and borrow checker disagree about object lifetimes; rather than one unsafe escape hatch, Servo names each access pattern as a type, and each dereference debug-asserts its thread state (assert_in_script/assert_in_layout, assert.rs:12). The#[dom_struct]/must_rootlint makes unrooted field pointers a compile error β the shape CPython's C API could not retrofit.Nodeβ a GC-traced doubly-linked tree with a layout side-slot. What: node/node.rs:127 β parent/first/last/next/prev asMutNullableDom<Node>;rare_data: Option<Box<NodeRareData>>so plain nodes pay nothing for rare fields; aninclusive_descendants_version: Cell<u64>bumped on mutation; andlayout_data: DomRefCell<Option<Box<GenericLayoutData>>>β the hole where the style/layout engines hang their per-node state on the DOM itself. Why: sibling links (not child vectors) make tree mutation O(1) with no allocator traffic; the version counter and dirty-descendant flags are what incremental restyle keys off; and the layout side-table inverts the usual "layout owns its parallel tree" model (the pre-2020 design) into "layout decorates the DOM" β one tree, two owners separated by thread state.GenericSender/GenericReceiverβ channels whose backend is a runtime choice. What: components/shared/base/generic_channel/ mod.rs β enum senders/receivers wrapping either crossbeam (same process) or ipc-channel (cross process), selected once per process byuse_ipc()(:42) from the--multiprocess/--force-ipcflags; plusGenericSharedMemoryfor bulk payloads. Why: it is the load-bearing seam of the whole engine: every subsystem (PaintProxy,ConstellationProxy,CoreResourceThread,ScriptThreadMessage) speaks through it, so the threaded and process-isolated builds are the same code β and the display list crosses as shared memory in one mode and (nearly) free crossbeam messages in the other. The cost is honesty in the type system: everything sent must beSerialize + Deserialize, which is whyReflowRequestcarriesTrustedNodeAddress(a raw pointer!) rather than nodes β and why that is only sound in the same-process mode.The cached layout trees + damage bits. What:
BoxTree,FragmentTree,StackingContextTreeonLayoutThread(layout_impl.rs:126), kept between reflows, withRestyleDamage/LayoutDamage/need_new_display_listcells deciding which phases run this time (handle_reflow's phase list, layout_impl.rs:1060). Why: a browser reflows 60 times a second but almost nothing changes between frames β the entire rendering pipeline is a skip-if- undamaged cache, the same bet as PostgreSQL's MVCC or Kafka's index: pay to detect change, not to redo work.
3. Concurrency and failure model
flowchart LR
subgraph process["One process"]
C["Constellation thread"]
P["Paint on embedder thread<br/>+ WebRender threads"]
R["Resource threads<br/>(tokio)"]
subgraph loops["Script threads - one per site instance"]
S1["ScriptThread A<br/>DOM A - layout A"]
S2["ScriptThread B<br/>DOM B - layout B"]
end
RP["shared rayon pool<br/>(STYLE_THREAD_POOL)"]
end
C ---|"generic channels"| S1 & S2 & P
S1 & S2 --- R
S1 & S2 -.->|"work stealing"| RP
P ---|"hit test results"| C
Diagram source β enable JavaScript to render the diagram.- What runs concurrently: the constellation thread, paint on the
embedder's thread, resource threads on tokio, one script thread
per site-instance (possibly in a sandboxed child process), one
WebRender scene/frame builder pair per window, and β during
reflow β the shared rayon pool fanning style/box-tree work out
across cores. What is serialized: everything within one
script thread: JS execution, DOM mutation, parser, timers, and
layout/reflow all take turns on that single thread's event loop
(
handle_msgs, script_thread.rs:1373), which is precisely why layout'sunsafeDOM reads are sound. A hung page hangs its document, never another site's β the concurrency unit is the site, not the tab. - The deadlock-avoidance rule is written down: "
Paintand all of its contained structs should never block on the Constellation, because sometimes the Constellation blocks on us" (paint.rs:119). Message graphs need cycle rules; this is Servo's, and it is documented at the one place a violator would read. - Crash containment is the architecture. A panicking or
poisoned script thread surfaces as a channel send error in the
constellation β
handle_send_error(constellation.rs:2922) closes the pipeline, notifies the embedder, and keeps serving the rest. The constellation itself is tested against random pipeline death (maybe_close_random_pipeline, constellation.rs:6061 β an in-tree chaos monkey behind--random-pipeline-closure-probability). A script thread that can no longer be talked to is detected by the background hang monitor (theclosingflag checked from SpiderMonkey's interrupt callback, script_thread.rs:860) β slow pages get flagged, wedged threads get abandoned. In multiprocess mode the OS reaps the whole child; the supervisor loses nothing it cannot recreate fromLoadData. - Backpressure and throttling are supervisor policy: hidden or
inactive pipelines are throttled β timers slowed, updates coalesced
β by
Pipeline::send_throttle_messages(pipeline.rs:~223) and the "never unthrottle a pipeline without a document" rule; the paint side coalesces frames through theRefreshDriver(refresh_driver.rs:23), which the embedder can drive from real vsync.
4. API and extension design
- The public surface is an embedding API, not a browser.
ServoBuilderβServoβWebView+ delegates (ServoDelegate,WebViewDelegatein components/servo/) is the whole product surface: hosts supply a window, GL context, clipboard, permission prompts, and protocol handlers (ProtocolRegistry), and receive navigation events, input routing, and frames.ports/servoshellproves the API by being a complete browser in a few thousand lines;ffi/capiproves it again in C. Compare VS Code: the engine became a platform by growing an embedder seam before it needed one. - The seams are factories on crate boundaries. The script crate
never depends on the layout or image-cache implementations:
InitialScriptStatecarriesArc<dyn LayoutFactory>/Arc<dyn ImageCacheFactory>(shared/layout/lib.rs:289) from the embedding layer down. Dependency arrows point up into the engine, so component crates stay independently testable and the engine can be re-hosted with different engines at each slot β the same inversion runc uses for its factories. - The web API is generated from 576 WebIDL files.
components/script_bindings/webidls/*.webidlare parsed by a Python codegen forked from SpiderMonkey's (script_bindings/codegen/run.py, driven from build.rs) into Rust binding modules,FooMethodstraits, and JS glue; the DOM side is written once, by hand, as#[dom_struct]structs. Adding a new interface is an IDL file plus one struct β the Redis commands.json pattern at web scale, with the same payoff: docs, types, and dispatch cannot drift apart. - Honest warts. The unsafe DOM access from layout is guarded by
debug_assert!only β release builds trust the discipline, so a missed thread-state bug is UB, not an error. The IPC boundary forces every message to be serde-serializable, which the display list pays on every frame in multiprocess mode. And the bindings codegen being Python meanscargo buildsilently shells out to a whole second toolchain β the price of inheriting SpiderMonkey's generator instead of rewriting it (a trade VS Code never had to make, and one CPython also accepted with its generated cases tables).
5. The defining trade-offs
Trade-off 1: message passing over shared state, everywhere
- Tension: a browser is many worlds β untrusted pages, network, GPU, embedder β that must not be able to corrupt each other, but that also must cooperate at 60 fps.
- Choice: subsystem state is private; the only road between
components is a typed message channel (
GenericSender), with the backend β crossbeam or ipc-channel β chosen at runtime (generic_channel/mod.rs:42use_ipc). Even the windowβlayout boundary, once a channel to a layout thread, is now a synchronous call β the one place the design let data stay shared. - Evidence:
Pipeline::spawn(pipeline.rs:91) β creating a page is sendingSpawnPipeline;send_display_list(shared/paint/lib.rs:330) β a frame is a message with shared memory inside;CoreResourceMsg::Fetch(resource_thread.rs:409). The bill:ReflowRequestcannot carry DOM nodes across IPC, so it carriesTrustedNodeAddressand works only in-process β the message design quietly assumes which mode it is in. - Given up: zero-copy sharing between engine parts in the multiprocess mode (every display list serializes), one extra hop of latency per subsystem boundary, and stack traces that end at channels.
- Verdict: right when components are untrusted or crashable β the whole point of a browser. Wrong for a single-tenant embedded library chasing maximum throughput β which is exactly why the same code keeps the threaded mode: one engine, two deployment shapes, one message vocabulary.
Trade-off 2: unsafe DOM sharing for layout, guarded by thread identity
- Tension: layout needs parallel reads of the whole DOM, but the DOM is a garbage-collected SpiderMonkey graph that Rust cannot borrow across threads; copying it per reflow would be ruinous.
- Choice: layout runs on the script thread's time: the script
thread pauses (it made the synchronous call), layout wraps DOM
nodes in
unsafeSend/Sync handles (layout_dom/servo_layout_node.rs:52β63), and fan-out happens via the shared rayon pool while the one thread that could mutate is standing still. Every access debug-asserts itsthread_state(assert.rs:12); mutation of DOM nodes from layout is forbidden by construction (onlylayout_dataside-slots are written). - Evidence:
Window::reflow(window.rs:2659) β the synchronousself.layout.borrow_mut().reflow(reflow)(window.rs:2792) that makes the pause true;driver::traverse_dom(..., rayon_pool)(layout_impl.rs:1340) β the parallel read;GenericLayoutDatainNode::layout_data(node/node.rs:164) β the sanctioned write path for layout results. The soundness argument is architectural: the HTML event loop guarantees no interleaved mutation during the call. - Given up: layout cannot overlap script execution on the same document (no concurrent reflow-during-JS), correctness in release builds rests on a discipline enforced only in debug builds, and the pre-2020 message-passing layout thread's crash isolation was traded away.
- Verdict: right when mutation is single-threaded by spec β which the HTML event loop guarantees β and the data is huge relative to traversal cost. Wrong the moment you need concurrent writers: then pay for a copy or a message log, as the async HTML tokenizer did (async_html.rs).
Trade-off 3: one engine, two process topologies, runtime-switchable
- Tension: an embeddable engine wants cheap in-process threading; a web-facing product needs OS-level site isolation and sandboxing; maintaining two engines would fork every subsystem.
- Choice: the
GenericChannelenum makes topology a runtime flag;spawn_multiprocess(sandboxing.rs:190) re-executes the engine binary with a one-shot token payload and optionally installs a gaol sandbox (content_process_sandbox_profile, sandboxing.rs:52) so untrusted content processes can read fonts and little else; theProcessManager(process_manager.rs:40) registers child processes into the constellation's select loop so a dead child is just another closed channel. - Evidence:
use_ipc()(generic_channel/mod.rs:42) β oneOnceLockdecides the whole engine's channel mode; the multiprocess-only guards inServo::new(servo.rs:948 β single JS-engine init happens only in single-process mode);UnprivilegedContent(sandboxing.rs:31) β the entire child boot state as one serializable enum. The bill is paid everywhere a message must beSerialize + Deserializeeven when it will ride a crossbeam channel. - Given up: the ability to hand a pointer between components ever (everything is designed for the hostile mode even when running friendly), and a class of bugs that only exist in one mode (IPC failures, routing through the ipc-channel router thread).
- Verdict: right when the same product must ship as an embedded
library and a hardened browser β one codebase, one set of
message types, two trust domains. Wrong when one topology is
truly impossible: then the abstraction is dead weight, as the
single-process-only
TrustedNodeAddressinReflowRequestalready hints.
6. Reading path
docs/ORGANIZATION.mdand the repo root β Servo's architecture lives in the crate graph itself; thenCargo.toml's[workspace.dependencies]for the satellite projects (stylo, html5ever, webrender, ipc-channel, gaol are separate repos).components/servo/servo.rs:909 Servo::newβ the wiring diagram of the whole engine as one function; note which pieces are created here (paint, resource threads, constellation) and which are deferred to pipelines. Thencreate_constellationat :1233 for what the supervisor is handed at birth.components/constellation/constellation.rs:778 runβ the supervisor loop; readmaybe_close_random_pipeline(:6061) next to believe the crash-containment story, thenhandle_request(:1135) just to see how many message families one thread routes.components/constellation/pipeline.rs:26andevent_loop.rs:31β the two records the constellation supervises with; do not missDrop for EventLoop(:55): shutdown as ownership.constellation.rs:921 get_or_create_event_loop_for_new_pipelineand theget_event_loop_for_new_pipelinejust above it (:875) β the site-instance policy in ~60 lines: same-site reuse, about:blank inheritance, sandbox isolation.components/constellation/sandboxing.rs:190 spawn_multiprocessβ re-exec, one-shot token, gaol; thenprocess_manager.rs:40to see how children plug into the supervisor's select loop.components/script/event_loop/script_thread.rs:483 createβ how a script thread is born (8 MiB stack, thread-state init, BHM), then:1037 startβ:1373 handle_msgsβ the HTML event loop as a channel multiplexer; note the task queue and the interrupt callback that honors the hang monitor'sclosingflag.components/script/dom/mod.rsβ the finest internal documentation in the codebase: the DOM-object/reflector split, inheritance-by-first-field, construction rules. Thencomponents/script_bindings/root.rs:153(Dom),:297(DomRoot),:440(STACK_ROOTS) andrefcounted.rs:66(Trusted) β the pointer zoo, one type per legal access.components/script/dom/node/node.rs:127 Nodeβ the linked-tree DOM; findlayout_data(:164),inclusive_descendants_version, andrare_data, and ask of each field who writes it and when.components/script/layout_dom/servo_layout_node.rs:52β the unsafe bridge layout crosses; read theunsafe impl Send/Syncat :62 together withcomponents/script_bindings/assert.rsand convince yourself the pause argument holds (it is the same argument Trade-off 2 prices).components/script/dom/window/window.rs:2659 reflowβ the script-side restyle invalidation harvest, then the synchronous hand-in to layout; cross tocomponents/layout/layout_impl.rs: 1060 handle_reflowβ:1255(rayon pool) β:1340 traverse_domβ:1545 build_display_listβ one reflow, phase by phase, with the damage flags gating each.components/script/dom/servoparser/mod.rs:128 ServoParser(and its doc comment on the two input streams) with a skim ofasync_html.rs'sParseOperationlog β two designs of the same spec algorithm, chosen by a preference flag (dom_servoparser_async_html_tokenizer_enabled, config/prefs.rs:458).components/paint/paint.rs:121β thePaintdoc comment (including the never-block rule), thenpainter.rs:997 handle_new_display_listandrefresh_driver.rs:23β where display lists become WebRender transactions and vsync becomesupdate_the_renderingpressure.components/shared/base/generic_channel/mod.rs:42 use_ipcβ the seam that makes everything above runnable as threads or processes; end here, because this is the file that explains why every other file could ignore the question.
You have now traced one navigation from embedder click to first presented frame β through a supervisor that owns nothing, a script thread that owns everything for one site, a layout engine that borrows it all for one call, and channels that did not care whether their peers were threads or processes.
7. What to steal
- Supervise by projection, not by participation β give the
supervisor records that contain handles and ids but no domain
data (
Pipeline, pipeline.rs:26), and it can never be corrupted by what it supervises; kill/restart stays O(1). Seen in the constellation's maps versus the script thread's DOM. - Make channel backend a runtime choice β wrap crossbeam and ipc-channel behind one enum type (generic_channel/mod.rs), and "threaded or multiprocess" stops being two architectures; you pay only serde bounds on messages.
- Encode lifecycle in ownership β
Drop for EventLoopsends the exit message (event_loop.rs:55), so forgetting to clean up is impossible by construction; the ownership graph is the shutdown protocol. - Name each legal access pattern as a type β
Dom(heap field, traced),DomRoot(stack, rooted),LayoutDom(borrowed window during reflow),Trusted(cross-thread pin counted as a GC root): one unsafe mechanism, four vocabularies, each debug-asserted againstthread_state(assert.rs). This is how to marry a GC runtime to Rust without lying to either. - Gate every pipeline phase on damage bits β reflow runs only
the phases whose cached trees were invalidated
(
can_skip_reflow_request_entirely, layout_impl.rs:1060): at 60 fps, the fast path is the product, and correctness of skip logic is the real engineering. - Chaos-test your supervisor β
maybe_close_random_pipeline(constellation.rs:6061) randomly kills pipelines under a debug flag, exactly like Netflix's Chaos Monkey for processes: the crash-containment story is executable, not aspirational.
8. Difficulty and prerequisites
- Difficulty:
demanding. Realistic budget: 12β16 hours for this path. The codebase is enormous (~40 crates), the Rust is idiomatic but leans hard onunsafein exactly the places this dive stops, and the HTML/CSS/JS platform concepts (event loop processing model, the cascade, quirks mode) are assumed knowledge, not taught here. - Prerequisites: solid Rust β lifetimes,
Send/Sync,unsafe, interior mutability (Cell/RefCell); how a tracing GC finds roots (the CPython dive covers the same problem in a smaller system); the shape of the HTML spec's event loop and tree construction (skim WHATWG Β§8 "the HTML syntax" if needed); roughly what WebIDL is. The libuv dive is good contrast for a single-loop world; Kubernetes for the other end β supervision with reconcile loops instead of message graphs.
9. Further reading
- The Servo Book's design documentation (book.servo.org/design-documentation/) β the crate-by-crate map this dive's section 1 walks, upstream's own words.
- In the clone:
docs/ORGANIZATION.md,docs/glossary.md, the header documentation ofcomponents/script/dom/mod.rs(the DOM-ownership document), and the ASCII diagram incomponents/script/dom/servoparser/async_html.rsexplaining the parser-thread protocol. - The satellite repositories, each a lesson on its own: html5ever (spec-exact parsing without a parser generator), stylo (the CSS engine Servo built and Firefox adopted as Quantum CSS), WebRender (the GPU display list model this dive treats as a black box), ipc-channel and gaol.
- Lin Clark, Inside a super fast CSS engine: Quantum CSS (Stylo) (Mozilla Hacks, 2017) β the story of stylo's migration into Firefox, i.e. this codebase's style system running in ~500M browsers.
- The WHATWG HTML Standard's "Event loops" and "Parsing HTML
documents" sections β the specifications
handle_msgs,update_the_rendering, andServoParserimplement line by line, with spec quotes pasted at the implementation sites.