Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions chrome_flags.go
Original file line number Diff line number Diff line change
@@ -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")
}
75 changes: 75 additions & 0 deletions chrome_flags_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 7 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions notes/gvisor-screenshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down