perf(cef): opt-in BeginFrame from the frame clock and pin the OSR rate (#429)
* perf(cef): drive BeginFrame from the frame clock and allow pinning the rate Pins the bridge revision that carries the presenter knobs, and applies the two Dumber-side frame-path changes: - CEF produces frames on BeginFrame ticks driven by the GTK frame clock instead of its own timer, so frame production follows the widget's cadence; - `DUMBER_CEF_WINDOWLESS_FRAME_RATE` pins the OSR frame rate and takes precedence over the configured value and over adaptive monitor polling, so a run can be held at one cadence without editing the config file. Non-positive and unparsable frame rates fall back to the configured behaviour, and `DUMBER_CEF_EXTERNAL_BEGIN_FRAME=0` restores CEF's own timer. The rate is parsed with a 32-bit bound rather than converted from Atoi, which the linter rejects as a potential overflow. This is not an ownership change: the bridge still presents a borrowed DMA-BUF that CEF releases to its pool when the callback returns. * chore(deps): pin the render-path bridge revision Updates purego-cef2gtk to the revision that gates GtkGraphicsOffload on the runtime GTK version, reports the effective offload, import-priority and retire-limit settings in Diagnostics, and delivers batched wheel motion once per frame tick. * fix(cef): keep the external BeginFrame opt-in The env flag had been inverted into a default-on switch. Restore the canonical opt-in semantics: only an explicit true value drives CEF BeginFrame from the GTK frame clock, while unset, empty, invalid and false values keep CEF's own cadence. The tests now exercise externalBeginFrameEnabled itself instead of the generic boolean helper. The pinned OSR rate override stays: a valid positive DUMBER_CEF_WINDOWLESS_FRAME_RATE wins over the configured value and disables adaptive monitor-refresh polling, with invalid, non-positive and overflowing values falling back to the configured behaviour. That pin selects how often CEF is asked to capture frames; it is independent of the external BeginFrame request rate. The render diagnostic snapshot now also reports the effective GDK offload request, import priority and retire limit. * chore(deps): repin the bridge to the reviewed render-path revision Moves the pin to the bridge revision that reports the offload request and its installation separately, keeps offload recovery to the constructor, and delivers the accrued wheel share when a stall retires a burst. The stale go.sum lines for superseded revisions are dropped. * fix(cef): log the pinned OSR rate and cover the BeginFrame call site A pinned OSR rate silently suspended adaptive polling without saying so in the startup log, so the cef: configured engine line now reports windowless_frame_rate_pinned next to the rate it resolves to. The BeginFrame opt-in was only ever asserted through the boolean helper, which is how an inverted default shipped; a call-site test now drives configureNativePopupWindow over absent, empty, false, invalid and true values and checks the WindowInfo field itself. The render diagnostic snapshot also reports gdk_offload_installed next to the request. * chore(deps): pin purego-cef2gtk v0.11.0 The released tag carries the same content as the pseudo-version this branch pinned (the bridge pull request was squash-merged), so this swaps a branch-scoped revision for the released one without changing behaviour. * feat(cef): enable external BeginFrame by default The embedder's frame clock is the cadence the compositor samples, and the tick implementation already exists, so CEF now waits for a BeginFrame request on each tick instead of running its own timer. The environment variable stays as the opt-out: 0, false, no or off restores CEF's internal cadence. This is a deliberate default under observation rather than a measured win. The tick loop requests a frame on every tick regardless of the configured capture rate, and no frame-timing measurement shows an improvement over CEF's own timer yet, so the default is being dogfooded and can be reverted to opt-in without touching the implementation. The comment on the helper and the call-site test say so, and both now assert the default rather than an opt-in.
- commit
- 81c09fd1c5fd5608be698f9b56692adb04875e16
- author
- bnema · · 2026-09-11 10:24 UTC
- committer
- GitHub · · 2026-09-11 10:24 UTC
- parent
- 62dc92c
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
8 files · +180 -12
M go.mod +1 -1
| @@ -7,7 +7,7 @@ require ( | |||
| 7 | 7 | github.com/andybalholm/brotli v1.2.3 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.4 | |
| 9 | 9 | github.com/bnema/purego-cef v0.14.2 | |
| 10 | - | github.com/bnema/purego-cef2gtk v0.10.2-0.20260911042437-e9e81371d3b8 | |
| 10 | + | github.com/bnema/purego-cef2gtk v0.11.0 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.6 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.5 | |
| 13 | 13 | github.com/bnema/purego-webp v0.2.1 | |
| @@ -7,7 +7,7 @@ require ( | |||||
| 7 | github.com/andybalholm/brotli v1.2.3 | 7 | github.com/andybalholm/brotli v1.2.3 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.4 | 8 | github.com/bnema/purego v0.11.0-bnema.4 | ||
| 9 | github.com/bnema/purego-cef v0.14.2 | 9 | github.com/bnema/purego-cef v0.14.2 | ||
| 10 | - | github.com/bnema/purego-cef2gtk v0.10.2-0.20260911042437-e9e81371d3b8 | 10 | + | github.com/bnema/purego-cef2gtk v0.11.0 |
| 11 | github.com/bnema/purego-pipewire v0.1.6 | 11 | github.com/bnema/purego-pipewire v0.1.6 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.5 | 12 | github.com/bnema/purego-sqlite v0.1.5 | ||
| 13 | github.com/bnema/purego-webp v0.2.1 | 13 | github.com/bnema/purego-webp v0.2.1 | ||
M go.sum +2 -2
| @@ -14,8 +14,8 @@ github.com/bnema/purego v0.11.0-bnema.4 h1:uQjx9QtM945rD6HkwYzPGuVN7wKr5HrFKIWE3 | |||
| 14 | 14 | github.com/bnema/purego v0.11.0-bnema.4/go.mod h1:LHW8Sb4QO18dQaLVRU8tZCGM0SjsC+agFW/3M3nCUtg= | |
| 15 | 15 | github.com/bnema/purego-cef v0.14.2 h1:0tKXT00gFvWUsL3A4sX4kzD+3XRmPOif0ccwO9iokqU= | |
| 16 | 16 | github.com/bnema/purego-cef v0.14.2/go.mod h1:r+qCMwzuI+wq3xo189x3NAWWpALWoWwjtk+8wVCK1Eg= | |
| 17 | - | github.com/bnema/purego-cef2gtk v0.10.2-0.20260911042437-e9e81371d3b8 h1:9BtmuhH5raXQKXwI0+owr/iD1oT0hkjCHhS/WUPUiXk= | |
| 18 | - | github.com/bnema/purego-cef2gtk v0.10.2-0.20260911042437-e9e81371d3b8/go.mod h1:1tIL/HpDXXwNSIgtLVgumjApAEP7noO0DcHPhmEcgcU= | |
| 17 | + | github.com/bnema/purego-cef2gtk v0.11.0 h1:imn2AzfqXijtWw1NXE4E7niIja7itKQATDS/JPAEGPw= | |
| 18 | + | github.com/bnema/purego-cef2gtk v0.11.0/go.mod h1:1tIL/HpDXXwNSIgtLVgumjApAEP7noO0DcHPhmEcgcU= | |
| 19 | 19 | github.com/bnema/purego-pipewire v0.1.6 h1:olbxeiqIJZXil0pus5Uzh9psH/KTI9GovqTCuPXTquU= | |
| 20 | 20 | github.com/bnema/purego-pipewire v0.1.6/go.mod h1:57aFYl3Wll3XbrwblQjmX8R1k7RkcZQwfQTGQWcKbdc= | |
| 21 | 21 | github.com/bnema/purego-sqlite v0.1.5 h1:L4KtDQR0++TZqHVnuzCPsrw55s1ww/nXGEpdrkHsM4Y= | |
| @@ -14,8 +14,8 @@ github.com/bnema/purego v0.11.0-bnema.4 h1:uQjx9QtM945rD6HkwYzPGuVN7wKr5HrFKIWE3 | |||||
| 14 | github.com/bnema/purego v0.11.0-bnema.4/go.mod h1:LHW8Sb4QO18dQaLVRU8tZCGM0SjsC+agFW/3M3nCUtg= | 14 | github.com/bnema/purego v0.11.0-bnema.4/go.mod h1:LHW8Sb4QO18dQaLVRU8tZCGM0SjsC+agFW/3M3nCUtg= | ||
| 15 | github.com/bnema/purego-cef v0.14.2 h1:0tKXT00gFvWUsL3A4sX4kzD+3XRmPOif0ccwO9iokqU= | 15 | github.com/bnema/purego-cef v0.14.2 h1:0tKXT00gFvWUsL3A4sX4kzD+3XRmPOif0ccwO9iokqU= | ||
| 16 | github.com/bnema/purego-cef v0.14.2/go.mod h1:r+qCMwzuI+wq3xo189x3NAWWpALWoWwjtk+8wVCK1Eg= | 16 | github.com/bnema/purego-cef v0.14.2/go.mod h1:r+qCMwzuI+wq3xo189x3NAWWpALWoWwjtk+8wVCK1Eg= | ||
| 17 | - | github.com/bnema/purego-cef2gtk v0.10.2-0.20260911042437-e9e81371d3b8 h1:9BtmuhH5raXQKXwI0+owr/iD1oT0hkjCHhS/WUPUiXk= | 17 | + | github.com/bnema/purego-cef2gtk v0.11.0 h1:imn2AzfqXijtWw1NXE4E7niIja7itKQATDS/JPAEGPw= |
| 18 | - | github.com/bnema/purego-cef2gtk v0.10.2-0.20260911042437-e9e81371d3b8/go.mod h1:1tIL/HpDXXwNSIgtLVgumjApAEP7noO0DcHPhmEcgcU= | 18 | + | github.com/bnema/purego-cef2gtk v0.11.0/go.mod h1:1tIL/HpDXXwNSIgtLVgumjApAEP7noO0DcHPhmEcgcU= |
| 19 | github.com/bnema/purego-pipewire v0.1.6 h1:olbxeiqIJZXil0pus5Uzh9psH/KTI9GovqTCuPXTquU= | 19 | github.com/bnema/purego-pipewire v0.1.6 h1:olbxeiqIJZXil0pus5Uzh9psH/KTI9GovqTCuPXTquU= | ||
| 20 | github.com/bnema/purego-pipewire v0.1.6/go.mod h1:57aFYl3Wll3XbrwblQjmX8R1k7RkcZQwfQTGQWcKbdc= | 20 | github.com/bnema/purego-pipewire v0.1.6/go.mod h1:57aFYl3Wll3XbrwblQjmX8R1k7RkcZQwfQTGQWcKbdc= | ||
| 21 | github.com/bnema/purego-sqlite v0.1.5 h1:L4KtDQR0++TZqHVnuzCPsrw55s1ww/nXGEpdrkHsM4Y= | 21 | github.com/bnema/purego-sqlite v0.1.5 h1:L4KtDQR0++TZqHVnuzCPsrw55s1ww/nXGEpdrkHsM4Y= | ||
M internal/infrastructure/cef/adaptive_frame_rate.go +10 -0
| @@ -23,6 +23,16 @@ func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int | |||
| 23 | 23 | }) | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | // resolveWindowlessFrameRate applies a pinned rate on top of the configured OSR | |
| 27 | + | // frame rate. A pinned rate wins over both the configured value and the adaptive | |
| 28 | + | // monitor polling, which is suspended so the pinned cadence is what CEF keeps. | |
| 29 | + | func resolveWindowlessFrameRate(configRate int32, adaptive bool) (int32, bool) { | |
| 30 | + | if pinned, ok := windowlessFrameRateOverride(); ok { | |
| 31 | + | return pinned, false | |
| 32 | + | } | |
| 33 | + | return normalizedWindowlessFrameRate(configRate), adaptive | |
| 34 | + | } | |
| 35 | + | ||
| 26 | 36 | func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() { | |
| 27 | 37 | if wv == nil || !wv.adaptiveWindowlessFrameRate { | |
| 28 | 38 | return | |
| @@ -23,6 +23,16 @@ func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int | |||||
| 23 | }) | 23 | }) | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | + | // resolveWindowlessFrameRate applies a pinned rate on top of the configured OSR | |||
| 27 | + | // frame rate. A pinned rate wins over both the configured value and the adaptive | |||
| 28 | + | // monitor polling, which is suspended so the pinned cadence is what CEF keeps. | |||
| 29 | + | func resolveWindowlessFrameRate(configRate int32, adaptive bool) (int32, bool) { | |||
| 30 | + | if pinned, ok := windowlessFrameRateOverride(); ok { | |||
| 31 | + | return pinned, false | |||
| 32 | + | } | |||
| 33 | + | return normalizedWindowlessFrameRate(configRate), adaptive | |||
| 34 | + | } | |||
| 35 | + | ||||
| 26 | func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() { | 36 | func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() { | ||
| 27 | if wv == nil || !wv.adaptiveWindowlessFrameRate { | 37 | if wv == nil || !wv.adaptiveWindowlessFrameRate { | ||
| 28 | return | 38 | return | ||
M internal/infrastructure/cef/engine_init.go +3 -2
| @@ -50,7 +50,7 @@ func NewEngine( | |||
| 50 | 50 | logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery") | |
| 51 | 51 | } | |
| 52 | 52 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 53 | - | windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate) | |
| 53 | + | windowlessFrameRate, adaptiveWindowlessFrameRate := resolveWindowlessFrameRate(cfg.WindowlessFrameRate, cfg.AdaptiveWindowlessFrameRate) | |
| 54 | 54 | renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack) | |
| 55 | 55 | if err != nil { | |
| 56 | 56 | return nil, err | |
| @@ -89,6 +89,7 @@ func NewEngine( | |||
| 89 | 89 | ||
| 90 | 90 | logger.Info(). | |
| 91 | 91 | Int32("windowless_frame_rate", windowlessFrameRate). | |
| 92 | + | Bool("windowless_frame_rate_pinned", windowlessFrameRatePinned()). | |
| 92 | 93 | Str("render_stack", string(renderStackPlan.Stack)). | |
| 93 | 94 | Str("render_backend", renderStackPlan.Backend.String()). | |
| 94 | 95 | Str("angle_backend", renderStackPlan.ANGLEBackend). | |
| @@ -109,7 +110,7 @@ func NewEngine( | |||
| 109 | 110 | ctx, | |
| 110 | 111 | eng, | |
| 111 | 112 | webViewFactoryOptions{ | |
| 112 | - | adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate, | |
| 113 | + | adaptiveWindowlessFrameRate: adaptiveWindowlessFrameRate, | |
| 113 | 114 | windowlessFrameRate: windowlessFrameRate, | |
| 114 | 115 | windowlessFrameRateMax: cfg.WindowlessFrameRateMax, | |
| 115 | 116 | inputConfig: cfg.Input, | |
| @@ -50,7 +50,7 @@ func NewEngine( | |||||
| 50 | logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery") | 50 | logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery") | ||
| 51 | } | 51 | } | ||
| 52 | cleanStaleSingletonLocks(logger, stateRoot) | 52 | cleanStaleSingletonLocks(logger, stateRoot) | ||
| 53 | - | windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate) | 53 | + | windowlessFrameRate, adaptiveWindowlessFrameRate := resolveWindowlessFrameRate(cfg.WindowlessFrameRate, cfg.AdaptiveWindowlessFrameRate) |
| 54 | renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack) | 54 | renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack) | ||
| 55 | if err != nil { | 55 | if err != nil { | ||
| 56 | return nil, err | 56 | return nil, err | ||
| @@ -89,6 +89,7 @@ func NewEngine( | |||||
| 89 | 89 | ||||
| 90 | logger.Info(). | 90 | logger.Info(). | ||
| 91 | Int32("windowless_frame_rate", windowlessFrameRate). | 91 | Int32("windowless_frame_rate", windowlessFrameRate). | ||
| 92 | + | Bool("windowless_frame_rate_pinned", windowlessFrameRatePinned()). | |||
| 92 | Str("render_stack", string(renderStackPlan.Stack)). | 93 | Str("render_stack", string(renderStackPlan.Stack)). | ||
| 93 | Str("render_backend", renderStackPlan.Backend.String()). | 94 | Str("render_backend", renderStackPlan.Backend.String()). | ||
| 94 | Str("angle_backend", renderStackPlan.ANGLEBackend). | 95 | Str("angle_backend", renderStackPlan.ANGLEBackend). | ||
| @@ -109,7 +110,7 @@ func NewEngine( | |||||
| 109 | ctx, | 110 | ctx, | ||
| 110 | eng, | 111 | eng, | ||
| 111 | webViewFactoryOptions{ | 112 | webViewFactoryOptions{ | ||
| 112 | - | adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate, | 113 | + | adaptiveWindowlessFrameRate: adaptiveWindowlessFrameRate, |
| 113 | windowlessFrameRate: windowlessFrameRate, | 114 | windowlessFrameRate: windowlessFrameRate, | ||
| 114 | windowlessFrameRateMax: cfg.WindowlessFrameRateMax, | 115 | windowlessFrameRateMax: cfg.WindowlessFrameRateMax, | ||
| 115 | inputConfig: cfg.Input, | 116 | inputConfig: cfg.Input, | ||
M internal/infrastructure/cef/env.go +39 -1
| @@ -2,11 +2,13 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | + | "strconv" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | const ( | |
| 9 | 10 | cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME" | |
| 11 | + | cefWindowlessFrameRateEnvVar = "DUMBER_CEF_WINDOWLESS_FRAME_RATE" | |
| 10 | 12 | cefEnableWebAuthnUnsafeEnvVar = "DUMBER_CEF_ENABLE_WEBAUTHN_UNSAFE" | |
| 11 | 13 | cefChromiumFlagsEnvVar = "DUMBER_CEF_CHROMIUM_FLAGS" | |
| 12 | 14 | cefEnableVAAPIEnvVar = "DUMBER_CEF_ENABLE_VAAPI" | |
| @@ -28,8 +30,44 @@ func envBoolEnabled(envVar string) bool { | |||
| 28 | 30 | } | |
| 29 | 31 | } | |
| 30 | 32 | ||
| 33 | + | // externalBeginFrameEnabled reports whether CEF produces frames on BeginFrame | |
| 34 | + | // ticks driven by the GTK frame clock instead of its own timer. It is enabled by | |
| 35 | + | // default: the embedder's frame clock is the cadence the compositor samples, so | |
| 36 | + | // asking CEF for a frame on each tick aligns production with presentation. A | |
| 37 | + | // falsy value restores CEF's internal cadence. | |
| 38 | + | // | |
| 39 | + | // This is an experimental default under observation: the tick loop requests a | |
| 40 | + | // frame on every tick regardless of the configured capture rate, and no | |
| 41 | + | // frame-timing measurement yet shows an improvement over CEF's own timer. | |
| 31 | 42 | func externalBeginFrameEnabled() bool { | |
| 32 | - | return envBoolEnabled(cefExternalBeginFrameEnvVar) | |
| 43 | + | switch strings.ToLower(strings.TrimSpace(os.Getenv(cefExternalBeginFrameEnvVar))) { | |
| 44 | + | case "0", "false", "no", "off": | |
| 45 | + | return false | |
| 46 | + | default: | |
| 47 | + | return true | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // windowlessFrameRateOverride returns an explicitly pinned OSR frame rate. A | |
| 52 | + | // pinned rate takes precedence over the configured value and disables adaptive | |
| 53 | + | // monitor-refresh polling, so a run can be compared at a single cadence. | |
| 54 | + | func windowlessFrameRateOverride() (int32, bool) { | |
| 55 | + | raw := strings.TrimSpace(os.Getenv(cefWindowlessFrameRateEnvVar)) | |
| 56 | + | if raw == "" { | |
| 57 | + | return 0, false | |
| 58 | + | } | |
| 59 | + | rate, err := strconv.ParseInt(raw, 10, 32) | |
| 60 | + | if err != nil || rate <= 0 { | |
| 61 | + | return 0, false | |
| 62 | + | } | |
| 63 | + | return int32(rate), true | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // windowlessFrameRatePinned reports whether the environment pins the OSR frame | |
| 67 | + | // rate, which also suspends adaptive monitor-refresh polling. | |
| 68 | + | func windowlessFrameRatePinned() bool { | |
| 69 | + | _, pinned := windowlessFrameRateOverride() | |
| 70 | + | return pinned | |
| 33 | 71 | } | |
| 34 | 72 | ||
| 35 | 73 | func cefWebAuthnUnsafeEnabled() bool { | |
| @@ -2,11 +2,13 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "os" | 4 | "os" | ||
| 5 | + | "strconv" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| 8 | const ( | 9 | const ( | ||
| 9 | cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME" | 10 | cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME" | ||
| 11 | + | cefWindowlessFrameRateEnvVar = "DUMBER_CEF_WINDOWLESS_FRAME_RATE" | |||
| 10 | cefEnableWebAuthnUnsafeEnvVar = "DUMBER_CEF_ENABLE_WEBAUTHN_UNSAFE" | 12 | cefEnableWebAuthnUnsafeEnvVar = "DUMBER_CEF_ENABLE_WEBAUTHN_UNSAFE" | ||
| 11 | cefChromiumFlagsEnvVar = "DUMBER_CEF_CHROMIUM_FLAGS" | 13 | cefChromiumFlagsEnvVar = "DUMBER_CEF_CHROMIUM_FLAGS" | ||
| 12 | cefEnableVAAPIEnvVar = "DUMBER_CEF_ENABLE_VAAPI" | 14 | cefEnableVAAPIEnvVar = "DUMBER_CEF_ENABLE_VAAPI" | ||
| @@ -28,8 +30,44 @@ func envBoolEnabled(envVar string) bool { | |||||
| 28 | } | 30 | } | ||
| 29 | } | 31 | } | ||
| 30 | 32 | ||||
| 33 | + | // externalBeginFrameEnabled reports whether CEF produces frames on BeginFrame | |||
| 34 | + | // ticks driven by the GTK frame clock instead of its own timer. It is enabled by | |||
| 35 | + | // default: the embedder's frame clock is the cadence the compositor samples, so | |||
| 36 | + | // asking CEF for a frame on each tick aligns production with presentation. A | |||
| 37 | + | // falsy value restores CEF's internal cadence. | |||
| 38 | + | // | |||
| 39 | + | // This is an experimental default under observation: the tick loop requests a | |||
| 40 | + | // frame on every tick regardless of the configured capture rate, and no | |||
| 41 | + | // frame-timing measurement yet shows an improvement over CEF's own timer. | |||
| 31 | func externalBeginFrameEnabled() bool { | 42 | func externalBeginFrameEnabled() bool { | ||
| 32 | - | return envBoolEnabled(cefExternalBeginFrameEnvVar) | 43 | + | switch strings.ToLower(strings.TrimSpace(os.Getenv(cefExternalBeginFrameEnvVar))) { |
| 44 | + | case "0", "false", "no", "off": | |||
| 45 | + | return false | |||
| 46 | + | default: | |||
| 47 | + | return true | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // windowlessFrameRateOverride returns an explicitly pinned OSR frame rate. A | |||
| 52 | + | // pinned rate takes precedence over the configured value and disables adaptive | |||
| 53 | + | // monitor-refresh polling, so a run can be compared at a single cadence. | |||
| 54 | + | func windowlessFrameRateOverride() (int32, bool) { | |||
| 55 | + | raw := strings.TrimSpace(os.Getenv(cefWindowlessFrameRateEnvVar)) | |||
| 56 | + | if raw == "" { | |||
| 57 | + | return 0, false | |||
| 58 | + | } | |||
| 59 | + | rate, err := strconv.ParseInt(raw, 10, 32) | |||
| 60 | + | if err != nil || rate <= 0 { | |||
| 61 | + | return 0, false | |||
| 62 | + | } | |||
| 63 | + | return int32(rate), true | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // windowlessFrameRatePinned reports whether the environment pins the OSR frame | |||
| 67 | + | // rate, which also suspends adaptive monitor-refresh polling. | |||
| 68 | + | func windowlessFrameRatePinned() bool { | |||
| 69 | + | _, pinned := windowlessFrameRateOverride() | |||
| 70 | + | return pinned | |||
| 33 | } | 71 | } | ||
| 34 | 72 | ||||
| 35 | func cefWebAuthnUnsafeEnabled() bool { | 73 | func cefWebAuthnUnsafeEnabled() bool { | ||
M internal/infrastructure/cef/env_test.go +8 -6
| @@ -2,32 +2,34 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | - | func TestExternalBeginFrameEnabledIsOptIn(t *testing.T) { | |
| 5 | + | func TestExternalBeginFrameEnabledIsOnByDefault(t *testing.T) { | |
| 6 | 6 | tests := []struct { | |
| 7 | 7 | name string | |
| 8 | 8 | value string | |
| 9 | 9 | want bool | |
| 10 | 10 | }{ | |
| 11 | + | {name: "unset", value: "", want: true}, | |
| 12 | + | {name: "blank", value: " ", want: true}, | |
| 11 | 13 | {name: "1", value: "1", want: true}, | |
| 12 | 14 | {name: "true", value: "true", want: true}, | |
| 13 | 15 | {name: "yes", value: "yes", want: true}, | |
| 14 | 16 | {name: "on", value: "on", want: true}, | |
| 15 | 17 | {name: "TRUE", value: "TRUE", want: true}, | |
| 16 | 18 | {name: " true ", value: " true ", want: true}, | |
| 17 | - | {name: "", value: "", want: false}, | |
| 18 | - | {name: "false", value: "false", want: false}, | |
| 19 | + | {name: "random enables", value: "random", want: true}, | |
| 19 | 20 | {name: "0", value: "0", want: false}, | |
| 21 | + | {name: "false", value: "false", want: false}, | |
| 20 | 22 | {name: "no", value: "no", want: false}, | |
| 21 | 23 | {name: "off", value: "off", want: false}, | |
| 22 | - | {name: "random", value: "random", want: false}, | |
| 24 | + | {name: "OFF", value: "OFF", want: false}, | |
| 23 | 25 | } | |
| 24 | 26 | ||
| 25 | 27 | for _, tt := range tests { | |
| 26 | 28 | t.Run(tt.name, func(t *testing.T) { | |
| 27 | 29 | t.Setenv(cefExternalBeginFrameEnvVar, tt.value) | |
| 28 | 30 | ||
| 29 | - | if got := envBoolEnabled(cefExternalBeginFrameEnvVar); got != tt.want { | |
| 30 | - | t.Fatalf("envBoolEnabled(%q) = %v, want %v", tt.value, got, tt.want) | |
| 31 | + | if got := externalBeginFrameEnabled(); got != tt.want { | |
| 32 | + | t.Fatalf("externalBeginFrameEnabled(%q) = %v, want %v", tt.value, got, tt.want) | |
| 31 | 33 | } | |
| 32 | 34 | }) | |
| 33 | 35 | } | |
| @@ -2,32 +2,34 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | - | func TestExternalBeginFrameEnabledIsOptIn(t *testing.T) { | 5 | + | func TestExternalBeginFrameEnabledIsOnByDefault(t *testing.T) { |
| 6 | tests := []struct { | 6 | tests := []struct { | ||
| 7 | name string | 7 | name string | ||
| 8 | value string | 8 | value string | ||
| 9 | want bool | 9 | want bool | ||
| 10 | }{ | 10 | }{ | ||
| 11 | + | {name: "unset", value: "", want: true}, | |||
| 12 | + | {name: "blank", value: " ", want: true}, | |||
| 11 | {name: "1", value: "1", want: true}, | 13 | {name: "1", value: "1", want: true}, | ||
| 12 | {name: "true", value: "true", want: true}, | 14 | {name: "true", value: "true", want: true}, | ||
| 13 | {name: "yes", value: "yes", want: true}, | 15 | {name: "yes", value: "yes", want: true}, | ||
| 14 | {name: "on", value: "on", want: true}, | 16 | {name: "on", value: "on", want: true}, | ||
| 15 | {name: "TRUE", value: "TRUE", want: true}, | 17 | {name: "TRUE", value: "TRUE", want: true}, | ||
| 16 | {name: " true ", value: " true ", want: true}, | 18 | {name: " true ", value: " true ", want: true}, | ||
| 17 | - | {name: "", value: "", want: false}, | 19 | + | {name: "random enables", value: "random", want: true}, |
| 18 | - | {name: "false", value: "false", want: false}, | |||
| 19 | {name: "0", value: "0", want: false}, | 20 | {name: "0", value: "0", want: false}, | ||
| 21 | + | {name: "false", value: "false", want: false}, | |||
| 20 | {name: "no", value: "no", want: false}, | 22 | {name: "no", value: "no", want: false}, | ||
| 21 | {name: "off", value: "off", want: false}, | 23 | {name: "off", value: "off", want: false}, | ||
| 22 | - | {name: "random", value: "random", want: false}, | 24 | + | {name: "OFF", value: "OFF", want: false}, |
| 23 | } | 25 | } | ||
| 24 | 26 | ||||
| 25 | for _, tt := range tests { | 27 | for _, tt := range tests { | ||
| 26 | t.Run(tt.name, func(t *testing.T) { | 28 | t.Run(tt.name, func(t *testing.T) { | ||
| 27 | t.Setenv(cefExternalBeginFrameEnvVar, tt.value) | 29 | t.Setenv(cefExternalBeginFrameEnvVar, tt.value) | ||
| 28 | 30 | ||||
| 29 | - | if got := envBoolEnabled(cefExternalBeginFrameEnvVar); got != tt.want { | 31 | + | if got := externalBeginFrameEnabled(); got != tt.want { |
| 30 | - | t.Fatalf("envBoolEnabled(%q) = %v, want %v", tt.value, got, tt.want) | 32 | + | t.Fatalf("externalBeginFrameEnabled(%q) = %v, want %v", tt.value, got, tt.want) |
| 31 | } | 33 | } | ||
| 32 | }) | 34 | }) | ||
| 33 | } | 35 | } | ||
M internal/infrastructure/cef/render_diagnostics.go +4 -0
| @@ -102,6 +102,10 @@ func (wv *WebView) logRenderDiagnosticSnapshot( | |||
| 102 | 102 | Int("pending_reschedules", diag.PendingReschedules). | |
| 103 | 103 | Int("pending_schedule_failures", diag.PendingScheduleFailures). | |
| 104 | 104 | Int("pending_idle_callbacks", diag.PendingIdleCallbacks). | |
| 105 | + | Bool("gdk_offload_requested", diag.OffloadRequested). | |
| 106 | + | Bool("gdk_offload_installed", diag.OffloadInstalled). | |
| 107 | + | Int("gdk_import_priority", diag.ImportPriority). | |
| 108 | + | Int("gdk_retire_limit", diag.RetireLimit). | |
| 105 | 109 | Int32("surface_width", width). | |
| 106 | 110 | Int32("surface_height", height). | |
| 107 | 111 | Float32("surface_scale", scale). | |
| @@ -102,6 +102,10 @@ func (wv *WebView) logRenderDiagnosticSnapshot( | |||||
| 102 | Int("pending_reschedules", diag.PendingReschedules). | 102 | Int("pending_reschedules", diag.PendingReschedules). | ||
| 103 | Int("pending_schedule_failures", diag.PendingScheduleFailures). | 103 | Int("pending_schedule_failures", diag.PendingScheduleFailures). | ||
| 104 | Int("pending_idle_callbacks", diag.PendingIdleCallbacks). | 104 | Int("pending_idle_callbacks", diag.PendingIdleCallbacks). | ||
| 105 | + | Bool("gdk_offload_requested", diag.OffloadRequested). | |||
| 106 | + | Bool("gdk_offload_installed", diag.OffloadInstalled). | |||
| 107 | + | Int("gdk_import_priority", diag.ImportPriority). | |||
| 108 | + | Int("gdk_retire_limit", diag.RetireLimit). | |||
| 105 | Int32("surface_width", width). | 109 | Int32("surface_width", width). | ||
| 106 | Int32("surface_height", height). | 110 | Int32("surface_height", height). | ||
| 107 | Float32("surface_scale", scale). | 111 | Float32("surface_scale", scale). | ||
A internal/infrastructure/cef/render_experiment_test.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strconv" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestWindowlessFrameRatePinnedFollowsTheOverride(t *testing.T) { | |
| 11 | + | cases := []struct { | |
| 12 | + | value string | |
| 13 | + | want bool | |
| 14 | + | }{ | |
| 15 | + | {value: "", want: false}, | |
| 16 | + | {value: "60", want: true}, | |
| 17 | + | {value: " 144 ", want: true}, | |
| 18 | + | {value: "0", want: false}, | |
| 19 | + | {value: "-60", want: false}, | |
| 20 | + | {value: "many", want: false}, | |
| 21 | + | {value: strconv.FormatInt(int64(1)<<40, 10), want: false}, | |
| 22 | + | } | |
| 23 | + | for _, testCase := range cases { | |
| 24 | + | t.Run("pin:"+testCase.value, func(t *testing.T) { | |
| 25 | + | t.Setenv(cefWindowlessFrameRateEnvVar, testCase.value) | |
| 26 | + | if got := windowlessFrameRatePinned(); got != testCase.want { | |
| 27 | + | t.Fatalf("windowlessFrameRatePinned(%q) = %v, want %v", testCase.value, got, testCase.want) | |
| 28 | + | } | |
| 29 | + | }) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestConfigureNativePopupWindowEnablesExternalBeginFrameByDefault(t *testing.T) { | |
| 34 | + | // The default is only worth asserting through a real call site: an inverted | |
| 35 | + | // default shipped before because only the boolean helper was covered. | |
| 36 | + | cases := []struct { | |
| 37 | + | value string | |
| 38 | + | want int32 | |
| 39 | + | }{ | |
| 40 | + | {value: "", want: 1}, | |
| 41 | + | {value: "1", want: 1}, | |
| 42 | + | {value: "true", want: 1}, | |
| 43 | + | {value: "on", want: 1}, | |
| 44 | + | {value: "random", want: 1}, | |
| 45 | + | {value: "0", want: 0}, | |
| 46 | + | {value: "false", want: 0}, | |
| 47 | + | {value: "off", want: 0}, | |
| 48 | + | } | |
| 49 | + | for _, testCase := range cases { | |
| 50 | + | t.Run("call-site:"+testCase.value, func(t *testing.T) { | |
| 51 | + | t.Setenv(cefExternalBeginFrameEnvVar, testCase.value) | |
| 52 | + | windowInfo := purecef.NewWindowInfo() | |
| 53 | + | settings := purecef.NewBrowserSettings() | |
| 54 | + | ||
| 55 | + | configureNativePopupWindow(&windowInfo, &settings, 60) | |
| 56 | + | ||
| 57 | + | if windowInfo.ExternalBeginFrameEnabled != testCase.want { | |
| 58 | + | t.Fatalf("ExternalBeginFrameEnabled with %q = %d, want %d", | |
| 59 | + | testCase.value, windowInfo.ExternalBeginFrameEnabled, testCase.want) | |
| 60 | + | } | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestResolveWindowlessFrameRatePrefersAPinnedRate(t *testing.T) { | |
| 66 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "") | |
| 67 | + | ||
| 68 | + | rate, adaptive := resolveWindowlessFrameRate(0, true) | |
| 69 | + | if rate != defaultCEFWindowlessFrameRate || !adaptive { | |
| 70 | + | t.Fatalf("unpinned resolve = (%d, %v), want (%d, true)", rate, adaptive, defaultCEFWindowlessFrameRate) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | rate, adaptive = resolveWindowlessFrameRate(90, true) | |
| 74 | + | if rate != 90 || !adaptive { | |
| 75 | + | t.Fatalf("configured resolve = (%d, %v), want (90, true)", rate, adaptive) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | rate, adaptive = resolveWindowlessFrameRate(90, false) | |
| 79 | + | if rate != 90 || adaptive { | |
| 80 | + | t.Fatalf("non-adaptive resolve = (%d, %v), want (90, false)", rate, adaptive) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "144") | |
| 84 | + | rate, adaptive = resolveWindowlessFrameRate(0, true) | |
| 85 | + | if rate != 144 || adaptive { | |
| 86 | + | t.Fatalf("pinned resolve = (%d, %v), want (144, false)", rate, adaptive) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "not-a-rate") | |
| 90 | + | rate, adaptive = resolveWindowlessFrameRate(0, true) | |
| 91 | + | if rate != defaultCEFWindowlessFrameRate || !adaptive { | |
| 92 | + | t.Fatalf("unparsable pin resolve = (%d, %v), want the configured fallback", rate, adaptive) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "0") | |
| 96 | + | rate, adaptive = resolveWindowlessFrameRate(60, true) | |
| 97 | + | if rate != 60 || !adaptive { | |
| 98 | + | t.Fatalf("zero pin resolve = (%d, %v), want (60, true)", rate, adaptive) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "-60") | |
| 102 | + | rate, adaptive = resolveWindowlessFrameRate(60, true) | |
| 103 | + | if rate != 60 || !adaptive { | |
| 104 | + | t.Fatalf("negative pin resolve = (%d, %v), want (60, true)", rate, adaptive) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | overflow := strconv.FormatInt(int64(1)<<40, 10) | |
| 108 | + | t.Setenv(cefWindowlessFrameRateEnvVar, overflow) | |
| 109 | + | rate, adaptive = resolveWindowlessFrameRate(60, true) | |
| 110 | + | if rate != 60 || !adaptive { | |
| 111 | + | t.Fatalf("overflowing pin resolve = (%d, %v), want (60, true)", rate, adaptive) | |
| 112 | + | } | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strconv" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestWindowlessFrameRatePinnedFollowsTheOverride(t *testing.T) { | |||
| 11 | + | cases := []struct { | |||
| 12 | + | value string | |||
| 13 | + | want bool | |||
| 14 | + | }{ | |||
| 15 | + | {value: "", want: false}, | |||
| 16 | + | {value: "60", want: true}, | |||
| 17 | + | {value: " 144 ", want: true}, | |||
| 18 | + | {value: "0", want: false}, | |||
| 19 | + | {value: "-60", want: false}, | |||
| 20 | + | {value: "many", want: false}, | |||
| 21 | + | {value: strconv.FormatInt(int64(1)<<40, 10), want: false}, | |||
| 22 | + | } | |||
| 23 | + | for _, testCase := range cases { | |||
| 24 | + | t.Run("pin:"+testCase.value, func(t *testing.T) { | |||
| 25 | + | t.Setenv(cefWindowlessFrameRateEnvVar, testCase.value) | |||
| 26 | + | if got := windowlessFrameRatePinned(); got != testCase.want { | |||
| 27 | + | t.Fatalf("windowlessFrameRatePinned(%q) = %v, want %v", testCase.value, got, testCase.want) | |||
| 28 | + | } | |||
| 29 | + | }) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestConfigureNativePopupWindowEnablesExternalBeginFrameByDefault(t *testing.T) { | |||
| 34 | + | // The default is only worth asserting through a real call site: an inverted | |||
| 35 | + | // default shipped before because only the boolean helper was covered. | |||
| 36 | + | cases := []struct { | |||
| 37 | + | value string | |||
| 38 | + | want int32 | |||
| 39 | + | }{ | |||
| 40 | + | {value: "", want: 1}, | |||
| 41 | + | {value: "1", want: 1}, | |||
| 42 | + | {value: "true", want: 1}, | |||
| 43 | + | {value: "on", want: 1}, | |||
| 44 | + | {value: "random", want: 1}, | |||
| 45 | + | {value: "0", want: 0}, | |||
| 46 | + | {value: "false", want: 0}, | |||
| 47 | + | {value: "off", want: 0}, | |||
| 48 | + | } | |||
| 49 | + | for _, testCase := range cases { | |||
| 50 | + | t.Run("call-site:"+testCase.value, func(t *testing.T) { | |||
| 51 | + | t.Setenv(cefExternalBeginFrameEnvVar, testCase.value) | |||
| 52 | + | windowInfo := purecef.NewWindowInfo() | |||
| 53 | + | settings := purecef.NewBrowserSettings() | |||
| 54 | + | ||||
| 55 | + | configureNativePopupWindow(&windowInfo, &settings, 60) | |||
| 56 | + | ||||
| 57 | + | if windowInfo.ExternalBeginFrameEnabled != testCase.want { | |||
| 58 | + | t.Fatalf("ExternalBeginFrameEnabled with %q = %d, want %d", | |||
| 59 | + | testCase.value, windowInfo.ExternalBeginFrameEnabled, testCase.want) | |||
| 60 | + | } | |||
| 61 | + | }) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestResolveWindowlessFrameRatePrefersAPinnedRate(t *testing.T) { | |||
| 66 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "") | |||
| 67 | + | ||||
| 68 | + | rate, adaptive := resolveWindowlessFrameRate(0, true) | |||
| 69 | + | if rate != defaultCEFWindowlessFrameRate || !adaptive { | |||
| 70 | + | t.Fatalf("unpinned resolve = (%d, %v), want (%d, true)", rate, adaptive, defaultCEFWindowlessFrameRate) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | rate, adaptive = resolveWindowlessFrameRate(90, true) | |||
| 74 | + | if rate != 90 || !adaptive { | |||
| 75 | + | t.Fatalf("configured resolve = (%d, %v), want (90, true)", rate, adaptive) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | rate, adaptive = resolveWindowlessFrameRate(90, false) | |||
| 79 | + | if rate != 90 || adaptive { | |||
| 80 | + | t.Fatalf("non-adaptive resolve = (%d, %v), want (90, false)", rate, adaptive) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "144") | |||
| 84 | + | rate, adaptive = resolveWindowlessFrameRate(0, true) | |||
| 85 | + | if rate != 144 || adaptive { | |||
| 86 | + | t.Fatalf("pinned resolve = (%d, %v), want (144, false)", rate, adaptive) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "not-a-rate") | |||
| 90 | + | rate, adaptive = resolveWindowlessFrameRate(0, true) | |||
| 91 | + | if rate != defaultCEFWindowlessFrameRate || !adaptive { | |||
| 92 | + | t.Fatalf("unparsable pin resolve = (%d, %v), want the configured fallback", rate, adaptive) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "0") | |||
| 96 | + | rate, adaptive = resolveWindowlessFrameRate(60, true) | |||
| 97 | + | if rate != 60 || !adaptive { | |||
| 98 | + | t.Fatalf("zero pin resolve = (%d, %v), want (60, true)", rate, adaptive) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | t.Setenv(cefWindowlessFrameRateEnvVar, "-60") | |||
| 102 | + | rate, adaptive = resolveWindowlessFrameRate(60, true) | |||
| 103 | + | if rate != 60 || !adaptive { | |||
| 104 | + | t.Fatalf("negative pin resolve = (%d, %v), want (60, true)", rate, adaptive) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | overflow := strconv.FormatInt(int64(1)<<40, 10) | |||
| 108 | + | t.Setenv(cefWindowlessFrameRateEnvVar, overflow) | |||
| 109 | + | rate, adaptive = resolveWindowlessFrameRate(60, true) | |||
| 110 | + | if rate != 60 || !adaptive { | |||
| 111 | + | t.Fatalf("overflowing pin resolve = (%d, %v), want (60, true)", rate, adaptive) | |||
| 112 | + | } | |||
| 113 | + | } | |||