Engineering blog · deep dive · work in progress

Running real Chromium on a kernel we wrote ourselves.

DOOM was the warm-up. Chromium is the real exam: about half a gigabyte of someone else's C++, ninety shared libraries, dozens of threads, a GPU stack and a multi-process IPC design that assumes a full Linux underneath it. This is a live account of how far EuroOS can now run the actual Google browser binary, the real kernel bugs it forced us to fix, and the honest wall we are standing at right now.

Why Chromium, and why it is a different order of test than DOOM

A while ago we wrote about DOOM running on EuroOS: the real 1993 engine, unmodified, as a userspace program on our from-scratch kernel. DOOM is a genuine integration test because it is someone else's software that cannot be tweaked into cooperating. But DOOM is also small, single-threaded, and static. It asks the operating system a few sharp questions and stops.

Chromium asks every question at once, and then a thousand more. It is one of the largest pieces of software humanity ships. Getting it to even start exercises a dynamic loader, ninety-odd shared libraries resolved and relocated, a C++ runtime with exceptions and thread-local storage, hundreds of Linux syscalls, POSIX threads at scale, futexes, epoll, eventfd, shared memory, Unix sockets, a sandbox model, and a multi-process architecture where the browser forks and re-executes itself into renderer, GPU and utility processes that talk over a custom IPC layer.

The house rule, restated up front: we never present a demo as the real thing, and we under-claim on purpose. So to be exact about what this post is and is not: the real, unmodified Chromium binary now runs its entire startup on EuroOS. It has not yet rendered a web page. This is a deep dive into a bring-up that is genuinely in progress, published because the engineering along the way is the interesting part.

Step one: getting a 485 MiB binary to run at all

Our first problem was physical. The chrome-headless-shell binary is 485 MiB. You cannot copy that into a RAM filesystem on a machine with a couple of gigabytes and then also give the browser room to work. So EuroOS serves the executable straight from a disk image and demand-pages it: the loader reads only the ELF header and program headers, reserves the virtual address space, and every 4 KiB page of code faults in from disk on first touch. The binary is never fully resident. A browser's hundreds of megabytes of text cost only the pages that actually execute.

The same machinery carries the ninety libraries Chromium links against, each mapped lazily, each page filled from disk or zero-filled for .bss on the fault that first reaches it. With that in place, the first real milestone was almost anticlimactic:

qemu · EuroOS boot · serial0
[glibc-disk] /pack/chrome-headless-shell: 485 MiB on disk, entry@... (demand-paged from disk)
[chrome-disk] chrome --version from DISK (485 MB demand-paged exe): exit=0
[chrome-disk]   Chromium 152.0.7952.0
The real binary prints its own version string. Not a re-implementation, not a mock: Google's C++, demand-paged off a disk it has never seen, on a kernel written from scratch in Rust.

Step two: the entire startup, and the bugs it found for us

Printing a version string parses arguments and exits. Actually starting the browser is a different animal. Driving chrome-headless-shell toward loading a page took it through profile setup, the V8 JavaScript engine, roughly fifty threads, shared-memory allocation, font and locale loading, network and cache subsystems, and the compositor. Every one of those touched a part of our Linux compatibility layer that no smaller program had reached. Chromium behaved exactly like the ideal test author: it found the gaps we did not know we had, and it named each one.

A file-descriptor famine hiding as a permissions error

Chromium was failing to create its profile, its cache and its history with permission-denied errors that made no sense. The root cause was mundane and brutal: our descriptor table held sixteen entries. Chrome opens far more, and once the table was full, every new open returned failure that the C library reports as a permissions error. One constant, sixteen to five hundred and twelve, and a whole category of impossible bugs evaporated.

Missing syscalls a real IPC layer depends on

Chrome's inter-thread messaging duplicates socket and event handles with dup; it sizes shared memory with fallocate; it detects processor count with sched_getaffinity; it waits for a socket to become writable through epoll reporting EPOLLOUT. Each was unimplemented or subtly wrong, and each made a subsystem quietly break. Implementing them, correctly, was most of the work of getting init to complete.

A spinlock deadlock, found with a hardware probe

At one point chrome simply froze, with interrupts disabled, in kernel code. We injected a non-maskable interrupt from the hypervisor to capture where the CPU was wedged: a tight spin inside our own dup implementation. The bug was a self-deadlock, a lock taken twice because a Rust temporary held it across a block. Ten minutes of confusion, one line of fix, and a lesson re-learned about spinlocks and scope.

Descriptor numbers that collided across kinds

Chromium ships a debug check that crashes on purpose if it is ever handed a file descriptor it already owns. It caught us red-handed: our kernel allocated regular-file, pipe, directory and socket descriptors from overlapping number ranges, so the same integer could name two different things. We unified allocation so a descriptor is free only when it is free in every table at once. Chromium's own paranoia found a real correctness bug in our kernel.

With that chain of fixes in place, the payoff: the real browser binary runs its complete single-process startup on EuroOS. V8 initializes, the thread pool spins up, the subsystems come alive, and the process exits cleanly with no crash. On a from-scratch operating system, that is a genuine landmark.

Step three: the honest wall, and why it is not our fault

And yet no page rendered. Chrome initialized and then quit, without ever navigating. For a long time we assumed this was a bug in our environment, and we hunted it hard: we disassembled the exact machine instruction where a worker thread died and found int3; ud2, which is Chromium's way of saying "a safety check failed, crash now on purpose." Deterministically, in the same thread, on every run. The threads were not corrupted. They were choosing to abort.

The reason turned out to be upstream, not ours. We were running Chromium in --single-process mode, because that avoids needing to spawn child processes, which our kernel could not yet do. But --single-process is a deprecated, broken configuration in modern headless Chrome: its worker threads crash on internal checks on real Linux too. It is a documented dead end across the Puppeteer and ChromeDP communities. No amount of compatibility work on our side would make it render a page, because it does not render a page anywhere.

That reframed the whole project. The road to a rendered page does not run through a better single-process hack. It runs through the thing single-process was avoiding: real, honest, multiple operating-system processes. Chromium's default is a browser process that forks and re-executes itself into a renderer, a GPU process and utilities, all talking over shared memory and sockets. To render a page, EuroOS has to actually do that.

Step four: so we taught the kernel to fork

This is the part we are proudest of from the last stretch. We measured exactly how Chromium spawns a child: a plain fork (a clone with no shared address space), followed by execve in the child. So we implemented fork for our demand-paged process model, which is harder than the textbook version because the process is not a simple flat arena. It is a copied identity region for the loader, libc, heap and stack, plus a sparse 256 GiB demand region where the browser's code and data live, paged in from disk.

Our fork gives the child its own complete address space: the multi-block arena remapped onto its own freshly copied, 2 MiB-aligned physical frames, and a copy of every committed demand page so the child sees the parent's exact runtime state, then a task that resumes at the fork return with the child's own page tables. It exposed its own crop of real bugs on the way, each satisfying to close: the child arena had to be 2 MiB-aligned or the CPU rejected the huge-page mapping; the child needed /dev/null to redirect its descriptors; a permissive eventfd2 that accepted invalid flags tripped a Chromium sanity probe. And then:

qemu · EuroOS boot · serial0
[fork] clone(flags=0x1200011) = fork -> do_glibc_fork
[fork] pid 1002 -> child task 42 (own pml4=0x1f90000 arena=0x2000000 96 MiB)
EuroOS forks a demand-paged process. Chromium's browser process calls fork, and the kernel hands back a real child with its own address space, its own copy of the arena, and its own cloned demand pages. The child runs its inherited code with no crash.

That single line is a working fork for a half-gigabyte demand-paged program on an operating system we wrote from nothing. Chromium now creates a child process on EuroOS.

Where we are right now, told straight

Immediately after the fork, Chromium tries to build the communication channel between the browser and its new child, and stops there with a fatal check: it wants a cross-process message channel that we have not built yet. That is the next wall, and it is a real one. Making it work means shared memory mapped into two different address spaces at once, message delivery between separate processes, and passing descriptors from one process to another over a socket. It is the heart of Chromium's multi-process design, and it is a substantial piece of operating-system engineering in its own right.

So, precisely: we can demand-page and run the real Chromium binary's full startup, and we can now fork it into a genuine child process. We cannot yet render a web page. The remaining distance is the cross-process IPC stack, then the GPU and compositor path, then navigation. We are building it in the open, one honest milestone at a time, exactly as we did with DOOM.

What this already says about EuroOS

EuroOS is not a Linux distribution, and Chromium does not change that. Its identity is the native, sovereign platform: capability security, signed and sealed updates, a European stack whose every line we control. The Linux ABI is a bridge. But the value of a bridge is measured by the heaviest traffic that crosses it, and there is almost no heavier traffic in software than a modern browser engine.

Everything this effort has forced into existence is now platform capability, not browser-specific glue: demand paging that carries binaries larger than RAM, a descriptor and memory model hardened by Chromium's own debug checks, a mature slice of the Linux syscall surface, and, as of this month, a working fork for demand-paged processes. That last one is the foundation of multiprocessing on EuroOS, and it will serve every future workload, not just this one.

Honest footnotes, as always

  • Verified: the unmodified chrome-headless-shell binary (Chromium 148 / Chrome for Testing) demand-paged from a disk image, running its complete single-process startup to a clean exit; and separately, our fork creating a real child process with its own address space that runs without crashing. Both on the stock EuroOS image under QEMU.
  • Not yet done: a rendered web page. Chromium reaches its cross-process IPC setup after fork and stops. Multi-process bring-up (IPC, then GPU and compositor, then navigation) is the work ahead.
  • Single-process headless Chromium is a broken upstream configuration; its worker threads crash on internal checks on real Linux too. That is why our path forward is multiprocessing, not a single-process workaround.
  • Our CI emulates the CPU instruction-by-instruction with no hardware virtualization, roughly sixty times slower than real hardware, so a full browser boot there takes minutes. That is the harness, not the browser.
  • The software rasterizer path uses AVX2 instructions our emulated CPU does not provide, so graphics stay on a software-only path for now. The GPU and compositor bring-up is a later milestone.
Read the DOOM write-up The platform Technical docs