G01 — Chromium: Build, Debug, Trace, Test

Operational reference. Verified against upstream 2026-08-10 — re-verify anything here older than ~6 months and update the Verification Log in PROGRESS.md.

Spec areas §3 (build), §20 (debugging), §21 (tracing), §22 (tests).

Rule for this file: it records procedures and the reasoning behind them, not a frozen command list. When a command here fails, the fix is to consult docs/ in your own checkout — which is the authoritative, versioned copy — and then update this file. In-tree docs beat any external source including this one.


1. Your specific machine

Apple M2 Pro · 12 cores · 32 GB RAM · ~146 GB free at start.

This is a comfortable build machine on CPU and RAM, and a tight one on disk. Budget:

ItemSizeWhere
src + .git (full history)~80–100 GB~/chromium/src
git cache mirror~24 GB~/Library/Caches/depot_tools/git_cachemeasured; a surprise
One out/ dir, component + symbol_level=0~15–25 GB~/chromium/src/out/
One out/ dir with usable symbols~40–80 GB~/chromium/src/out/

The git-cache trap (measured on this machine, 2026-08-10)

fetch --git-cache is the fast-checkout path upstream recommends, and it works — but it keeps a full bare mirror outside the checkout, in addition to the working checkout's own .git. Peak storage is therefore roughly double what the checkout size suggests.

Measured after fetch completed here:

~/chromium/src                              26 GB
~/Library/Caches/depot_tools/git_cache      24 GB   ← the mirror
free space                                  146 GB → 90 GB

The mirror location is set by cache_dir in ~/chromium/.gclient — read that file rather than guessing; it is not ~/.cache, and conflating the two will send you deleting the wrong thing.

What to do about it:

  • The mirror is a cache, not a dependency. Once src is synced it can be deleted; the cost is that a future fetch of a second checkout loses its speedup, and the next gclient sync re-fetches more over the network. With one checkout on a disk-constrained machine, deleting it is usually right.
  • If you keep it, budget for it from the start rather than discovering it at 90 % full mid-build.
  • Check with du -sh "$(grep cache_dir ~/chromium/.gclient | cut -d'"' -f2)".

Remaining budget here: 90 GB free, ~20 GB needed for a component build with modest symbols. Comfortable, but not comfortable enough for two symbol-rich output directories.


2. Build configuration

cd ~/chromium/src
gn gen out/Default
gn args out/Default        # opens an editor
is_debug = false                    # release-mode codegen: much faster builds, usable perf
is_component_build = true           # many small dylibs → fast incremental links. Essential.
symbol_level = 1                    # function names + line numbers, no full type info
blink_symbol_level = 2              # full symbols for Blink only; cheap, since it's a subset
dcheck_always_on = true             # ← the one most people omit, and the one you want most

# --- toolchain workaround for this machine; see §2.1. Remove once Xcode is 26+ ---
use_clang_modules = false
use_unified_system_module = false

Verified working on this checkout (gn gen → 32,319 targets, 2026-08-10).

Why each, because copying build flags without understanding them is how you end up unable to debug at the moment you need to:

  • is_component_build = true is the single largest iteration-time win. A static build relinks a ~1 GB binary on every change; a component build relinks one small shared library. Non-negotiable for a learning checkout.
  • symbol_level: 0 is fastest but stack traces become useless addresses. 1 gives you function names and line numbers — enough for almost all of this track. 2 gives full type info for variable inspection, and costs a lot of disk. blink_symbol_level = 2 is the compromise: full fidelity exactly where you'll be setting breakpoints.
  • dcheck_always_on = true keeps DCHECKs in a release build. This is the highest-value flag for learning: DCHECKs are Blink's invariants written as executable assertions (bi-01 Technique 7), and having them fire when you break something turns a confusing misrender into a precise message naming the invariant you violated. Enable it and leave it on.
  • is_debug = true is deliberately not recommended as your default. It is far slower to build and to run, and its main benefit (assertions) you already have from dcheck_always_on. Reach for it only when you need libc++ debug iterators or full unoptimised stepping.

2.0 WARNING - BLOCKER: trunk cannot compile on this machine (2026-08-10)

Status: checkout works, compilation does not. Read this before spending time on 2.1.

This machine:   macOS 15.0 (Sequoia) - Xcode 16.2 - SDK 15.2
Trunk requires: macOS 26.2+ (Tahoe)  - Xcode 26.5+ - SDK 26.5

git log dates the requirement exactly:

2026-05-13  mac: Switch to Xcode 26.5 17F42 (2026-05-11) and SDK 26.5 25F70

Trunk has required Xcode 26.5 for over a year. The failure is not configuration - it is a missing SDK symbol:

// base/process/launch_mac.cc
if (__builtin_available(macOS 26, *)) {
  DPSXCHECK(posix_spawn_file_actions_addchdir(&file_actions_, path));     // SDK 26 only
} else {
  DPSXCHECK(posix_spawn_file_actions_addchdir_np(&file_actions_, path));  // SDK 15 has this
}

__builtin_available is a runtime check, but the symbol must still exist at compile time. SDK 15.2's spawn.h declares only the _np variant. This pattern recurs across the tree, so it is not patchable in any sane way - it is a genuine toolchain-floor problem.

The gn workarounds in 2.1 get gn gen to succeed. They do not and cannot fix this.

What this does not block

The checkout is not wasted. Everything except compilation works right now:

  • git grep -n over 30M lines - faster than Code Search, and it sees generated inputs
  • git log -S'...' archaeology (section 25) - the most valuable local capability
  • reading docs/, DEPS, OWNERS, .json5, .idl, .mojom in-tree
  • everything in bi-01, bi-02, bi-03, bi-04, bi-07 and Labs 01-02

Phases 0-2 require no build at all. A local build is first genuinely needed at Phase 3-4 (section 20, native debugging) and again at Phase 6 (sections 22/23 tests, 24 contribution). On the phase plan that is roughly two months of runway.

Your options, ranked

OptionCostConsequence
AUpgrade macOS to 26.2+, then Xcode to 26.5+multi-hour OS upgrade + ~15 GB Xcode; reboot; some risk to existing toolchains (homebrew, anaconda, rust)Recommended. Trunk builds; local source matches Code Search; contribution path stays open.
BCheck out a revision from before 2026-05-13 and gclient syncanother long sync; ~15 months of source driftBuilds today, but local source no longer matches Code Search or these modules - actively confusing while learning, and it forecloses section 24 contribution.
CDefer the build; work from the checkout + stock ChromenoneZero risk, no loss before Phase 3.

Recommendation: C now, A before Phase 3. There is no reason to take an OS upgrade during Phase 0, and no reason to arrive at Phase 3 without one. Option B is a genuine fallback only if you decide against upgrading at all - the staleness cost is real and it ends the contribution track, which is the stated target of section 24.

Do not delete out/Default or the checkout in the meantime; both are reusable the moment the toolchain is current.

2.1 Case study: trunk required a toolchain this machine didn't have

This happened on the first gn gen here, and it is worth reading in full because the diagnostic path is the transferable part — the specific flags will be obsolete within a year.

Symptom. gn gen failed with three ERROR Input to targets not generated by a dependency errors naming files that do not exist:

//out/Default/sdk/xcode_links/MacOSX15.2.sdk/usr/include/DarwinFoundation1.modulemap
                                                          DarwinFoundation2.modulemap
                                                          DarwinFoundation3.modulemap

Diagnosis, step by step.

  1. The missing files are in the SDK, not in Chromium. So this is a toolchain-version problem, not a checkout problem. ls "$(xcrun --show-sdk-path)/usr/include" | grep modulemap showed DarwinFoundation.modulemap but no numbered variants — they exist only in newer SDKs.
  2. xcodebuild -version → Xcode 16.2, SDK 15.2. grep mac_sdk_official_version build/config/mac/mac_sdk.gni26.5. Chromium trunk had moved to the macOS 26 SDK.
  3. Who wants those files? grep -rn DarwinFoundation1 build/ buildtools/buildtools/third_party/libc++/modules.gni, inside if (use_clang_modules).
  4. Why is that path taken at all? build/config/c++/modules.gni: use_autogenerated_modules = !(is_apple && use_system_xcode) — on macOS with a system Xcode this is false, which selects the manual modulemap path that hardcodes the numbered files. The autogenerated path asserts xcode_version_int >= 2600 anyway, so both branches require Xcode 26.
  5. Is there an escape? grep -n "use_clang_modules =" build/config/c++/c++.gni showed it inside a declare_args() block → it is a settable gn arg, not a computed constant. That single observation is the whole fix.

Fix. use_clang_modules = false reduced the failure from four toolchain variants to one remaining target (//build/modules:system_modulemap), which build/modules/BUILD.gn gates on a second arg, use_unified_system_module. Setting both to false generated cleanly.

Cost of the workaround. Clang header modules are a compile-time optimisation for libc++ headers. Disabling them means somewhat slower compiles and no -fmodules-strict-decluse include hygiene checking. Nothing about Blink's behaviour changes, so it is a sound trade for a learning checkout. Revisit it if you upgrade Xcode.

The transferable lessons, which matter more than the flags:

  • A gn gen error naming a nonexistent SDK file is a toolchain-version problem. Check mac_sdk_official_version against xcrun --show-sdk-version before anything else.
  • Chromium trunk tracks the newest toolchain aggressively. Being one Xcode major behind is enough to break the build. Expect this again.
  • Before concluding "I must upgrade," check whether the offending behaviour sits behind a declare_args() value. grep -n "<name> =" build/config/**/*.gni and look for the enclosing declare_args() block. A surprising amount of Chromium's build is switchable.
  • When it breaks again after a gclient sync, re-run exactly this procedure. It will; a workaround pinned to a toolchain gap has a shelf life.

If you do upgrade Xcode later, delete both workaround lines and re-run gn gen — keeping dead workarounds is how build configs become unexplainable.

Verified target names (2026-08-10)

//content/shell:content_shell
//:blink_tests
//third_party/blink/renderer/controller:blink_unittests

Do not trust this list — including here. Regenerate it with gn ls out/Default | grep -E ':(content_shell|blink_tests)$'.

Inspect what you actually got:

gn args out/Default --list --short          # every arg and its current value
gn args out/Default --list=symbol_level     # docs for one arg, from the build files

Building

autoninja -C out/Default chrome           # the full browser
autoninja -C out/Default content_shell    # minimal embedder — prefer this
autoninja -C out/Default blink_tests       # content_shell + web test infrastructure

autoninja selects the correct underlying executor (ninja or siso) and the right parallelism. Use it rather than invoking ninja directly; the wrapper is where upstream encodes build-system migrations.

Prefer content_shell over chrome for everything in this track. It is a minimal embedder of //content — no bookmarks, no sync, no extensions, no UI. It builds far faster, starts far faster, and it is what the web tests run against. If your question is about Blink, chrome is 90 % irrelevant code.

The build system is searchable, and almost nobody learns this. These are the gn equivalents of bi-01's Code Search techniques:

gn ls out/Default                                  # every target
gn ls out/Default | grep -i blink                  # find the real target names — do this
                                                   # instead of trusting any doc's list
gn refs out/Default third_party/blink/renderer/core/html/parser/html_tree_builder.cc
                                                   # which targets contain this file?
gn desc out/Default //third_party/blink/renderer/core:core deps
gn path out/Default //chrome //third_party/blink/renderer/core:core
                                                   # why does A depend on B?

gn refs <file> answers "what do I have to rebuild to test this change," and gn path answers "why is this even linked in," which is a genuine architecture question.

When the build breaks after a sync

In order, cheapest first:

gclient sync -D            # sync deps, delete stale ones
gn clean out/Default       # clear generated files, keep the args
rm -rf out/Default && gn gen out/Default   # nuclear; costs a full rebuild

Most post-sync failures are stale generated files, so gn clean resolves them. Reach for the third option rarely — on this machine it is an hour you did not need to spend.


3. Running

out/Default/Content\ Shell.app/Contents/MacOS/Content\ Shell https://example.com
out/Default/Content\ Shell.app/Contents/MacOS/Content\ Shell --run-web-tests <test path>
out/Default/Chromium.app/Contents/MacOS/Chromium --user-data-dir=/tmp/cr-profile

Always pass --user-data-dir to a scratch directory when running a local build, so you never touch your real profile.

Flags that matter for this track:

FlagUse
--user-data-dir=<path>isolate profile — always
--renderer-startup-dialogpause each renderer at startup so you can attach
--disable-hang-monitorstop Chrome killing a renderer you're stopped in
--enable-blink-features=Footurn on a runtime-enabled feature by name
--disable-blink-features=Foo...and off, to A/B a behaviour
--enable-logging=stderr --v=1see LOG()/VLOG() output
--single-processone process — convenient, frequently broken, never trust it for behaviour
--no-sandboxlast resort for debugging; changes the security model, so never conclude anything about behaviour from a --no-sandbox run

The --enable-blink-features / --disable-blink-features pair is the fastest way to answer "is this behaviour behind a flag" — a question bi-01 Technique 4 says you should ask early and often.


4. Debugging with lldb

Setup

Chromium ships lldb helpers in-tree. Wire them in once:

# see docs/lldbinit.md in your checkout for the current recommended contents
echo "command script import ~/chromium/src/tools/lldb/lldbinit.py" >> ~/.lldbinit

Without this, WTF::String, std::u16string and friends print as raw pointers and you will waste time. Check docs/lldbinit.md in your own checkout for the current form — this is exactly the kind of instruction that drifts.

Attaching to the right process

This is the part that trips everyone. Chromium is multiprocess; a breakpoint in Blink must be set in a renderer, not the browser process you launched.

# 1. Launch with renderers paused at startup
out/Default/Content\ Shell.app/Contents/MacOS/Content\ Shell \
  --renderer-startup-dialog --disable-hang-monitor <url>

It prints something like:

Renderer (80156) paused waiting for debugger to attach. Send SIGUSR1 to unpause.
# 2. Attach, set breakpoints, then release it
lldb -p 80156
(lldb) breakpoint set --name blink::HTMLConstructionSite::FosterParent
(lldb) process handle SIGUSR1 -n true -p true -s false   # don't stop on the unpause signal
(lldb) continue
# in another terminal:
kill -USR1 80156

Alternatively, find the renderer by inspecting Chrome's own task manager, or by --wait-for-debugger style flags for other process types (--utility-startup-dialog, and the equivalent GPU flag).

--disable-hang-monitor matters: without it, sitting at a breakpoint for 30 seconds gets your renderer killed for being unresponsive, and you lose the state you were inspecting.

Useful lldb, for this track specifically

(lldb) breakpoint set -n blink::Document::UpdateStyleAndLayout
(lldb) breakpoint set -f html_tree_builder.cc -l 812
(lldb) breakpoint set -n blink::Element::SetAttribute -c 'name == "class"'   # conditional
(lldb) breakpoint command add 1
> bt 12
> continue
> DONE
(lldb) thread backtrace all       # every thread — reveals the thread boundaries directly
(lldb) frame variable
(lldb) expression -- node->DebugName()

thread backtrace all is underrated here: it is the fastest way to see the renderer's thread structure — main, compositor, raster, IO — which is otherwise an abstract claim from bi-02. Do it once early and read the thread names.

Getting a breakpoint to hit at all

The three reasons a Blink breakpoint doesn't hit, in order of frequency:

  1. You attached to the browser process, not a renderer.
  2. The function was inlined. Set the breakpoint on the caller, or build that file with less optimisation.
  3. The code path is behind a runtime-enabled feature that is off.

5. Tracing

Tracing is rung 3 of the bi-01 ladder — above search, below the debugger — and it is the right first tool when you have a behaviour and no hypothesis. It also works on stock Chrome with no build at all, which is why Phase 0 can start tracing before the build finishes.

Three levels, increasing power:

  1. DevTools → Performance. Curated view, JS-centric, good for main-thread work.
  2. Perfetto UI (ui.perfetto.dev), or chrome://tracing in older builds. All categories, all processes, all threads. This is where you see the compositor thread, the GPU process, and cross-process flow arrows.
  3. Command-line tracing — startup tracing flags for capturing things that happen before you can click record.

What to actually do with it in this track:

  • Record with the blink, cc, gpu, viz, toplevel and devtools.timeline categories and identify, by name, the events for: style recalculation, layout, pre-paint, paint, commit, activation, raster, and frame presentation. Those names are the vocabulary the rest of the browser modules use.
  • Follow a single frame across processes using flow arrows: renderer main → compositor → GPU → presented. This makes §15's "the app does not own the whole budget" concrete rather than rhetorical.
  • Correlate a trace event name back to source: trace events are declared in the code with TRACE_EVENT macros, so the event name is a greppable string. This is the single best bridge between rung 3 and rung 1 — see something in a trace, grep its name, land in the implementation.

That last bullet is the technique that makes tracing a navigation tool rather than only a performance tool. TRACE_EVENT0("blink", "...") names are searchable identifiers into the exact code that ran.


6. Tests

autoninja -C out/Default blink_tests
strip ./out/Default/Content\ Shell.app/Contents/MacOS/Content\ Shell   # macOS: recommended upstream

third_party/blink/tools/run_web_tests.py -t Default
third_party/blink/tools/run_web_tests.py -t Default fast/forms
third_party/blink/tools/run_web_tests.py -t Default fast/fo\*

Direct, without the harness (you diff by hand):

out/Default/content_shell --run-web-tests fast/forms/001.html

Known failures are declared, not deleted:

third_party/blink/web_tests/TestExpectations

Read TestExpectations early — it is a map of Chromium's known interop gaps, and therefore a map of tractable first contributions (§24 rung 3). An entry there is a documented, accepted, currently-wrong behaviour with a bug attached.

C++ tests:

gn ls out/Default | grep unittests        # find the current target names; don't guess
autoninja -C out/Default <target>
out/Default/<target> --gtest_filter='HTMLTreeBuilder*'

Remember the Blink/non-Blink naming split from bi-01 Technique 6: foo_test.cc in Blink, foo_unittest.cc elsewhere.


7. Iteration-time discipline

Ranked by effect on this specific machine:

  1. is_component_build = true — do not build without it.
  2. Build the smallest target that answers your question: content_shell over chrome; a single unit-test target over blink_tests.
  3. Use gn refs to learn which targets a file belongs to, and build only those.
  4. symbol_level = 1 + blink_symbol_level = 2 rather than symbol_level = 2 everywhere.
  5. ccache, if you frequently switch branches. Little benefit for linear work.
  6. Do not run gclient sync more often than you need. Every sync is a partial rebuild.

The general principle: a Chromium question answered by a 10-minute rebuild was usually answerable by a 30-second search or a 2-minute trace. The ladder in bi-01 exists to protect build time, which is your scarcest resource.


8. Verification checklist

Update PROGRESS.md §8 whenever you confirm or refute one of these.

  • args.gn values still valid (gn args out/Default --list)
  • blink_tests, content_shell target names still current (gn ls)
  • run_web_tests.py path still current
  • TestExpectations path still current
  • docs/lldbinit.md contents still match what you put in ~/.lldbinit
  • --renderer-startup-dialog still the documented attach mechanism
  • Perfetto vs chrome://tracing — which does upstream currently document?