yacine sellami

Forking Chromium

A Chromium checkout, a first build, and keeping your patches alive across upstream releases.

Yacine Sellami 15 min read #chromium #build #clangd #patches #fork

Let’s get this show on the road!

Forking is the easy part but maintenance isn’t, Chromium ships a new stable release every four weeks your fork has to replay its changes onto every one of them or it falls behind security fixes and the diff against upstream grows until merging is no longer practical. Based on my observations most personal forks die at the first painful upgrade.

This post is two parts. First: checkout, configure, build. Standard procedure. Second: the maintenance loop. How to keep your changes alive across releases. The second half is the part most guides skip this one won’t.

Hardware

The estimates below are for the first build, the following builds will be incremental only recompiling the files you have changed and their dependency graph.

CPU: I recommend enabling Amd SMT/Intel HT and tuning as much as possible, Reference numbers for a clean release build:

  • 16-core (Ryzen 9 9900X, Ryzen 9 5950X , Apple M3/M4 Max): ~1 hours.
  • 8-core (Ryzen 7 7700X, i7-14700): ~3 hours.

RAM: 32/64 GB. 16 GB completes the build but swaps during link.

Swap: configure 20–30 GB on the same SSD as the source. Chromium’s link phase can spike memory well past physical RAM; swap absorbs the spike so a long build doesn’t get OOM-killed in its final minutes. I have around 30 GB of swap.

Disk: 200 GB free on an internal SSD. NVMe preferred. Filesystem choice matters:

  • ext4 / xfs / btrfs: fine.
  • NTFS via ntfs-3g on Linux: 10–100× slower for small-file operations. The Chromium tree contains roughly 400k files. A one-hour build on ext4 takes a working day on ntfs-3g.
  • External USB / Thunderbolt SSD: functional but the bus stalls under concurrent I/O. Format ext4.
  • HDD: not viable even using a RAID unless you want your grand children to inherit the build.

Network: initial fetch is very large and you need a very stable and fast internet, Subsequent gclient sync on a release tag pulls few gigs over time.

autoninja invokes siso on recent Chromium. siso checkpoints build state. An interrupted build (power loss, sleep, OOM, Ctrl-C and other failures) resumes from the last completed action on re-run.

Stress-test CPU/RAM (stress-ng) and disk (fio) before committing to a long build. A single hardware bit-flip during compilation surfaces as a phantom syntax error.

On Windows, exclude the source tree from Defender or any indexing operations, file scanning during compile inflates wall-clock time substantially.

Prerequisites

This guide is Linux-first but Windows works the same shape, you’ll swap shell/commands and skip install-build-deps.sh. The depot_tools docs cover the Windows variants.

Windows: VS 2022 ≥ 17.0.0 with Desktop development with C++, MFC/ATL, and Windows 11 SDK 10.0.26100.x. Git and Python (depot_tools brings its own bundled Python on Windows).

Linux: git and python3. The rest comes via install-build-deps.sh after the checkout exists (Debian/Ubuntu/Fedora-aware; Arch/NixOS users adapt or use the manual list in src/build/).

The requirements above might change, consider checking the official chromium docs.

depot_tools

depot_tools Google's toolkit for managing a Chromium checkout. Bundles gclient (multi-repo manager), fetch (initial clone), gn (build config), and autoninja (build runner). is your entrypoint. Clone it into the workdir you’ll use for everything Chromium:

mkdir -p ~/chromium && cd ~/chromium
git clone https://chromium.googlesource.com/chromium/tools/depot_tools
echo 'export PATH="$HOME/chromium/depot_tools:$PATH"' >> ~/.zshrc  # or .bashrc
exec $SHELL                                                         # reload

Two optional knobs worth setting once. Git cache (skip re-downloading sub-repos across syncs):

mkdir -p ~/.git-cache
echo 'export GIT_CACHE_PATH="$HOME/.git-cache"' >> ~/.profile

Stop depot_tools from auto-updating itself. By default, every invocation of gclient, fetch, gn, or autoninja first checks for and applies updates to depot_tools, which can silently change tool behaviour between runs. Pin it:

echo 'export DEPOT_TOOLS_UPDATE=0' >> ~/.profile

Fetch the source

cd ~/chromium
fetch --nohooks chromium       # --nohooks skips post-clone scripts so we run them in order
cd src
./build/install-build-deps.sh  # Linux: installs apt/dnf system deps. Ships inside src/build/.
gclient runhooks               # the hooks --nohooks skipped: clang toolchain, sysroots, build infra

First fetch is slow (an hour-ish on a good connection). Then ~/chromium/src/ exists and is a full Chromium tree at main.

Pick a release and branch off it

You almost never want main branch as the web often breaks on it due the experimental nature of the changes. Pick a version that’s actually shipped. The kind you see in chrome://version, e.g. 143.0.7499.170. Find live versions at chromiumdash.appspot.com/releases, or locally:

git tag --sort=-v:refname | head -20

Then branch off that tag with the my_local_ prefix:

# --with_branch_heads pulls release branch refs; --with_tags pulls tag refs.
# Both are off by default because most users don't need them.
gclient sync --with_branch_heads --with_tags
git fetch --tags

git checkout -B my_local_143.0.7499.170 143.0.7499.170

# Re-sync so sub-repo versions match DEPS at that exact tag
gclient sync --with_branch_heads --with_tags

This branch is your fork. The prefix matters: the upgrade tooling later parses the branch name to find which upstream tag your changes are against. Don’t rename it.

Set up clangd

Now that src/ exists, point your editor at Chromium’s clangd. I use Zed. Fast, doesn’t fight you when clangd indexes half of third_party/blink. VS Code works too; pick what you want.

Use the clangd binary Chromium ships, not your system one. The PCH Precompiled headers. Parsed C++ header content cached on disk to speed up compiles. The format isn't stable across clang versions, so a mismatched clangd reads them as garbage. format drifts between versions and you get bogus diagnostics.

src/third_party/llvm-build/Release+Asserts/bin/clangd

Missing? Run tools/clang/scripts/update.py from src/.

Drop this into ~/chromium/src/.clangd. It silences transitive-include noise and only background-indexes the directories you’ll actually navigate:

Diagnostics:
  UnusedIncludes: None
  MissingIncludes: None

CompileFlags:
  Remove: [-cfg=*, -exec_root=*, -inputs=*, -DUNSAFE_BUFFERS_BUILD, "--warning-suppression-mappings=*"]

If:
  PathMatch: ".*"
Index:
  Background: Skip
---
If:
  PathMatch: '(^|.*/)(base|url|content|mojo|net|storage|viz|cc|gpu)(/.*)?$|(^|.*/)services(/network(/.*)?)?$|(^|.*/)ui/events(/.*)?$|(^|.*/)third_party/blink/renderer/(core|platform|bindings)(/.*)?$|(^|.*/)third_party/skia(/.*)?$'
Index:
  Background: Build

The --- is a YAML document separator. clangd reads multiple documents per file. Don’t delete it.

Clangd also needs compile_commands.json. That’s generated by gn gen in the build step below. Until you run that, the editor will boot with clangd inactive.

Zed

Open ~/.config/zed/settings.json (or ~/chromium/src/.zed/settings.json for project scope):

{
  "lsp": {
    "clangd": {
      "binary": {
        "path": "/home/ys/chromium/src/third_party/llvm-build/Release+Asserts/bin/clangd",
        "arguments": ["--compile-commands-dir=/home/ys/chromium/src/out/Default"]
      }
    }
  }
}

Reload the LSP from the command palette (cmd+shift+peditor: restart language server).

VS Code

Install the clangd extension. Then disable Microsoft’s C/C++ extension or it’ll fight clangd for the same files and you’ll get duplicate diagnostics.

~/chromium/src/.vscode/settings.json:

{
  "clangd.path": "/home/ys/chromium/src/third_party/llvm-build/Release+Asserts/bin/clangd",
  "clangd.arguments": [
    "--compile-commands-dir=${workspaceFolder}/out/Default",
    "--background-index"
  ],
  "C_Cpp.intelliSenseEngine": "disabled"
}

Restart the window. Open a .cc file. The status bar should report clangd: indexing then go quiet.

Indexing workers and memory

clangd defaults to one indexing worker per CPU core. On a 16-core machine indexing Chromium that’s enough RAM pressure to OOM mid-session. Cap workers, deprioritize background work, and route PCH to disk. All flags are real, documented in clangd --help (clangd ≥16):

  • -j=N — limit concurrent indexing workers (default = CPU count). 4 is a sane ceiling for most workloads.
  • --background-index-priority=low — run background indexing as low-priority threads so editor responsiveness doesn’t drop.
  • --malloc-trim — Linux only. Periodically returns freed heap back to the OS instead of holding it.
  • --pch-storage=disk — store precompiled headers on disk instead of in memory. Slower lookup, drastically lower RAM.
  • --header-insertion=never — skip header-insertion suggestions if you don’t use them. Saves index work.

Add them to clangd.arguments (VS Code) or the Zed arguments array. Example for Zed:

{
  "lsp": {
    "clangd": {
      "binary": {
        "path": "/home/ys/chromium/src/third_party/llvm-build/Release+Asserts/bin/clangd",
        "arguments": [
          "--compile-commands-dir=/home/ys/chromium/src/out/Default",
          "--background-index",
          "-j=4",
          "--background-index-priority=low",
          "--malloc-trim",
          "--pch-storage=disk"
        ]
      }
    }
  }
}

On a 32 GB machine, -j=4 --pch-storage=disk keeps clangd under ~6 GB resident even mid-index.

Configure and build

One out/ directory does everything: build outputs and compile_commands.json for clangd.

gn gen out/Default --export-compile-commands

Point your editor’s clangd setting at it:

--compile-commands-dir=/home/ys/chromium/src/out/Default

For the full list of args available in Chromium 146, see this gist. To dump every arg with its default and docstring against whatever version you have checked out:

gn args --list out/Default

Edit out/Default/args.gn (the file gn gen just created):

is_debug = false
is_component_build = true
symbol_level = 0
blink_symbol_level = 0
v8_symbol_level = 0
target_cpu = "x64"
concurrent_links = 1
enable_vulkan = true
enable_swiftshader_vulkan = true

symbol_level = 0 is fastest. 1 for day-to-day. 2 if you’re serious about debugging; linking gets miserable.

concurrent_links = 1 limits how many heavy link steps run in parallel. Saves you from linker OOMs on a 16 GB machine. Bump to 2 if you’ve got RAM to spare.

Then build. chrome is the target; other targets (content_shell, chromedriver, unit_tests) work the same way:

autoninja -C out/Default chrome

autoninja runs siso Google's newer build executor, distributed-build aware. Took over from ninja as Chromium's default executor in 2024. Same job, slightly different bug surface. in recent Chromium, not ninja. Same idea, slightly different bug surface. To force ninja: set use_siso = false in args.gn and re-run gn gen. Note: autoninja re-runs gn automatically when args change; you don’t need to run gn gen after every args edit.

./out/Default/chrome --version

Matches your tag? First half done.

Build configurations

out/Default is fine to start. Now depending on how deep the work is, you might need to keep multiple build directories. Each one is its own args.gn plus its own object cache; they share the source tree, the git cache, and the toolchain. The configs below are what I keep built simultaneously.

Debug

Every assertion live, full symbols, shared libraries. Slow to compile, slow to run, but DCHECK fires loudly and stack traces are readable.

out/Debug/args.gn:

is_debug = true
is_component_build = true
symbol_level = 2

Use when something’s wrong and you need exact location.

Release

Optimized, monolithic binary, partial symbols. Close to shipped behaviour, still investigable on crash.

out/Release/args.gn:

is_debug = false
is_component_build = false
symbol_level = 1

Default for day-to-day testing.

Production

Release plus the bits stock Chrome ships with: proprietary codecs, Chrome ffmpeg branding, DCHECK hard-off, NaCl stripped. This is the binary that goes out.

out/Production/args.gn:

is_debug = false
is_component_build = false
dcheck_always_on = false
symbol_level = 1
enable_nacl = false
proprietary_codecs = true
ffmpeg_branding = "Chrome"

Building all three

autoninja -C out/Debug chrome
autoninja -C out/Release chrome
autoninja -C out/Production chrome

Three out dirs are variable depending on what’s been built and how recently, but plan for at least 100 GB total. The source tree is shared, so it’s not 3× the full checkout. Incremental builds only touch what your last change affects, so cycling between configs is cheap once each has finished its first full build.

A common workflow: a bug that only reproduces in Production gets re-run under Debug. Symbols and assertions tell you what’s actually wrong without recompiling 400k objects.


Post-build: maintaining your fork

The fork starts dying the moment you stop touching it. 144 lands, then 145, then 146. Your changes ride along or get left behind.

A great solution to this problem is to keep your changes as per-file patches in a separate repo. Replay them onto each new tag from scratch.

The cycle, step by step:

 ┌───────────────────────────────────────────────────────────────┐
 │                                                               │
 │   1. You edit code in chromium/src/                           │
 │      git commit on branch my_local_<tag>                      │
 │                                                               │
 │                            │                                  │
 │                            ▼                                  │
 │                                                               │
 │   2. Snapshot your changes:                                   │
 │        $ ./tools/export.py                                    │
 │      Result: Patches/<tag>/ holds one .patch per file changed.│
 │      Commit the patches repo. You're done for now.            │
 │      (Push to a remote later if you want offsite backup.)     │
 │                                                               │
 │                            │                                  │
 │             ... weeks pass. Chromium ships 147 ...            │
 │                            │                                  │
 │                            ▼                                  │
 │                                                               │
 │   3. Move your patches to the new Chromium:                   │
 │        $ ./tools/upgrade.py 147.0.7700.100                    │
 │      What it does for you:                                    │
 │        - fetches the new tag                                  │
 │        - creates branch my_local_147.0.7700.100               │
 │        - runs apply.py, replaying patches from the prev dir   │
 │                                                               │
 │                            │                                  │
 │                            ▼                                  │
 │                                                               │
 │   4. Fix what doesn't fit anymore:                            │
 │        - resolve any conflicts apply.py reported              │
 │        - rebuild: autoninja -C out/Release chrome             │
 │        - fix API breakage from the version bump               │
 │                                                               │
 │                            │                                  │
 │                            ▼                                  │
 │                                                               │
 │   5. Re-snapshot the now-working patches:                     │
 │        $ ./tools/export.py                                    │
 │      Writes Patches/<new-tag>/ alongside older versions.      │
 │                                                               │
 │                            │                                  │
 │              └────  back to step 1, on the new tag  ──────────│
 │                                                               │
 └───────────────────────────────────────────────────────────────┘

What that buys you:

  • One .patch per source file. Grep-able, review-able, single purpose.
  • Fresh history per upgrade. No rebase wreckage.
  • One directory per Chromium tag in the patches repo. A timeline of what worked on what.

Layout on disk:

chromium/
├── src/                                Chromium checkout
└── Patches/
    ├── tools/{_lib,export,apply,upgrade}.py
    ├── 146.0.7680.177/                 one dir per Chromium tag you've patched
    │   ├── manifest.json               metadata: tag, sha256, file count
    │   └── chrome/browser/foo.cc.patch
    └── 147.0.7700.100/
        ├── manifest.json
        └── chrome/browser/foo.cc.patch

Invariant: a patch for chromium/src/chrome/browser/foo.cc exported against tag 146.0.7680.177 lives at Patches/146.0.7680.177/chrome/browser/foo.cc.patch. Mirrored exactly inside the version dir.

The tooling

not battle-tested

The scripts below are what I run on my own fork. They have not been put through a formal test suite, run against multiple checkout shapes, or independently audited. Treat them as a starting point you read before trusting, not a drop-in dependency. If you adopt them, run the dry-run variants first and keep your patches repo under version control so a bad day is recoverable.

Full source: github.com/Redrrx/chromium_fork_tooling.

Three scripts in Patches/tools/. Each does one job.

scriptwhat it doeshow to call it
export.pysnapshot committed src/ changes into Patches/<tag>/./tools/export.py
apply.pyreplay Patches/<tag>/*.patch onto a clean src/ checkout./tools/apply.py
upgrade.pyfetch a new Chromium tag, branch from it, replay prior patches./tools/upgrade.py 147.0.7700.100

Day-to-day workflow:

  1. Edit src/. Commit on your my_local_<tag> branch.
  2. ./tools/export.py. Snapshot into Patches/<tag>/.
  3. Tag a branch in the patches repo named after the upstream version.
  4. Upstream ships a new release → ./tools/upgrade.py <new-tag>.
  5. Resolve conflicts. Build. Re-export.

The rest of this section is the why of how each script behaves. Useful if you want to write your own. Skippable if you just want to use mine.

inside export.py

Diffs my_local_<tag> against <tag>. Writes one patch per changed file into Patches/<tag>/.

Refuses to run off a my_local_* branch. Patches without a known upstream base are worthless:

branch = git_text(["branch", "--show-current"], cwd=src).strip()
if not branch.startswith("my_local_"):
    die(log, f"not on a my_local_* branch (current: '{branch}')")
base_tag = branch[len("my_local_"):]

Strips third-party patch dirs and editor configs out of the diff:

EXCLUDES = [
    ":!third_party/*/patches/*",
    ":!base/third_party/*/patches/*",
    ":!.clangd",
    ":!.gitignore",
]

Round-trips by sha256. After writing all the per-file patches, the concatenation of their bytes must hash to the same value as the combined diff:

combined = git_bytes(["diff", f"{base_tag}..{branch}", "--", *EXCLUDES], cwd=src)
combined_sha = hashlib.sha256(combined).hexdigest()

h = hashlib.sha256()
for f in files:
    p = stage_dir / f"{f}.patch"
    if p.is_file():
        h.update(p.read_bytes())

if combined_sha != h.hexdigest():
    die("round-trip sha256 mismatch")

If the hashes disagree, the export is broken. You find out now, not three months later when an upgrade fails for unrelated reasons. Until verification passes, nothing has been written outside the temp staging dir, so a failed export leaves your real Patches/ untouched.

inside apply.py

Plays the patches back onto a clean tree. Two design choices matter.

Check everything before touching anything. A naive script applies one patch, hits a conflict on the next, leaves the tree half-modified. Walk the list with git apply --check first; only commit to the clean set:

clean, conflicts = [], []
for patch in patches:
    r = subprocess.run(["git", "apply", "--check", str(patch)],
                       cwd=src, capture_output=True, check=False)
    (clean if r.returncode == 0 else conflicts).append(patch)

You see all conflicts at once. Nothing on disk has changed yet.

Surgical rollback on unexpected failure. A patch can pass --check and still fail to apply. Rare, usually a binary or line-ending edge case. We track which files we modified vs created so rollback only touches what we changed:

modified, new_files = [], []
try:
    for patch in clean:
        target = src / patch_target(patch)
        (modified if target.exists() else new_files).append(str(target))
        subprocess.run(["git", "apply", str(patch)], cwd=src, check=True)
except subprocess.CalledProcessError:
    # rollback: restore the files we touched, delete the ones we created
    if modified:
        subprocess.run(["git", "checkout", "HEAD", "--", *modified], cwd=src)
    for f in new_files:
        Path(f).unlink(missing_ok=True)
    raise

Touches only what we know we changed. No git checkout -- . on the whole tree. That’s the script that nukes someone’s unfinished work the one time they forgot to stash.

inside upgrade.py

Refuses on a dirty tree. Fetches the tag. Creates my_local_<new-tag> from it. Runs apply with --from=<prev-tag> so patches from the previous version’s directory get replayed onto the new branch.

If anything blows up before apply runs cleanly, it rolls back: hard-reset to the new tag, switch back to your previous branch, delete the new one. The checkout looks like the upgrade never happened.

prev_branch = git_text(["branch", "--show-current"], cwd=src).strip()
prev_tag = prev_branch[len("my_local_"):]

# create the new branch off the new tag
subprocess.run(["git", "checkout", "-b", new_branch, new_tag], cwd=src, check=True)
branch_created = True

try:
    apply_exit = subprocess.run(
        [sys.executable, script_dir / "apply.py", f"--from={prev_tag}"],
    ).returncode
except Exception:
    # rollback: undo what we created
    if branch_created:
        subprocess.run(["git", "reset", "--hard", new_tag], cwd=src)
        subprocess.run(["git", "checkout", prev_branch], cwd=src)
        subprocess.run(["git", "branch", "-D", new_branch], cwd=src)
    raise

Without that rollback you’d manually unwind every failed attempt. You’d start postponing upgrades. The fork would drift.

End to end update

145 → 147:

./tools/upgrade.py 147.0.7700.100
# resolve any conflicts the script reported

cd ../src
gclient sync --with_branch_heads --with_tags
autoninja -C out/Release chrome
# fix API churn

cd ../Patches
./tools/export.py
git checkout -b 147.0.7700.100
git add -A && git commit -m "Patches for 147.0.7700.100"

Branch list in the patches repo becomes a timeline:

145.0.7632.116
146.0.7680.177
147.0.7700.100

If 147 breaks something you can’t fix today, check out 146.0.7680.177 and run a version behind until you can.

Housekeeping

Two things accumulate that nobody warns you about.

Dead my_local_* branches. Every upgrade leaves the previous branch sitting in the checkout. After a year you’ve got five or six. List them, kill the ones you’re not comparing against:

git branch | grep my_local_
git branch -D my_local_142.0.7444.111

I keep the current and one prior. Everything older goes.

Pack file bloat. Long-running checkouts grow giant packs. 45 GB is normal once you’ve upgraded a handful of times. Repack:

git gc --aggressive --prune=now
git repack -a -d -f --depth=250 --window=250

Takes hours, reduces pack size substantially. Worth running every few months.

For the git cache, du -sh ~/.git-cache/* finds which sub-repo is fat. If a cache entry is beyond reasonable, delete it and let the next gclient sync rebuild it.

Failures worth knowing

Setup:

  • gclient sync missing refs → git fetch --tags, retry.
  • Linker OOM → symbol_level = 0, concurrent_links = 1.
  • clangd PCH errors → wrong binary. Point at src/third_party/llvm-build/Release+Asserts/bin/clangd.
  • Stalling builds on Windows → Defender. Exclude the tree.
  • siso misbehaving → use_siso = false in args.gn, regenerate.

Maintenance:

  • Export round-trip mismatch → your excludes and your actual changes disagree. Audit it.
  • apply --check passes, apply fails → whitespace or line endings. Open the patch.
  • A patch that worked last month doesn’t → upstream moved the file. git log --follow.
  • Forgot to commit before export → it warns but doesn’t fail. Read the warning.