scry build guide
Audience: an agent. This is a build spec, not a tutorial. It describes how to build a native desktop browser whose chrome is real GUI code and whose web content is Chromium rendered offscreen into a texture — and, more importantly, it records every trap the reference implementation hit so you don't pay for them twice. Every number here was measured on one Linux box (32 cores, AMD RADV, Hyprland/Wayland with an Xwayland fallback) during a single overnight build.
The reference implementation is called scry. It is Linux-only, single-user, ~156 tests, built in six OODA rounds overnight plus a morning of tweaks. Where a detail is specific to the reference system's data sources, the guide names the general pattern first and the concrete instance second.
0. what you are building, and the one bet that makes it worth building
A browser where everything outside the page rectangle is native: tab strip, omnibox, sidebar rail, command palette, context menus, prompts, toasts.
The architectural bet, stated precisely:
An Electron/Tauri shell can only draw inside the webview rectangle. Native popovers, menus painted over web content, sidecar panes, and OS-level chrome that knows about your data all live outside it. If the affordances you want are outside the rectangle, you need the host to be a native UI toolkit and the web content to be a composited texture inside it — not the other way around.
The second bet is about content: you are not building a browser for the open web. Keep Chromium for that. This browser is for pages you control, which buys you a much softer threat model and lets both ends of the connection be yours — pages can call into the shell, and the shell (and your agents) can script pages.
If neither bet applies to you, build an Electron app instead. It is much less work.
1. architecture
┌──────────────────────────────────────────┐
ctl client ─────►│ app crate GPUI host │
(unix socket) │ window · tab strip · omnibox · rail │
│ palette · native menus · session.json │
└───┬──────────────┬───────────────┬───────┘
│ │ │
texture + │ query │ bridge │ CDP loopback
input │ │ │ (window.<api>)
┌───▼──────────┐ ┌─▼────────────┐ │
│ cef crate │ │ index crate │ │
│ OSR, pump, │ │ jump index │ │
│ input xlate │ │ N sources │ │
│ ALL unsafe │ │ fuzzy match │ │
└───┬──────────┘ └─┬────────────┘ │
│ │ │
CEF 151 YOUR OWN data stores │
(prebuilt) │
│ │
┌───▼───────────────────────────────┐
│ cdp crate :9223 — agents read │
│ and script every open tab │
└───────────────────────────────────┘
Four crates. The seams are the design:
| crate | job | why separate |
|---|---|---|
*-cef |
offscreen rendering, message-loop pump, texture hand-off, input translation | all unsafe and all CEF types live here. The app crate never sees a raw CEF handle. This is what makes the rest of the app boring, testable code. |
*-index |
the jump index: source adapters over your data, fuzzy query, sensitivity gating | headless, no GUI dependency. It builds and tests in ~10 s without touching GPUI. You will iterate on it constantly; do not chain it to a 40 s GUI build. |
*-app |
the binary: window, tabs, compositing, input routing, omnibox, rail, palette, session, control socket | the only crate that knows what your product is |
*-cdp |
CLI + library over the DevTools port | optional, deletable, experimental |
Two more seams inside the app crate, both intentionally amputatable:
- bridge (
bridge.rs) —window.<yourapi>injected into every page. - control (
control.rs+ctl.rs) — a Unix socket and a thin client in the same binary. This is the dogfood loop.
Dependency direction is one-way and enforced by policy. If you have a sibling project that shares GUI helpers, depend on it by path and never the reverse; extract into a shared crate only when a second consumer actually appears, never speculatively.
2. dependencies and pinning
GPUI
GPUI is Zed's GUI framework. It is not published to crates.io in a form you can
rely on; you pin a git rev and [patch] it to a local checkout:
[workspace.dependencies]
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = [
"font-kit", "x11", "wayland", "runtime_shaders",
] }
[patch."https://github.com/zed-industries/zed"]
gpui = { path = "/abs/path/to/zed/crates/gpui" }
gpui_platform = { path = "/abs/path/to/zed/crates/gpui_platform" }
gpui_macros = { path = "/abs/path/to/zed/crates/gpui_macros" }
Rationale for the [patch] indirection rather than a bare path dep: every
project on the box that uses GPUI points at the same local checkout, so they
can never drift onto different revs, and if the checkout moves the build fails
loudly instead of silently resolving to zed HEAD.
Two more pins that matter:
image = "0.25", matching whatever GPUI uses. A different minor makesimage::Framea different type and the texture hand-off stops compiling.- GPUI is unusably slow unopposed by the optimizer, so in dev builds:
[profile.dev.package]
gpui = { opt-level = 3 }
gpui_platform = { opt-level = 3 }
taffy = { opt-level = 3 }
Note the API detail: at this rev the app is constructed with
gpui_platform::application(), not Application::new().
CEF
Use tauri-apps/cef-rs, crate name
cef. This was a real evaluation, not a coin flip:
| option | verdict |
|---|---|
cef (cef-rs, tauri-apps) |
chosen. The only actively maintained binding — its 151.1.0 release landed the same day as the reference spike, and it tracks upstream CEF within days. Ships cef-dll-sys (generated C-API bindings + a build script that downloads and arranges the matching binary distribution) and a working examples/osr. |
cef-ui (hytopiagg) |
dead — last commit Sept 2024, CEF 1xx era. |
wef |
abandoned; it was the CEF-OSR crate inside longbridge/gpui-component, which has since replaced it with gpui-wry. Useful prior art, not a dependency. |
hand-written C shim over libcef |
unnecessary — cef-dll-sys already is exactly that, generated. |
cef = { version = "151.1.0", default-features = false }
Do not enable the accelerated_osr feature: it wants a wgpu::Device that
GPUI does not hand out (see §5).
3. the CEF binary distribution — fetch mechanics
You do not vendor Chromium. You need the prebuilt CEF distribution matching the crate's build metadata:
cef 151.1.0+151.3.12 ⇒ cef_binary_151.3.12+gd9cea67+chromium-151.0.7922.47_linux64_minimal
321 MB, from cef-builds.spotifycdn.com.
mkdir -p .cef
export CEF_PATH="$PWD/.cef/cef_binary_151.3.12+gd9cea67+chromium-151.0.7922.47_linux64_minimal"
cargo build --release -p your-app # downloads + arranges on first build
Mechanics worth knowing before you burn a build:
CEF_PATHis the only wiring. Nothing is hardcoded. Point it anywhere.cef-dll-sys's build script downloads and flattens into$CEF_PATH/151.3.12/cef_linux_x86_64/(libcef.so,icudtl.dat,*.pak,locales/).- It validates the distribution's
archive.jsonand errors on version mismatch. The crate's build-metadata segment is the required distribution version. Do not hand-install a different build; it will not be quietly wrong, it will refuse. - Building
libcef_dll_wrapperneeds a C++ toolchain: cmake + ninja + a C++ compiler. - In CI, pin and cache the distribution. Do not let a build script pull 321 MB from a CDN on every run.
LD_LIBRARY_PATH is not optional. The arranged directory must be on it at
runtime or the dynamic loader cannot find libcef.so. Make a runner script the
only supported launch path:
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
export CEF_PATH="${CEF_PATH:-$HERE/.cef/cef_binary_151.3.12+...+linux64_minimal}"
export CEF_DIR="$CEF_PATH/151.3.12/cef_linux_x86_64"
export LD_LIBRARY_PATH="$CEF_DIR:${LD_LIBRARY_PATH:-}"
export APP_BIN="${APP_BIN:-$HERE/target/${PROFILE:-release}/app}"
exec "$APP_BIN" "$@"
Consequences you will hit: your ctl client lives in the same binary and
therefore also needs LD_LIBRARY_PATH, so it goes through the runner too
(./run.sh ctl status). A bare ./target/release/app ctl status dies with
error while loading shared libraries: libcef.so. And your .desktop entry
must Exec the runner, never the binary.
build times (measured, 32 cores, CEF already arranged)
| what | time |
|---|---|
build the *-cef crate, deps warm |
9.6 s |
| build the app crate, debug, after GPUI was built | 38 s |
| build the app crate, release, incremental after a source edit | 18 s |
| fully cold including the 321 MB download (spike measurement, same dep set) | ~90 s |
The 18 s number is the whole point: CEF is a prebuilt blob outside the build path, so your edit-run loop never recompiles Chromium.
4. process bootstrap and the message pump
subprocess re-exec
CEF re-runs your own binary for the renderer, GPU, zygote and utility processes. Therefore:
execute_process()must be the first statement inmain(), and non-browser processes must return immediately — before GPUI initializes anything.
Detect via the type command-line switch. Note the cost: a debug binary can be
~300 MB and gets re-exec'd ~6 times. cef-rs supports a separate helper binary
([package.metadata.cef.bundle] helper_name) so renderer processes don't carry
your whole GUI image; worth doing before you ship, not before you demo.
threading — single-threaded, and it is not a fight
This is the main architectural question and it resolves cleanly:
- Initialize CEF on the process main thread, before GPUI takes it over.
Settings { external_message_pump: true, windowless_rendering_enabled: true }. Never callrun_message_loop(). (multi_threaded_message_loopis Windows-only.)- Run the pump on GPUI's foreground executor. GPUI foreground tasks resume on
the main thread, so every CEF call —
do_message_loop_work,send_*_event,was_resized— happens on the thread that calledinitialize. That is exactly what CEF requires and it needs no extra machinery. - The
Browserhandle is!Send; keep it in athread_local!. The only thing crossing a thread boundary is the pixel buffer, behindArc<Mutex<Frame>>. OnPaintfires synchronously insidedo_message_loop_work(), so there is no callback-ordering hazard.
Do not ship the naive fixed-interval poll. A 6 ms timer works and is what the
spike used, but external_message_pump is contractually paired with
CefBrowserProcessHandler::on_schedule_message_pump_work(delay_ms): CEF tells
you exactly when to pump next. Implement it, schedule a one-shot foreground task
at that delay (clamp to roughly [0, 16] ms, collapse pending wakeups). This drops
idle CPU to near zero — the poll burns cycles on a static page — and removes up
to 6 ms of input latency.
Wrap all CEF handles in one host type that is explicitly !Send and panics if
touched off-thread, rather than relying on convention.
5. the texture path — the single best discovery
CEF's OnPaint buffer format and GPUI's RenderImage format are identical.
Both are BGRA8, premultiplied, top-left origin. GPUI's RenderImage is
documented as "a cached and processed image, in BGRA format" even though it is
typed as image::RgbaImage. So the bytes go straight across with zero
swizzle, zero conversion:
let rgba = RgbaImage::from_raw(w, h, bgra_bytes)?; // no conversion
let image = Arc::new(RenderImage::new(smallvec![Frame::new(rgba)]));
// render: img(image).size_full()
The trap that will leak your GPU memory: every RenderImage gets a fresh
ImageId and GPUI caches it in the sprite atlas by that id. Without dropping
the previous one, the atlas grows by a full frame on every paint. Call
cx.drop_image(old, Some(window)) when you replace the frame. Sixty times a
second, a 4 MB frame, no eviction — you find this within a minute if you are
watching, and never if you are not.
Also:
- Double-buffer, don't clone. The obvious first implementation clones the
buffer per frame purely to satisfy borrowck. Two
Vecs swapped under the lock gives exactly one copy out of CEF's buffer and zero after. - Dirty rects are a trap in the other direction.
OnPaintsupplies them, and wiring them looks like an easy win. Measured win in the reference: ~0.5 %, because whole-buffer ownership still moves on every frame and GPUI's atlas API takes whole images anyway. It was kept for correctness and explicitly documented as not a speed feature. Do not claim it as an optimization. - Force alpha opaque in
on_paint(px[3] = 0xff) if you want opaque pages. CEF's alpha comes from the page, not fromBrowserSettings.background_color; a page that never paints an opaque backdrop genuinely leaks the desktop through. (Beware the adjacent false alarm: a translucent-by-default compositor rule will produce identical-looking ghosting that is not your bug.)
measured performance
Sustained synthetic wheel scroll, 1280×784 logical, windowless_frame_rate: 60:
- release: 59 paint/s, browser process ~7 % of one core
- debug: 57 paint/s, ~9 % of one core
That is the CEF frame cap, not a ceiling you are hitting. Subjectively indistinguishable from a normal browser at that size, with a small extra latency consistent with a one-frame-plus-pump-interval pipeline.
The CPU path will not scale to 4K/HiDPI — the per-frame copy is O(pixels) and
the atlas upload becomes the bottleneck. The zero-copy fix is
on_accelerated_paint + SharedTextureHandle → DMA-BUF import (cef-rs already
implements the import). It requires GPUI to expose its wgpu::Device or accept
an externally-created texture as an ImageSource. That is a fork-or-upstream
decision, not a code decision — scope it early or accept the CPU path
deliberately. Ship the CPU path first; it is ~30 lines.
6. the gotchas, in the order you will hit them
Each of these cost real time in the reference build.
-
CEF and your GUI toolkit must agree on Wayland vs X11. CEF picks its ozone platform from the session environment independently of which backend GPUI chose. If they disagree the process aborts at startup (
Failed to connect to Wayland display→cef initialize failed). Fix: inon_before_command_line_processing, append--ozone-platform=x11whenWAYLAND_DISPLAYis unset, so CEF follows the toolkit. Choose the two together, deliberately, at one place in the code. -
device_scale_factor: logical in, device out. Hardcodingscreen_info.device_scale_factor = 1.0while the window reports 1.1× gives an upscaled, blurry texture.view_rectmust stay in logical px whilescreen_inforeports the real scale; CEF then paints at device pixels. Plumb both fromwindow.scale_factor(). Never pre-multiply coordinates by the scale factor in the event structs. -
Keyboard needs two events.
RAWKEYDOWNalone does nothing visible in a text field. TheCHARevent, withcharacterset as a UTF-16 unit, is what inserts text. Enter, backspace and tab also need an explicitCHAR. -
native_key_codehas no portable source. GPUI'sKeystrokedoes not expose the hardware keycode, so the reference reconstructs it from a US/evdev table. Correct on a US layout; wrong scancode (right logical key) on others. Chromium uses it for layout-sensitive shortcuts. If you care, get the platform keycode properly. -
Popup widgets must be blitted inside the CEF seam.
OnPaintwithPaintElementType != PET_VIEW— native<select>dropdowns, autofill — is a separate buffer that a naive implementation drops on the floor, so<select>silently does nothing. Composite the popup buffer into the main buffer at the rect fromOnPopupSize. Do it inside the CEF crate: zero app-side changes, and the app never learns that popups exist. -
Set
root_cache_path. Unset, CEF warns about process-singleton behaviour on every start. Set it per-profile; it is also what makes logins survive restarts. -
Shut CEF down cleanly at exit (
close every browser→ wait forOnBeforeClose→cef::shutdown()). Be honest about why: the real win is an orderly profile flush, not reaping zombies — the kernel already does that. Killing the process leaves the profile at risk, not the process table. -
Chrome-only repaints need an explicit refresh.
cx.notify()re-runsrender, but with a static page producing no CEF frames, the X11 backend will keep presenting the previous frame until the next mouse event. A sidebar toggle changed in state and not on screen. Every chrome-only state change (toggle, status line, toast) must also callcx.refresh_windows(). This class of bug looks exactly like a screenshot artifact and is not one. -
Flex children shrink by default. Rows in a scrolling flex column get squished instead of scrolling when the window is short; a sidebar next to a web-content pane collapses to zero width on the frame right after a toggle, because the page texture is still sized for the old box.
flex_noneon every row and header, andmin_won the pane. -
Overlay menus must
occlude(). A click on a native context-menu row was delivered to the interactive element behind the menu and navigated the tab. Oneocclude()on the menu pane fixes it; check every overlay you have, the bug is latent in all of them. -
--no-sandboxis required by the prebuilt distribution and it is a real gap, not a shortcut. The shippedchrome-sandboxis not root-owned setuid, so the helper cannot run. Fixing it meanschown root:root && chmod 4755on$CEF_PATH/.../chrome-sandboxat install time, or a userns sandbox. See §11 on whether you are allowed to ship anyway.
7. the jump index — "search over YOUR OWN data", generalized
This is the feature that makes the thing worth using rather than a demo. The omnibox is not a URL bar. It is a fuzzy query over the set of destinations you actually navigate to, assembled from your own systems.
the pattern
- Inventory your navigable universe first, on disk, before writing code. Write it down as a document with measured counts, per-source freshness, and sharp edges. The reference spent the first ~30 minutes of the build on this and it shaped every later decision.
- One adapter per source, each returning a uniform
Entry { title, subtitle, url, kind, sensitivity, ranking_prior }. - Every adapter may fail independently. A failed source is reported by name
with its error in the index-rebuild output; it never fails the build and never
silently returns zero.
! source.name — FAILED: <error>on the rebuild line is the entire honesty mechanism. - Cost tiers. Local sqlite/disk sources rebuild in tens of milliseconds; network/subprocess sources get a TTL cache (an hour) and a short timeout (a slow localhost is a dead localhost).
- Fuzzy match with
nucleo-matcher, ranked by score plus a per-entry prior. Sub-millisecond over ~1000 entries, so no debounce: query on every keystroke. - Anything slower than ~50 ms is a separate explicit mode, never
keystroke-live. The reference's full-text memory search takes 1.2–3 s warm
and 15 s cold, so it lives behind a
?prefix with a visible mode chip, an honest in-flight spinner, and no work at all until you press enter. - Sensitivity is a first-class field, and it gates the UI. Entries carry a sensitivity; anything marked local-only wears a visible chip and refuses share actions rather than hiding the reason. Over-marking is the safe direction — the only cost is a withheld action.
- Open the underlying stores READ-ONLY. The index is a reader. All writes go through the owning system's own API (see §8).
- Everything is env-overridable (
APP_DATA_ROOT,APP_BASE_URL,APP_CACHE_DIR, per-source binaries). This is what lets you run an isolated instance against fixtures — which you will need for tests, for screenshots, and for demoing without exposing your own data. - Cache the built index as JSON, written atomically (temp file in the same dir, then rename), so a killed process cannot leave half an index behind.
the reference's sources, as an example
984 entries, 8 sources, warm rebuild 44 ms, query <1 ms:
760 reports.db local sqlite with an FTS index — the big one
71 spoke sites registry via a company CLI (network, TTL-cached)
63 SPA routes enumerated from route files on disk at build time
36 plats/canvases same CLI
28 links a personal links store (sqlite), tiered pinned/active/inbox
12 frequents frecency over browser history — a free ranking prior
8 workshop pages HTTP against a local API (runtime directory scan)
6 dev servers a known port list, health-probed
Substitute your own: a notes database, a ticket tracker's local cache, your repos, your bookmarks, your team's runbooks, your dev servers. The properties that make a source good are (a) you navigate to it often, (b) it has a stable URL, and (c) something on your box already knows the list.
Two findings from the reference worth stealing:
- A frecency prior over your own browser history is nearly free and is by far the best ranking signal available.
- Ranking priors and display order are different things. The reference folded "pin order" into the ranking prior together with visit count; the visit term (up to +0.5) swamped the pin term (0.03/slot), so the sidebar showed pins in the wrong order. A drag-to-reorder written through on top of that wrong display would have rewritten every pin on the first drag. If a section claims to show an authoritative order, fetch that order from its owner after every rebuild — do not infer it from a score.
8. writes go through the owning system
The index reads sqlite directly. Writes never do. When the palette pins a page, moves a tier, or renames a link, it shells out to the owning system's own CLI/capability layer with an absolute path to that system's runner and its own working directory.
Why this matters more than it looks:
- the owning system keeps dedupe, ordering and validation;
- a conflicting write is refused rather than silently applied (the reference's reorder API takes the complete permutation and treats a set mismatch as a conflict — a better guarantee than anything the browser could keep locally);
- your browser stays a read-only consumer of state it does not own, which is what lets you delete or rewrite it without migrating anything.
Corollary: never read another system's secret files. The reference mints
share links by POSTing the owning API's idempotent …/share endpoint rather than
reading its 0600 token file. If a surface has no share scheme, the palette row
is greyed with the reason stated rather than guessing a URL. Greying with a
reason, everywhere, is the house style: "unavailable — this page is not in
links", "LOCAL ONLY — this surface is never shared".
9. the control socket — the dogfood loop, and it is the point
A running instance listens on a Unix socket (~/.local/share/<app>/control.sock,
mode 0600). The client lives in the same binary: app ctl <cmd>.
ctl status version+sha, pid, uptime, tabs, index, sidebar
ctl tabs list open tabs
ctl open <url> [--new-tab] [--background]
ctl navigate <url> [--tab ID]
ctl activate <ID> · ctl close <ID>
ctl reload-index rebuild, report per-source counts
ctl restart lossless restart onto the current build
--json raw reply object instead of the table
Protocol: JSON lines, request in / response out, many pairs per connection.
{"reply":"error","message":…} is the failure shape for everything. A bare
connect with no line is a liveness probe and gets no response — that is how
the server distinguishes a live socket from a stale one at startup.
Exit codes: 0 ok · 2 no server · 3 server error or bad args.
Threading. The socket server runs on its own std thread and never touches
the GUI or CEF. It parks each request in an inbox with a reply channel; the app
drains that inbox from a GUI task on the main thread every 40 ms, handles the
command with &mut App in hand, and sends the reply back. Socket IO therefore
never blocks the UI thread, and CEF is only ever touched from the thread that
initialized it. Two commands answer late and carry their reply channel with
them: reload-index answers after the rebuild lands (so the counts describe the
index actually in use), and restart answers before the exec.
how a lossless restart works — and the deleted-inode trap
Measured blip: 0.65 s / 0.69 s, wall clock from issuing restart to the new
process answering status, with every tab restored.
- Flush the session synchronously. The debounce is not good enough here — that is exactly the lossless promise.
- Reply
restarting, then wait up to 750 ms for the connection thread to confirm the bytes are out. Exec-replacing before the client reads its reply is indistinguishable from a crash. - Close every browser and pump the message loop for ~300 ms so the child processes let go of the profile.
execv(<the binary this process booted from>, argv + --restarted).
The trap, and it is expensive: use std::env::current_exe() canonicalized
at boot, not /proc/self/exe at restart time. Cargo replaces the binary by
rename, so by restart time /proc/self/exe resolves to the deleted inode and
you silently re-run the build you were trying to replace. In the reference this
shipped, and three restarts showed no sign of a new code path that appeared
instantly on kill+relaunch. Until it was fixed, every previous round's "restart
and check" verification was worthless. Fall back to a $APP_BIN env var from
the runner, then /proc/self/exe as a last resort, so a restart can never become
a crash — and if execv fails, keep running the old build and say so.
execv, not spawn-and-exit, for three reasons: the pid survives (anything
watching it keeps watching), the environment survives (LD_LIBRARY_PATH from the
runner is what lets CEF find libcef.so), and there is no window in which two
processes contend for the CEF profile lock.
One related trap: do not ctl restart onto a binary you have just rebuilt
while CEF children are alive if your rebuild touched the CEF child path. The
old process execs the new binary, but CEF's helper launches then fail
(failed to execvp: …/app (deleted)) and the devtools port can end up rebound on
[::1] instead of 127.0.0.1. Kill and relaunch in that case.
Show a receipt. A restart and a crash-plus-relaunch look identical from the
outside; a 3 s toast (restarted · v0.1.0 (94f861aa2+)) and the client's
back in 0.69s … line are what make the difference visible.
Deliberately no quit command. An agent should be able to replace your
browser, not close it.
10. singleton — one instance, always
Two instances is not a degraded browser, it is a corrupt one: they fight over the session file, they both write through to your data store, and only one can own the debug port. The rule to implement is relaunch wins — launching from the app menu always yields exactly one live, visible instance with your tabs in it.
Run the claim in main after the CEF child-process fork check (so helper
re-execs never do it) and before CEF initialize (so the old instance has
released the profile, the port and the socket before the new one wants them):
flockan exclusive lock file. Two launchers double-tapped a second apart serialize here.- Read a pidfile (
pid\nexe), written by this same sequence. The pidfile, not the socket, is what makes the race deterministic: an instance three seconds into CEF init has no socket yet but does have a pidfile; without it, two simultaneous launches both probe an empty socket and both win. - If that pid is alive and
/proc/<pid>/exereally is your binary, send{"cmd":"takeover","pid":<ours>}on the control socket. The old instance flushes its session synchronously, replies, waits for the bytes to land, unlinks the socket, and runs the full quit path. The claimant then waits for the pid to actually die. - If it never answers or never dies — wedged,
SIGSTOPped, mid-init, or an older build that does not know the command — escalateSIGTERM→SIGKILL. - Write your own pidfile, drop the lock, start normally; the session loader then restores what the old instance just flushed.
Check /proc/<pid>/exe immediately before every signal, and never signal a pid
you cannot prove is yours — a stale pidfile whose pid has been recycled must
not cost a bystander its life. Strip the kernel's " (deleted)" suffix first:
after a rebuild that is the normal state of a running instance.
Measured, live, with six real tabs open:
| case | wall clock | result |
|---|---|---|
| graceful takeover | 0.51 s (0.16–0.18 s of that is the handshake) | old pid gone, all six tabs restored, one window, socket + port owned by the newcomer |
wedged (SIGSTOP) |
5.43 s | no reply in 3 s → SIGTERM (a stopped process cannot handle it) → SIGKILL; tabs survived via the last debounced flush |
| three launches at once | one window | a takeover chain, 0.49 s and 0.52 s, no kills |
Known edges to write down rather than paper over: a merely-busy instance can be
killed rather than asked nicely (a blocking index rebuild starves the 40 ms drain
loop), and if flock itself fails on an unwritable state dir, proceed unlocked
and say so on stderr — a browser that refuses to start because a lock file was
unwritable is the worse product.
Do not expose takeover as a ctl verb. Closing the user's browser and
replacing it are different acts; a takeover always leaves a window on screen.
11. session persistence and on-disk state
| path | what |
|---|---|
~/.local/share/<app>/session.json |
tabs (url, title, order, active, pinned) + UI flags + the closed-tab stack. Atomic write, ~400 ms debounce, written continuously. |
~/.local/share/<app>/session.json.bad |
appears only if the session file was corrupt; the old one is moved aside, never deleted. |
~/.local/share/<app>/cef-profile/ |
CEF root_cache_path — cookies, localStorage, HTTP cache. This is why logins survive restarts. |
~/.local/share/<app>/control.sock |
mode 0600; stale ones reaped at startup, live ones refuse the bind |
~/.local/share/<app>/scry.pid, singleton.lock |
§10 |
~/.cache/<app>/jump-index.json |
the built index |
Discipline that pays for itself immediately: every key added to the session format after v0.1 is absent-tolerant (serde defaults) and covered by a test that loads a v0.1 file and asserts the defaults. You will add keys — pinned, sidebar visibility, the reopen stack — on a weekly basis, and each one is a chance to break every existing user's session on upgrade.
The reopen stack (ctrl+shift+t) is capped (50) and persisted, so it survives
restart. Note the binding: ctrl+shift+tab is already cycle-backwards; take
Chromium's ctrl+shift+t.
12. agent control over the browser — CDP
Enable Chromium's DevTools endpoint (Settings.remote_debugging_port) and every
open tab becomes readable and scriptable from outside the process. This is what
turns the browser into an organ of whatever agent system you run.
cdp tabs [--json]
cdp text|md|html [target]
cdp shot [target] -o out.png [--full]
cdp eval [target] <js>
cdp navigate <target> <url>
target = a target id, a unique id prefix, or a case-insensitive substring
of url/title. Omitted = first page target.
What it buys: reading what the user is actually looking at without re-fetching the URL (works behind session cookies, for client-side-only pages, and for localhost dev servers); screenshotting any tab including background ones for report attachments or visual diffs; putting a page in front of the user instead of sending a link.
the division of labour, and the invisible-orphan hole
Open tabs with the control socket. Read and script them with CDP.
Target.createTarget (the obvious "open a tab" call) is unusable and should be
dropped from your CLI. Verified: it creates a real browser-level target — it
appears in tabs, it can be evaluated and screenshotted, at a different
viewport size — but the shell knows nothing about it. It is not in the tab strip,
not in the session file, and never painted anywhere. It is an invisible orphan
the user can neither see nor close. This is exactly the hole ctl open exists
to fill.
OSR quirks — measured, not assumed
Page.captureScreenshotworks under OSR and does not go through your compositor at all: Chromium renders into its own surface, so the PNG is the page, without your native chrome. (1517×935 for a 1400×863 CSS viewport at DPR ~1.08.)- Background tabs screenshot correctly — real content, not blank or stale.
captureBeyondViewport: trueworks (a 4000 px document captured as 2530×4000). On fixed-height scroll-container layouts it looks like a no-op; that is the page, not the flag.- Popup widgets that you composite yourself do not appear in CDP screenshots.
- Make sessions per-command — connect, call, exit. Nothing left holding a devtools socket.
Implementation note: ureq + tungstenite + serde. No async runtime, no
headless_chrome. Split markdown conversion deliberately: a DOM-walking JS
heuristic returns a flat block list (heading/para/li/quote/code/link/rule) and
your Rust renders it — which is why the conversion is unit-testable. It prefers
<main>/<article> and drops nav/header/footer/aside/script/style and hidden
elements. It is a heuristic, not a readability port.
Port choice: avoid Chromium's conventional 9222 (9223 in the reference) so
you never collide with a real Chrome. Bind localhost only — Chromium does this
unless --remote-debugging-address says otherwise, and you should never pass it.
Make the port an env knob that also accepts off.
13. pages talking to the shell — the bridge
Because both ends are yours, a page can put actions into the native command palette, raise a native toast, and open a real tab. Chromium cannot do this for your pages, because Chromium does not know what your pages are.
API v1
<api>.version |
the shell's version string. Proves which binary injected the bridge, not merely that something did. |
<api>.toast(msg) |
native status line, outside the web content. Clamped. |
<api>.openTab(url, {activate}) |
a real tab — in the strip, in the session file |
<api>.pin() |
exactly the palette's pin path, through the owning system |
<api>.actions.declare([{id,title,subtitle?}]) |
offer quick actions to the palette. Max 12; a second call replaces the set; [] retracts. |
<api>.actions.onInvoke(cb) |
cb(id) when the user runs one from the palette |
Declared actions render at the top of the palette under their own header, above the built-ins, and are cleared on navigation and on tab close — a declaration belongs to one document. Outside your browser the global is simply absent, so the same page still works in Chromium:
if (window.myapi) boot();
else window.addEventListener('myapi-ready', boot, { once: true });
transport: CDP loopback (and the honest alternative)
The app connects to its own DevTools endpoint on localhost. One background thread, one browser-level websocket:
Target.setAutoAttach {waitForDebuggerOnStart, flatten}plusTarget.setDiscoverTargets;- per session:
Runtime.addBinding {name:"__post"}(page → shell),Page.addScriptToEvaluateOnNewDocument(the stub, before page scripts), a one-shotRuntime.evaluateof the same stub for an already-loaded document, thenRuntime.runIfWaitingForDebugger; - page → shell arrives as
Runtime.bindingCalled; - shell → page (invoking a declared action) does not use CDP — the shell
already knows the tab, so it is a direct
CefFrame::ExecuteJavaScript.
The alternative is CEF's message router / on_context_created +
send_process_message. It is the canonical answer and it is available in
cef-rs 151. It was not taken because the page-facing half is the expensive half:
window.<api>'s methods must be real V8 functions backed by a CefV8Handler
implemented through generated C bindings, plus a process-message protocol on both
sides — a night of unsafe glue to replace something already working. The honest
trade:
| CDP loopback | CEF message router | |
|---|---|---|
| renderer-side unsafe code | none | required |
| works with OSR | verified | expected |
| injection before first script | yes, except on a brand-new tab (mitigated) | always |
| cost if CDP is disabled | the bridge is off | unaffected |
| latency | one localhost websocket hop | in-process |
Keep the seam swappable: the transport owns run()/install(); the message
types, the stub and the per-tab action registry are transport-agnostic.
bridge quirks — the expensive one is #3
Runtime.addBinding+Page.addScriptToEvaluateOnNewDocumentwork fine under OSR. No windowless-specific problem appeared at all.- Auto-attach does not cover a tab CEF creates itself. With
setAutoAttachalone, tabs opened byctrl+t/ctl opennever attached.Target.setDiscoverTargets+Target.attachToTargetontargetCreatedis what covers them. - A brand-new CEF tab attaches as
type: "other"with an empty url, and only becomestype: "page"in a latertargetInfoChanged. Filtering ontype == "page"at attach time silently skips every new tab. This was the single most expensive bug of the round. - You cannot use a request/reply helper on the bridge socket: it discards
events while waiting for its reply, which eats
bindingCalled. Fire commands and read every frame, logging anyerrorreply — a fire-and-forget command that fails silently is precisely the shape of an instrument that lies. - The injection race is real for a tab you have just created. Two
mitigations, and you need both: dispatch a
<api>-readyevent onwindowwhen the stub installs, and have the stub queue messages until the shell's binding lands (retry ~10 s) so an earlydeclare()is delayed rather than lost. - A message must name its tab, or "pin this page" pins the wrong page. Two mechanisms in order: (a) when the app creates a tab it announces it to the bridge, which matches on attach by url — or takes the oldest announcement when the target has not navigated yet — and stamps a tab id into the paused document; (b) a url fallback that refuses when two tabs share that url rather than guessing.
14. trust model — state it, then live with it
Write this down explicitly, because three separate features quietly depend on it:
Content is trusted: these are pages we control. CEF updates on our schedule. OAuth bounce pages are the one asterisk.
Consequences, all of which you must state plainly rather than bury:
- The Chromium sandbox is off. With a trusted-content model this is survivable; with untrusted content it is not. Fix it before that changes.
- The control socket has no auth.
0600file mode is the access control. Anything running as you can drive the browser. - CDP has no auth, by design. Anything that can open a TCP connection to localhost:9223 can read every open tab — including logged-in sessions — and execute JS in them. On a shared or untrusted machine, turn it off with the env knob.
- Every page gets the bridge. No per-origin gate, no permission prompt. A redirect, an OAuth bounce, or a link someone sends you can raise toasts, open tabs and pin itself.
What bounds the bridge, and why it is defensible here: no new privilege — every verb is something the user can already do from the palette with one keystroke, and nothing reads data out; nothing runs without the user — a declared action is inert until selected, a page cannot invoke its own actions; shape defences — 64 KB per message, 12 actions per tab, 200 chars per string, newlines stripped, unknown message types rejected; and one switch off — disabling CDP disables the bridge.
If you ever render genuinely untrusted pages, the bridge needs an origin allowlist before that happens, not after.
15. suggested build order
This mirrors what actually worked. Each stage ends in something you can see.
Stage 0 — spike (2–3 h, disposable). One crate, its own workspace (a bare
[workspace] in its Cargo.toml, or the parent workspace's members = ["crates/*"]
will drag it in and break the build). One GPUI window rendering one CEF offscreen
browser as a texture, with mouse/click/scroll/keyboard routed in, and a native
menu drawn over the web content — that last one is the "you could not do this
in Electron" proof and it is five lines once the input layer exists. Success
criteria, written down in advance: (1) live page as a texture, (2) input routes
both ways, (3) native chrome over content, (4) frame rate measured under
sustained scroll with an on-screen counter. Write the findings to a NOTES file
including everything that did not work. Do not skip this and do not keep it.
Stage 1 — the index (parallelizable, no GUI dependency). Inventory your
surfaces as a document first, then build the headless crate: adapters, fuzzy
query, sensitivity, atomic JSON cache, a build-index binary that prints
per-source counts and per-source failures. This is load-bearing, boring, and
fully testable, and it can be built by a different agent at the same time as
stage 0 because it depends only on the inventory document.
Stage 2 — the shell (one agent, serially). The CEF seam as a production crate
(scheduled pump, double buffer, drop_image, input translation) plus the app:
window, tab strip, N tabs, omnibox over the index, session persistence with
continuous writes. Resist polish. Do not split this across parallel agents — the
crate boundary between the seam and the app is exactly where integration friction
lives.
Stage 3 — the differentiators (parallelizable across disjoint seams). A sidebar over your own data + palette actions that write through the owning system (touches the app crate) and the CDP CLI (touches the CEF crate only, for the port setting). Disjoint file sets, two agents, no collisions.
Stage 4 — the dogfood loop. Control socket, ctl open/tabs/reload-index/
restart/status, execv restart, the restart toast. Do this early, not last:
from here on every subsequent change lands under the user in 0.65 s, which
changes what it feels like to ask for a tweak. In parallel, on the CEF crate
only: popup compositing and clean shutdown.
Stage 5 — the bridge. One agent (it touches the app crate; no parallel app work this round). Ship a demo page that declares two actions, one that mutates its own DOM and one that asks the shell for a real tab.
Stage 6 — ergonomics and the handoff. Explicit slow-search mode, README, desktop entry, a keybinding quickref rendered from a single const with a test that requires each row's probe string to exist in the crate's source — so a handler cannot be deleted while its documentation quietly survives, and keybind docs can only drift by making a test go red. Sweep the known-gaps list to current truth.
Stage 7 — drive it for a morning, then fix what grates. The reference's seventh round was nine asks against a shipped shell, and it produced three bugs the batch would otherwise have shipped. This round is not optional; it is where "working demo" becomes "daily driver".
notes on running agents through this
- Retire the existential risk first. The whole plan hinged on one unknown (OSR into a GPUI texture at usable latency with input routing). Everything else was normal feature work. Spike the unknown with a short leash and explicit pass criteria; do not start the real app until it passes.
- Parallelize only on disjoint crate/file boundaries, and say the boundary out loud in the brief ("B never edits the app crate").
- Require an honest quirk log per round, including measurements that came out bad (the 0.5 % dirty-rect win, the "clean shutdown does not fix zombies, the kernel already reaped them" correction). Those entries are worth more than the feature list.
- Audit the verification, not just the deliverable. In the reference, one round's "here is the screenshot of the help overlay" screenshot showed no overlay. Re-verifying live found both that the feature did work and that a different, real bug sat next to it.
- Count your own tests correctly. An orchestrator summing
test result: oklines wrong reported 119 where the true number was 98. Your own reporting is an instrument and it can lie.
16. verification method — synthetic input and screenshots
You will want live screenshots as evidence, and a GUI app makes that awkward. What worked:
- Run on X11 (
env -u WAYLAND_DISPLAY ./run.sh) when you need synthetic input. There is no working Wayland equivalent on a locked-down box (ydotoolneeds/dev/uinputpermissions). Remember §6.1: your app must push CEF onto the same backend. - XTEST via ctypes (
libX11+libXtst, ~80 lines) for motion, buttons, keys and chords — noxdotoolrequired. XTEST delivers to the focused window, which means it steals focus. XSendEventdirectly to the window id delivers keystrokes without stealing focus, which matters if a human is using the machine. GPUI accepts them. It is less reliable for fast key sequences — some keys drop — so type slowly and verify from the app's own state (ctl tabs,ctl status) rather than from the pixels.import -window <id>for the capture, cropped to the window.- A window that is occluded or on an inactive workspace gets no new frames,
so
importsilently returns a stale image. This is the single most misleading failure mode in screenshot verification: the app state changes, the PNG does not. Move the window somewhere it is actually composited (a spare or headless output works well and disturbs nobody) and confirm againstctl statusbefore believing any screenshot. - Run screenshot sessions in an isolated instance: point
XDG_DATA_HOMEat a scratch dir and the index's data-root/cache/base-url env knobs at fixtures. With the singleton keyed off the state dir, this cannot disturb a real running instance — and it is also how you take screenshots that contain none of your own data. - Do not exercise side-effecting actions in verification just to have a
screenshot of them. "Open in Chromium", "send to
" and similar spawn real side effects in the user's real environment. Verify the menu row, the dispatch path and the existence of the backend with a test; say plainly in the caveats that the click was never fired. - If a shot ever disagrees with the on-disk session file, suspect the stale-frame class first — but check that it is not a real missing-refresh bug (§6.8).
17. known gaps in the reference, so you can price them
- Chromium sandbox disabled (§6.11, §14).
- No IME / dead keys / non-Latin input. Keyboard is
key name → VK + CHAR; composition needsime_set_compositionand the toolkit's input-handler trait. Move keyboard onto GPUI'sInputHandler/EntityInputHandlerearly if you care — IME, composition and clipboard come along with it. native_key_codereconstructed from a US/evdev table (§6.4).- CPU texture path; will not scale to 4K (§5).
- No find-in-page, downloads, JS dialogs, file pickers, drag-and-drop, permissions prompts, or in-window devtools. (CDP itself is available, so "no devtools" means no inspector UI, not no inspection.)
OnCursorChangeunimplemented in the spike — the pointer never becomes an I-beam or a link hand. Its absence is immediately noticeable; wire it early.- A URL passed on argv is ignored (argv[1] is only inspected for
ctl), so the desktop entry carries no%Ufield code. Opening a URL from outside goes throughctl open. Writing the gap down beat papering over it with a field code that would appear to work.
18. the ~30-line summary, if you read nothing else
- GPUI host + CEF 151 OSR via tauri-apps
cef. Four crates: cef seam (all unsafe), headless index, app, optional CDP CLI. - Pin GPUI to a local checkout via
[patch]; pinimageto GPUI's minor. - CEF is a 321 MB prebuilt blob outside your build path —
CEF_PATHenv,LD_LIBRARY_PATHvia a runner script. Incremental release rebuilds: 18 s. - BGRA premultiplied both sides ⇒ zero swizzle.
cx.drop_imageper frame or the atlas leaks. Double-buffer; skip dirty rects (0.5 %). - External message pump on the UI thread via
on_schedule_message_pump_work, not a fixed poll.execute_process()first inmain(). - CEF and your toolkit must agree on Wayland vs X11.
view_rectlogical,screen_inforeal scale. Keyboard needs RAWKEYDOWN and CHAR. - Blit popup
OnPaint(<select>) inside the CEF seam. - Index your own surfaces, one fallible adapter per source, read-only; writes go through the owning system; sensitivity gates the UI; slow searches are an explicit mode.
- Control socket +
ctl restartviaexecvof the canonicalized boot path (not/proc/self/exe— deleted inode). 0.65 s lossless blip. That loop is the product. - Singleton by flock + pidfile + validated
/proc/<pid>/exebefore any signal; relaunch takes over in 0.5 s with tabs intact. - CDP: read and script with CDP, open with your own control socket — CDP-created targets are invisible orphans.
- Bridge over CDP loopback:
addBinding+addScriptToEvaluateOnNewDocument,setDiscoverTargets(auto-attach misses your own new tabs), and do not filter ontype == "page"at attach — new tabs attach as"other". - Session file absent-tolerant with tests. Trust model written down. Sandbox off is a gap, not a shortcut.
Built overnight 2026-07-31 by an agent, in six OODA rounds plus a morning batch: ~156 tests, four crates, one 321 MB blob nobody had to compile.