diff --git a/chrome_flags.go b/chrome_flags.go new file mode 100644 index 0000000..143a89e --- /dev/null +++ b/chrome_flags.go @@ -0,0 +1,56 @@ +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. +// +// 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..350b8ca --- /dev/null +++ b/chrome_flags_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "runtime" + "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) + } + } +} + +// 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 a5188db..4700c56 100644 --- a/main.go +++ b/main.go @@ -377,11 +377,16 @@ 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 // (instead of showing a window only after calling "rodney open") if !headless { 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.