From 166b8305985583d6b3017135f0eb1680acb71cab Mon Sep 17 00:00:00 2001 From: Eric Posen <563881+goeric@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:15:22 -0700 Subject: [PATCH 1/3] Stop Chromium running in-development features that crash the browser rodney's pinned Chromium (128.0.6568.0) is a development snapshot, and those builds apply testing/variations/fieldtrial_testing_config.json by default. That config force-enables features which are still being written, and one of them takes the browser process down: FATAL:history_embeddings_service.cc(597)] Check failed: cached_embedding != embedding_cache.end() HistoryEmbeddings computes passage embeddings on the browser's main thread after a navigation, so the CHECK aborts the whole browser rather than one tab. Every later rodney command then fails with "failed to connect to browser (is it still running?)". It only fires once the optimization guide has downloaded its on-device model, which is why a long-lived ~/.rodney profile crashes within seconds on a text-heavy page while a throwaway profile looks fine -- and why this reads as "heavy commercial sites kill the browser". Verified against a copy of a profile that reproduces it: 3/3 runs crashed before this change, 0/4 after. --disable-field-trial-config drops the testing config wholesale so rodney drives a browser with shipped defaults. HistoryEmbeddings is named explicitly as well, since a browser supplied through ROD_CHROME_BIN can enable it from a server-side variations seed that the switch does not cover. Co-Authored-By: Claude Opus 5 (1M context) --- chrome_flags.go | 33 +++++++++++++++++++++++ chrome_flags_test.go | 63 ++++++++++++++++++++++++++++++++++++++++++++ main.go | 4 ++- 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 chrome_flags.go create mode 100644 chrome_flags_test.go diff --git a/chrome_flags.go b/chrome_flags.go new file mode 100644 index 0000000..f350269 --- /dev/null +++ b/chrome_flags.go @@ -0,0 +1,33 @@ +package main + +import ( + "github.com/go-rod/rod/lib/launcher" +) + +// configureExperiments stops Chromium from running features that are still +// being written. +// +// rodney's pinned browser (128.0.6568.0) is a development snapshot, and those +// builds apply testing/variations/fieldtrial_testing_config.json by default, +// which force-enables in-development features. One of them, HistoryEmbeddings, +// computes passage embeddings on the browser's main thread after a navigation +// and CHECK-fails when its cache lookup misses, killing the browser process a +// few seconds after a text-heavy page loads: +// +// FATAL:history_embeddings_service.cc(597)] Check failed: +// cached_embedding != embedding_cache.end() +// +// It needs the on-device model the optimization guide downloads, so it only +// starts biting once a profile has been used for a while — a long-lived +// ~/.rodney profile crashes where a throwaway one does not. +// +// --disable-field-trial-config drops that config wholesale, so rodney drives a +// browser with shipped defaults rather than whatever happened to be mid-flight +// when the snapshot was cut. HistoryEmbeddings is named explicitly as well +// because a browser supplied through ROD_CHROME_BIN can enable it from a +// server-side variations seed, which that switch does not cover. +func configureExperiments(l *launcher.Launcher) *launcher.Launcher { + // Append, not Set: rod already disables features of its own. + return l.Set("disable-field-trial-config"). + Append("disable-features", "HistoryEmbeddings") +} diff --git a/chrome_flags_test.go b/chrome_flags_test.go new file mode 100644 index 0000000..dfa87f6 --- /dev/null +++ b/chrome_flags_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "strings" + "testing" + + "github.com/go-rod/rod/lib/launcher" +) + +// features returns the --disable-features values as rod would render them. +func features(l *launcher.Launcher) string { + values, ok := l.GetFlags("disable-features") + if !ok { + return "" + } + return strings.Join(values, ",") +} + +// HistoryEmbeddings runs in the browser process, so its CHECK failure takes the +// whole browser down rather than one tab. It is enabled by the field-trial +// testing config baked into the pinned Chromium snapshot. +func TestConfigureExperiments_DisablesFieldTrialConfig(t *testing.T) { + l := configureExperiments(launcher.New()) + + if !l.Has("disable-field-trial-config") { + t.Error("--disable-field-trial-config missing; the snapshot will force-enable in-development features") + } +} + +func TestConfigureExperiments_DisablesHistoryEmbeddings(t *testing.T) { + l := configureExperiments(launcher.New()) + + if !strings.Contains(features(l), "HistoryEmbeddings") { + t.Errorf("disable-features = %q, want it to contain HistoryEmbeddings", features(l)) + } +} + +// rod disables features of its own, and configureExtensions appends one more. +// Setting rather than appending would silently re-enable them. +func TestConfigureExperiments_KeepsExistingDisabledFeatures(t *testing.T) { + l := configureExperiments(launcher.New().Set("disable-features", "site-per-process", "TranslateUI")) + + got := features(l) + for _, want := range []string{"site-per-process", "TranslateUI", "HistoryEmbeddings"} { + if !strings.Contains(got, want) { + t.Errorf("disable-features = %q, want it to contain %q", got, want) + } + } +} + +// Later callers append to the same flag; the values must accumulate rather than +// replace each other. +func TestConfigureExperiments_LeavesRoomForLaterAppends(t *testing.T) { + l := configureExperiments(launcher.New().Set("disable-features", "site-per-process")) + l = l.Append("disable-features", "SomeLaterFeature") + + got := features(l) + for _, want := range []string{"site-per-process", "HistoryEmbeddings", "SomeLaterFeature"} { + if !strings.Contains(got, want) { + t.Errorf("disable-features = %q, want it to contain %q", got, want) + } + } +} diff --git a/main.go b/main.go index a5188db..6094d24 100644 --- a/main.go +++ b/main.go @@ -378,10 +378,12 @@ func cmdStart(args []string) { Set("no-sandbox"). Set("disable-gpu"). Set("single-process"). // Required for screenshots in gVisor/container environments - Leakless(false). // Keep Chrome alive after CLI exits + Leakless(false). // Keep Chrome alive after CLI exits UserDataDir(dataDir). Headless(headless) + l = configureExperiments(l) + // When in non-headless mode, make sure that we show the startup window immediately // (instead of showing a window only after calling "rodney open") if !headless { From ffe9660e0f1435ad705bb548359e4bdd79bacc39 Mon Sep 17 00:00:00 2001 From: Eric Posen <563881+goeric@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:15:42 -0700 Subject: [PATCH 2/3] Skip --single-process on macOS, where it aborts the browser --single-process collapses Chromium's renderer, GPU and utility services into the browser process. It was added so screenshots work under gVisor, whose multi-process compositor IPC hangs (notes/gvisor-screenshots/), but on macOS the collapse is fatal: media/capture's Apple backend CHECKs that it owns a CFRunLoop-enabled thread, which only holds when video capture has a utility process of its own. FATAL:video_capture_device_factory_apple.mm(37)] Check failed: mode. The MacOS video capture code must be run on a CFRunLoop-enabled thread Any page that touches navigator.mediaDevices therefore kills the whole browser. Device enumeration is routine in the fingerprinting scripts commercial sites ship, so this reads as "complex sites crash Chromium". Reproduces in one command on macOS, and stops reproducing with this change: rodney open https://example.com/ rodney js "navigator.mediaDevices.enumerateDevices().then(d=>d.length)" gVisor is a Linux sandbox, so skipping the flag on Darwin costs nothing; Linux and Windows behaviour is unchanged. This overlaps PR #45 by @flgusto, which diagnosed the same flag from crash reports. Kept here so the branch is a complete fix; drop this commit if #45 lands first. Co-Authored-By: Claude Opus 5 (1M context) --- chrome_flags.go | 23 +++++++++++++++++++++++ chrome_flags_test.go | 12 ++++++++++++ main.go | 7 +++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/chrome_flags.go b/chrome_flags.go index f350269..143a89e 100644 --- a/chrome_flags.go +++ b/chrome_flags.go @@ -1,9 +1,32 @@ package main import ( + "runtime" + "github.com/go-rod/rod/lib/launcher" ) +// singleProcessSupported reports whether --single-process is safe to pass on +// this platform. +// +// The flag collapses Chromium's renderer, GPU and utility services into the +// browser process. It is there because the multi-process compositor hangs under +// gVisor and screenshots time out (notes/gvisor-screenshots/README.md), but on +// macOS the collapse is fatal. media/capture's Apple backend CHECKs that it owns +// a CFRunLoop-enabled thread, which only holds when video capture has a utility +// process of its own, so any page that touches navigator.mediaDevices aborts the +// whole browser: +// +// FATAL:video_capture_device_factory_apple.mm(37)] Check failed: mode. +// The MacOS video capture code must be run on a CFRunLoop-enabled thread +// +// Device enumeration is routine in the fingerprinting scripts commercial sites +// ship, which is what made this look like "heavy sites crash the browser". +// Losing --single-process on macOS costs nothing: gVisor is a Linux sandbox. +func singleProcessSupported() bool { + return runtime.GOOS != "darwin" +} + // configureExperiments stops Chromium from running features that are still // being written. // diff --git a/chrome_flags_test.go b/chrome_flags_test.go index dfa87f6..350b8ca 100644 --- a/chrome_flags_test.go +++ b/chrome_flags_test.go @@ -1,6 +1,7 @@ package main import ( + "runtime" "strings" "testing" @@ -61,3 +62,14 @@ func TestConfigureExperiments_LeavesRoomForLaterAppends(t *testing.T) { } } } + +// On macOS --single-process makes any navigator.mediaDevices call abort the +// browser; on Linux it is what makes screenshots work under gVisor. +func TestSingleProcessSupported_SkipsMacOSOnly(t *testing.T) { + got := singleProcessSupported() + want := runtime.GOOS != "darwin" + + if got != want { + t.Errorf("singleProcessSupported() = %v on %s, want %v", got, runtime.GOOS, want) + } +} diff --git a/main.go b/main.go index 6094d24..4700c56 100644 --- a/main.go +++ b/main.go @@ -377,11 +377,14 @@ func cmdStart(args []string) { l := launcher.New(). Set("no-sandbox"). Set("disable-gpu"). - Set("single-process"). // Required for screenshots in gVisor/container environments - Leakless(false). // Keep Chrome alive after CLI exits + Leakless(false). // Keep Chrome alive after CLI exits UserDataDir(dataDir). Headless(headless) + if singleProcessSupported() { + l = l.Set("single-process") // Required for screenshots in gVisor/container environments + } + l = configureExperiments(l) // When in non-headless mode, make sure that we show the startup window immediately From 663fc049f82ccfca47f18fff38db813f3f360327 Mon Sep 17 00:00:00 2001 From: Eric Posen <563881+goeric@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:16:20 -0700 Subject: [PATCH 3/3] Note the macOS exception in the gVisor screenshot investigation The doc argues for --single-process and is the reason the flag exists, so record why macOS no longer gets it. Co-Authored-By: Claude Opus 5 (1M context) --- notes/gvisor-screenshots/README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/notes/gvisor-screenshots/README.md b/notes/gvisor-screenshots/README.md index 0b2a8b6..19060f7 100644 --- a/notes/gvisor-screenshots/README.md +++ b/notes/gvisor-screenshots/README.md @@ -120,6 +120,32 @@ For a CLI automation tool that controls one page at a time, these trade-offs are acceptable. The stability concern is mitigated by the fact that `rodney stop` and `rodney start` can quickly restart Chrome. +## Not applied on macOS + +The "a crash takes down the whole browser" trade-off turned out to be more than +theoretical on macOS, so `rodney start` skips `--single-process` there +(`singleProcessSupported` in `chrome_flags.go`). + +`media/capture`'s Apple backend CHECKs that it owns a CFRunLoop-enabled thread, +which only holds when video capture runs in a utility process of its own. Under +`--single-process` that CHECK fails and aborts the browser: + +``` +FATAL:video_capture_device_factory_apple.mm(37)] Check failed: mode. +The MacOS video capture code must be run on a CFRunLoop-enabled thread +``` + +Any page calling `navigator.mediaDevices.enumerateDevices()` triggers it, which +the device-fingerprinting scripts on most commercial sites do: + +```bash +rodney open https://example.com/ +rodney js "navigator.mediaDevices.enumerateDevices().then(d=>d.length)" +``` + +gVisor is a Linux sandbox, so nothing in this investigation is lost by skipping +the flag on Darwin. Linux and Windows keep it. + ## Reproducing The test script `screenshot_test.go` in this directory reproduces the investigation.