detached c460950

Use purego-cef2gtk CEF rendering bridge

Add CEF render/input diagnostics, heartbeat watchdogs, profile logging, and GPU diagnostic tooling while hardening popup readiness and shortcut detach behavior.
commit
c460950ff0486e66f59ef8e5545e147cdccaf497
author
brice · · 2026-05-01 06:27 UTC
parent
bb2fe9c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
34 files · +2211 -69
M .gitignore +2 -1
@@ -84,10 +84,11 @@ CLAUDE.local.md
8484 memory/
8585 notes/
8686 docs/plans/
87-scripts/
87+scripts/*
8888 !scripts/generate-types.js
8989 !scripts/verify_purego_only.sh
9090 !scripts/stress_omnibox_callbacks.go
91+!scripts/cef-gpu-diagnostics.sh
9192 specs/
9293 templates/
9394 .serena/
@@ -84,10 +84,11 @@ CLAUDE.local.md
84 memory/84 memory/
85 notes/85 notes/
86 docs/plans/86 docs/plans/
87-scripts/87+scripts/*
88 !scripts/generate-types.js88 !scripts/generate-types.js
89 !scripts/verify_purego_only.sh89 !scripts/verify_purego_only.sh
90 !scripts/stress_omnibox_callbacks.go90 !scripts/stress_omnibox_callbacks.go
91+!scripts/cef-gpu-diagnostics.sh
91 specs/92 specs/
92 templates/93 templates/
93 .serena/94 .serena/
M README.md +2 -0
@@ -101,6 +101,8 @@ CEF_DIR=/custom/path dumber browse
101101
102102 Then set `engine.type = "cef"` in your config.
103103
104+CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. You normally do not need render environment variables or helper scripts. For diagnostics only, set `DUMBER_RENDER_STACK=legacy-gl` to use the older GtkGLArea/OpenGL bridge, or `DUMBER_RENDER_STACK=vulkan-dmabuf` to force the default explicitly.
105+
104106 ## Keyboard-Driven
105107
106108 Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern.
@@ -101,6 +101,8 @@ CEF_DIR=/custom/path dumber browse
101 101
102 Then set `engine.type = "cef"` in your config.102 Then set `engine.type = "cef"` in your config.
103 103
104+CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. You normally do not need render environment variables or helper scripts. For diagnostics only, set `DUMBER_RENDER_STACK=legacy-gl` to use the older GtkGLArea/OpenGL bridge, or `DUMBER_RENDER_STACK=vulkan-dmabuf` to force the default explicitly.
105+
104 ## Keyboard-Driven106 ## Keyboard-Driven
105 107
106 Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern.108 Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern.
M cmd/dumber/main.go +9 -0
@@ -199,6 +199,7 @@ func runGUI(cfg *config.Config) int {
199199 timer.Mark("config")
200200
201201 ctx := initStartupContextWithTrace(cfg)
202+ applyCEFRenderStackDefault(ctx, cfg)
202203 timer.Mark("logger")
203204 bootstrapLog := logging.FromContext(ctx)
204205
@@ -273,6 +274,7 @@ func runStandaloneOmnibox() int {
273274 cfg := initConfig()
274275 configureBrowserLaunchRelay(cfg)
275276 ctx := initStartupContextWithTrace(cfg)
277+ applyCEFRenderStackDefault(ctx, cfg)
276278
277279 initResult, err := runParallelInitPhase(ctx, cfg)
278280 if err != nil {
@@ -320,6 +322,13 @@ func runStandaloneOmnibox() int {
320322 return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
321323 }
322324
325+func applyCEFRenderStackDefault(ctx context.Context, cfg *config.Config) {
326+ if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF {
327+ return
328+ }
329+ infracef.ApplyDefaultRenderStackEnvironment(ctx)
330+}
331+
323332 func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) {
324333 if cfg == nil || initResult == nil || initAdwaita == nil {
325334 return
@@ -199,6 +199,7 @@ func runGUI(cfg *config.Config) int {
199 timer.Mark("config")199 timer.Mark("config")
200 200
201 ctx := initStartupContextWithTrace(cfg)201 ctx := initStartupContextWithTrace(cfg)
202+ applyCEFRenderStackDefault(ctx, cfg)
202 timer.Mark("logger")203 timer.Mark("logger")
203 bootstrapLog := logging.FromContext(ctx)204 bootstrapLog := logging.FromContext(ctx)
204 205
@@ -273,6 +274,7 @@ func runStandaloneOmnibox() int {
273 cfg := initConfig()274 cfg := initConfig()
274 configureBrowserLaunchRelay(cfg)275 configureBrowserLaunchRelay(cfg)
275 ctx := initStartupContextWithTrace(cfg)276 ctx := initStartupContextWithTrace(cfg)
277+ applyCEFRenderStackDefault(ctx, cfg)
276 278
277 initResult, err := runParallelInitPhase(ctx, cfg)279 initResult, err := runParallelInitPhase(ctx, cfg)
278 if err != nil {280 if err != nil {
@@ -320,6 +322,13 @@ func runStandaloneOmnibox() int {
320 return ui.RunStandaloneOmnibox(ctx, runtimeCfg)322 return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
321 }323 }
322 324
325+func applyCEFRenderStackDefault(ctx context.Context, cfg *config.Config) {
326+ if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF {
327+ return
328+ }
329+ infracef.ApplyDefaultRenderStackEnvironment(ctx)
330+}
331+
323 func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) {332 func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) {
324 if cfg == nil || initResult == nil || initAdwaita == nil {333 if cfg == nil || initResult == nil || initAdwaita == nil {
325 return334 return
M docs/config/index.md +4 -1
@@ -690,8 +690,11 @@ All config values can be overridden via environment variables with the prefix `D
690690 DUMBER_DATABASE_PATH=/custom/path/db.sqlite
691691
692692 # Rendering (examples)
693-# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto"
693+# CEF defaults to the GPU-first Vulkan DMABUF stack.
694+# Use legacy-gl only as a diagnostic fallback.
695+# DUMBER_RENDER_STACK=legacy-gl
694696 DUMBER_RENDERING_MODE=cpu
697+# WebKit-specific rendering overrides:
695698 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true
696699 # DUMBER_RENDERING_GSK_RENDERER=opengl
697700 DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5
@@ -690,8 +690,11 @@ All config values can be overridden via environment variables with the prefix `D
690 DUMBER_DATABASE_PATH=/custom/path/db.sqlite690 DUMBER_DATABASE_PATH=/custom/path/db.sqlite
691 691
692 # Rendering (examples)692 # Rendering (examples)
693-# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto"693+# CEF defaults to the GPU-first Vulkan DMABUF stack.
694+# Use legacy-gl only as a diagnostic fallback.
695+# DUMBER_RENDER_STACK=legacy-gl
694 DUMBER_RENDERING_MODE=cpu696 DUMBER_RENDERING_MODE=cpu
697+# WebKit-specific rendering overrides:
695 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true698 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true
696 # DUMBER_RENDERING_GSK_RENDERER=opengl699 # DUMBER_RENDERING_GSK_RENDERER=opengl
697 DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5700 DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5
M docs/reference/configuration.md +1 -0
@@ -59,6 +59,7 @@
5959 | `rendering.show_fps` | bool | `false` | |
6060 | `rendering.sample_memory` | bool | `false` | |
6161 | `rendering.debug_frames` | bool | `false` | |
62+| `DUMBER_RENDER_STACK` env | string | `auto` | `auto`, `vulkan-dmabuf`, `legacy-gl` |
6263 | `default_ui_scale` | float | `1.0` | > 0 |
6364 | `default_webpage_zoom` | float | `1.2` | > 0 |
6465 | `workspace.new_pane_url` | string | `about:blank` | |
@@ -59,6 +59,7 @@
59 | `rendering.show_fps` | bool | `false` | |59 | `rendering.show_fps` | bool | `false` | |
60 | `rendering.sample_memory` | bool | `false` | |60 | `rendering.sample_memory` | bool | `false` | |
61 | `rendering.debug_frames` | bool | `false` | |61 | `rendering.debug_frames` | bool | `false` | |
62+| `DUMBER_RENDER_STACK` env | string | `auto` | `auto`, `vulkan-dmabuf`, `legacy-gl` |
62 | `default_ui_scale` | float | `1.0` | > 0 |63 | `default_ui_scale` | float | `1.0` | > 0 |
63 | `default_webpage_zoom` | float | `1.2` | > 0 |64 | `default_webpage_zoom` | float | `1.2` | > 0 |
64 | `workspace.new_pane_url` | string | `about:blank` | |65 | `workspace.new_pane_url` | string | `about:blank` | |
M docs/reference/dependencies.md +4 -0
@@ -71,6 +71,10 @@ sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugl
7171 sudo pacman -S vulkan-tools # for vulkaninfo
7272 ```
7373
74+### CEF Wayland rendering
75+
76+When `engine.type = "cef"`, Dumber defaults to the GPU-first Wayland stack: GDK DMABUF presentation with ANGLE/GSK Vulkan. No launch wrapper or render environment variables are required for normal use. Set `DUMBER_RENDER_STACK=legacy-gl` only when diagnosing the older GtkGLArea/OpenGL fallback path.
77+
7478 ## Debian/Ubuntu
7579
7680 ### Core GTK4 and WebKit
@@ -71,6 +71,10 @@ sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugl
71 sudo pacman -S vulkan-tools # for vulkaninfo71 sudo pacman -S vulkan-tools # for vulkaninfo
72 ```72 ```
73 73
74+### CEF Wayland rendering
75+
76+When `engine.type = "cef"`, Dumber defaults to the GPU-first Wayland stack: GDK DMABUF presentation with ANGLE/GSK Vulkan. No launch wrapper or render environment variables are required for normal use. Set `DUMBER_RENDER_STACK=legacy-gl` only when diagnosing the older GtkGLArea/OpenGL fallback path.
77+
74 ## Debian/Ubuntu78 ## Debian/Ubuntu
75 79
76 ### Core GTK4 and WebKit80 ### Core GTK4 and WebKit
M go.mod +2 -0
@@ -81,3 +81,5 @@ require (
8181 replace github.com/bnema/purego-cef2gtk => ../purego-cef2gtk
8282
8383 tool github.com/a-h/templ/cmd/templ
84+
85+replace github.com/bnema/purego-pipewire => ../purego-pipewire
@@ -81,3 +81,5 @@ require (
81 replace github.com/bnema/purego-cef2gtk => ../purego-cef2gtk81 replace github.com/bnema/purego-cef2gtk => ../purego-cef2gtk
82 82
83 tool github.com/a-h/templ/cmd/templ83 tool github.com/a-h/templ/cmd/templ
84+
85+replace github.com/bnema/purego-pipewire => ../purego-pipewire
M internal/bootstrap/engine.go +3 -2
@@ -95,8 +95,9 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
9595 }
9696 audioFactory := audiofactory.NewAudioOutputFactory()
9797 return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{
98- StateRoot: profile.CEFUserDataDir(),
99- LogFile: profile.CEFLogFile(),
98+ StateRoot: profile.CEFUserDataDir(),
99+ LogFile: profile.CEFLogFile(),
100+ ProfileLogDir: profile.Shared.LogDir,
100101 }, cefCfg, audioFactory, deps)
101102 default:
102103 return nil, fmt.Errorf("unknown engine type: %q", engineType)
@@ -95,8 +95,9 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
95 }95 }
96 audioFactory := audiofactory.NewAudioOutputFactory()96 audioFactory := audiofactory.NewAudioOutputFactory()
97 return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{97 return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{
98- StateRoot: profile.CEFUserDataDir(),98+ StateRoot: profile.CEFUserDataDir(),
99- LogFile: profile.CEFLogFile(),99+ LogFile: profile.CEFLogFile(),
100+ ProfileLogDir: profile.Shared.LogDir,
100 }, cefCfg, audioFactory, deps)101 }, cefCfg, audioFactory, deps)
101 default:102 default:
102 return nil, fmt.Errorf("unknown engine type: %q", engineType)103 return nil, fmt.Errorf("unknown engine type: %q", engineType)
M internal/bootstrap/session.go +5 -1
@@ -118,7 +118,11 @@ func StartBrowserSession(
118118 logger.Info().Str("log_file", logPath).Msg("session log file")
119119 }
120120
121- sessionCtx := corelogging.WithContext(context.Background(), logger)
121+ sessionCtx := corelogging.WithSessionMetadata(
122+ corelogging.WithContext(context.Background(), logger),
123+ string(session.ID),
124+ logPath,
125+ )
122126
123127 bs := &BrowserSession{
124128 Session: session,
@@ -118,7 +118,11 @@ func StartBrowserSession(
118 logger.Info().Str("log_file", logPath).Msg("session log file")118 logger.Info().Str("log_file", logPath).Msg("session log file")
119 }119 }
120 120
121- sessionCtx := corelogging.WithContext(context.Background(), logger)121+ sessionCtx := corelogging.WithSessionMetadata(
122+ corelogging.WithContext(context.Background(), logger),
123+ string(session.ID),
124+ logPath,
125+ )
122 126
123 bs := &BrowserSession{127 bs := &BrowserSession{
124 Session: session,128 Session: session,
M internal/infrastructure/cef/app.go +89 -0
@@ -1,6 +1,7 @@
11 package cef
22
33 import (
4+ "os"
45 "strings"
56
67 purecef "github.com/bnema/purego-cef/cef"
@@ -11,6 +12,7 @@ import (
1112
1213 const (
1314 dumbSchemeName = "dumb"
15+ chromiumEnableFeaturesSwitch = "enable-features"
1416 chromiumDisableFeaturesSwitch = "disable-features"
1517 chromiumDisableBlinkFeaturesSwitch = "disable-blink-features"
1618 )
@@ -56,9 +58,96 @@ func configureCommandLine(commandLine purecef.CommandLine) {
5658 // Chromium blocks them, showing an infinite spinner.
5759 commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
5860
61+ configureHardwareVideoDecode(commandLine)
62+ configureEnvChromiumFlags(commandLine)
5963 configureWebAuthnFeaturePolicy(commandLine)
6064 }
6165
66+func configureHardwareVideoDecode(commandLine purecef.CommandLine) {
67+ if commandLine == nil || !envBoolEnabled(cefEnableVAAPIEnvVar) {
68+ return
69+ }
70+
71+ appendUniqueCommaSeparatedSwitchValues(commandLine, chromiumEnableFeaturesSwitch,
72+ "AcceleratedVideoDecoder",
73+ "AcceleratedVideoDecodeLinuxGL",
74+ "AcceleratedVideoDecodeLinuxZeroCopyGL",
75+ "VaapiIgnoreDriverChecks",
76+ )
77+ appendSwitchIfMissing(commandLine, "ignore-gpu-blocklist")
78+ appendSwitchIfMissing(commandLine, "enable-zero-copy")
79+}
80+
81+func configureEnvChromiumFlags(commandLine purecef.CommandLine) {
82+ if commandLine == nil {
83+ return
84+ }
85+
86+ for _, token := range parseChromiumFlagsEnv(os.Getenv(cefChromiumFlagsEnvVar)) {
87+ applyChromiumFlagToken(commandLine, token)
88+ }
89+}
90+
91+func parseChromiumFlagsEnv(raw string) []string {
92+ // Reuse Dumber's command-line tokenizer so developer env flags can contain
93+ // quoted values without adding a second shell-like parser in the CEF adapter.
94+ return parseRelaunchCommandLineArgs(raw)
95+}
96+
97+func applyChromiumFlagToken(commandLine purecef.CommandLine, token string) {
98+ if commandLine == nil {
99+ return
100+ }
101+ token = strings.TrimSpace(token)
102+ if token == "" || token == "--" {
103+ return
104+ }
105+ name, value, ok := parseChromiumSwitchToken(token)
106+ if !ok {
107+ return
108+ }
109+
110+ switch name {
111+ case chromiumEnableFeaturesSwitch, chromiumDisableFeaturesSwitch, chromiumDisableBlinkFeaturesSwitch:
112+ appendUniqueCommaSeparatedSwitchValues(commandLine, name, strings.Split(value, ",")...)
113+ default:
114+ if value == "" {
115+ appendSwitchIfMissing(commandLine, name)
116+ return
117+ }
118+ commandLine.AppendSwitchWithValue(name, value)
119+ }
120+}
121+
122+func parseChromiumSwitchToken(token string) (name, value string, ok bool) {
123+ token = strings.TrimSpace(token)
124+ if token == "" {
125+ return "", "", false
126+ }
127+
128+ switch {
129+ case strings.HasPrefix(token, "--"):
130+ token = strings.TrimPrefix(token, "--")
131+ case strings.HasPrefix(token, "-"):
132+ token = strings.TrimPrefix(token, "-")
133+ default:
134+ return "", "", false
135+ }
136+
137+ if token == "" || strings.HasPrefix(token, "-") {
138+ return "", "", false
139+ }
140+ name, value, found := strings.Cut(token, "=")
141+ name = strings.TrimSpace(name)
142+ if name == "" {
143+ return "", "", false
144+ }
145+ if !found {
146+ return name, "", true
147+ }
148+ return name, value, true
149+}
150+
62151 func configureWebAuthnFeaturePolicy(commandLine purecef.CommandLine) {
63152 if commandLine == nil {
64153 return
@@ -1,6 +1,7 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "os"
4 "strings"5 "strings"
5 6
6 purecef "github.com/bnema/purego-cef/cef"7 purecef "github.com/bnema/purego-cef/cef"
@@ -11,6 +12,7 @@ import (
11 12
12 const (13 const (
13 dumbSchemeName = "dumb"14 dumbSchemeName = "dumb"
15+ chromiumEnableFeaturesSwitch = "enable-features"
14 chromiumDisableFeaturesSwitch = "disable-features"16 chromiumDisableFeaturesSwitch = "disable-features"
15 chromiumDisableBlinkFeaturesSwitch = "disable-blink-features"17 chromiumDisableBlinkFeaturesSwitch = "disable-blink-features"
16 )18 )
@@ -56,9 +58,96 @@ func configureCommandLine(commandLine purecef.CommandLine) {
56 // Chromium blocks them, showing an infinite spinner.58 // Chromium blocks them, showing an infinite spinner.
57 commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")59 commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
58 60
61+ configureHardwareVideoDecode(commandLine)
62+ configureEnvChromiumFlags(commandLine)
59 configureWebAuthnFeaturePolicy(commandLine)63 configureWebAuthnFeaturePolicy(commandLine)
60 }64 }
61 65
66+func configureHardwareVideoDecode(commandLine purecef.CommandLine) {
67+ if commandLine == nil || !envBoolEnabled(cefEnableVAAPIEnvVar) {
68+ return
69+ }
70+
71+ appendUniqueCommaSeparatedSwitchValues(commandLine, chromiumEnableFeaturesSwitch,
72+ "AcceleratedVideoDecoder",
73+ "AcceleratedVideoDecodeLinuxGL",
74+ "AcceleratedVideoDecodeLinuxZeroCopyGL",
75+ "VaapiIgnoreDriverChecks",
76+ )
77+ appendSwitchIfMissing(commandLine, "ignore-gpu-blocklist")
78+ appendSwitchIfMissing(commandLine, "enable-zero-copy")
79+}
80+
81+func configureEnvChromiumFlags(commandLine purecef.CommandLine) {
82+ if commandLine == nil {
83+ return
84+ }
85+
86+ for _, token := range parseChromiumFlagsEnv(os.Getenv(cefChromiumFlagsEnvVar)) {
87+ applyChromiumFlagToken(commandLine, token)
88+ }
89+}
90+
91+func parseChromiumFlagsEnv(raw string) []string {
92+ // Reuse Dumber's command-line tokenizer so developer env flags can contain
93+ // quoted values without adding a second shell-like parser in the CEF adapter.
94+ return parseRelaunchCommandLineArgs(raw)
95+}
96+
97+func applyChromiumFlagToken(commandLine purecef.CommandLine, token string) {
98+ if commandLine == nil {
99+ return
100+ }
101+ token = strings.TrimSpace(token)
102+ if token == "" || token == "--" {
103+ return
104+ }
105+ name, value, ok := parseChromiumSwitchToken(token)
106+ if !ok {
107+ return
108+ }
109+
110+ switch name {
111+ case chromiumEnableFeaturesSwitch, chromiumDisableFeaturesSwitch, chromiumDisableBlinkFeaturesSwitch:
112+ appendUniqueCommaSeparatedSwitchValues(commandLine, name, strings.Split(value, ",")...)
113+ default:
114+ if value == "" {
115+ appendSwitchIfMissing(commandLine, name)
116+ return
117+ }
118+ commandLine.AppendSwitchWithValue(name, value)
119+ }
120+}
121+
122+func parseChromiumSwitchToken(token string) (name, value string, ok bool) {
123+ token = strings.TrimSpace(token)
124+ if token == "" {
125+ return "", "", false
126+ }
127+
128+ switch {
129+ case strings.HasPrefix(token, "--"):
130+ token = strings.TrimPrefix(token, "--")
131+ case strings.HasPrefix(token, "-"):
132+ token = strings.TrimPrefix(token, "-")
133+ default:
134+ return "", "", false
135+ }
136+
137+ if token == "" || strings.HasPrefix(token, "-") {
138+ return "", "", false
139+ }
140+ name, value, found := strings.Cut(token, "=")
141+ name = strings.TrimSpace(name)
142+ if name == "" {
143+ return "", "", false
144+ }
145+ if !found {
146+ return name, "", true
147+ }
148+ return name, value, true
149+}
150+
62 func configureWebAuthnFeaturePolicy(commandLine purecef.CommandLine) {151 func configureWebAuthnFeaturePolicy(commandLine purecef.CommandLine) {
63 if commandLine == nil {152 if commandLine == nil {
64 return153 return
M internal/infrastructure/cef/app_test.go +71 -0
@@ -297,6 +297,77 @@ func TestConfigureCommandLine_PreservesExistingOzonePlatform(t *testing.T) {
297297 }
298298 }
299299
300+func TestConfigureCommandLine_EnableVAAPIEnvAppendsHardwareDecodeFlags(t *testing.T) {
301+ t.Setenv(cefEnableVAAPIEnvVar, "1")
302+
303+ commandLine := newMutableCommandLineStub()
304+ commandLine.AppendSwitchWithValue(chromiumEnableFeaturesSwitch, "ExistingFeature")
305+
306+ configureCommandLine(commandLine)
307+
308+ if got := commandLine.GetSwitchValue(chromiumEnableFeaturesSwitch); got != "ExistingFeature,AcceleratedVideoDecoder,AcceleratedVideoDecodeLinuxGL,AcceleratedVideoDecodeLinuxZeroCopyGL,VaapiIgnoreDriverChecks" {
309+ t.Fatalf("enable-features = %q, want VAAPI features appended", got)
310+ }
311+ if !commandLine.HasSwitch("ignore-gpu-blocklist") {
312+ t.Fatal("expected ignore-gpu-blocklist switch")
313+ }
314+ if !commandLine.HasSwitch("enable-zero-copy") {
315+ t.Fatal("expected enable-zero-copy switch")
316+ }
317+}
318+
319+func TestConfigureCommandLine_ChromiumFlagsEnvAppendsSwitches(t *testing.T) {
320+ t.Setenv(cefChromiumFlagsEnvVar, `--enable-features=VaapiVideoDecoder,VaapiIgnoreDriverChecks --ignore-gpu-blocklist --autoplay-policy=document-user-activation-required ignored-arg`)
321+
322+ commandLine := newMutableCommandLineStub()
323+ commandLine.AppendSwitchWithValue(chromiumEnableFeaturesSwitch, "ExistingFeature")
324+
325+ configureCommandLine(commandLine)
326+
327+ if got := commandLine.GetSwitchValue(chromiumEnableFeaturesSwitch); got != "ExistingFeature,VaapiVideoDecoder,VaapiIgnoreDriverChecks" {
328+ t.Fatalf("enable-features = %q, want env features appended", got)
329+ }
330+ if !commandLine.HasSwitch("ignore-gpu-blocklist") {
331+ t.Fatal("expected ignore-gpu-blocklist switch")
332+ }
333+ if got := commandLine.GetSwitchValue("autoplay-policy"); got != "document-user-activation-required" {
334+ t.Fatalf("autoplay-policy = %q, want env override", got)
335+ }
336+ if commandLine.HasSwitch("ignored-arg") {
337+ t.Fatal("non-switch env token should be ignored")
338+ }
339+}
340+
341+func TestParseChromiumSwitchToken(t *testing.T) {
342+ t.Parallel()
343+
344+ tests := []struct {
345+ name string
346+ token string
347+ wantName string
348+ wantValue string
349+ wantOK bool
350+ }{
351+ {name: "long switch with value", token: "--enable-features=VaapiVideoDecoder", wantName: "enable-features", wantValue: "VaapiVideoDecoder", wantOK: true},
352+ {name: "long switch without value", token: "--ignore-gpu-blocklist", wantName: "ignore-gpu-blocklist", wantOK: true},
353+ {name: "single dash switch", token: "-enable-zero-copy", wantName: "enable-zero-copy", wantOK: true},
354+ {name: "empty value", token: "--flag=", wantName: "flag", wantOK: true},
355+ {name: "embedded equals", token: "--flag=a=b", wantName: "flag", wantValue: "a=b", wantOK: true},
356+ {name: "non switch", token: "youtube.com", wantOK: false},
357+ {name: "sentinel", token: "--", wantOK: false},
358+ {name: "empty", token: " ", wantOK: false},
359+ }
360+
361+ for _, tt := range tests {
362+ t.Run(tt.name, func(t *testing.T) {
363+ gotName, gotValue, gotOK := parseChromiumSwitchToken(tt.token)
364+ if gotName != tt.wantName || gotValue != tt.wantValue || gotOK != tt.wantOK {
365+ t.Fatalf("parseChromiumSwitchToken(%q) = (%q, %q, %v), want (%q, %q, %v)", tt.token, gotName, gotValue, gotOK, tt.wantName, tt.wantValue, tt.wantOK)
366+ }
367+ })
368+ }
369+}
370+
300371 func TestAppendUniqueCommaSeparatedSwitchValues(t *testing.T) {
301372 t.Run("trims whitespace and skips empty existing values", func(t *testing.T) {
302373 commandLine := newMutableCommandLineStub()
@@ -297,6 +297,77 @@ func TestConfigureCommandLine_PreservesExistingOzonePlatform(t *testing.T) {
297 }297 }
298 }298 }
299 299
300+func TestConfigureCommandLine_EnableVAAPIEnvAppendsHardwareDecodeFlags(t *testing.T) {
301+ t.Setenv(cefEnableVAAPIEnvVar, "1")
302+
303+ commandLine := newMutableCommandLineStub()
304+ commandLine.AppendSwitchWithValue(chromiumEnableFeaturesSwitch, "ExistingFeature")
305+
306+ configureCommandLine(commandLine)
307+
308+ if got := commandLine.GetSwitchValue(chromiumEnableFeaturesSwitch); got != "ExistingFeature,AcceleratedVideoDecoder,AcceleratedVideoDecodeLinuxGL,AcceleratedVideoDecodeLinuxZeroCopyGL,VaapiIgnoreDriverChecks" {
309+ t.Fatalf("enable-features = %q, want VAAPI features appended", got)
310+ }
311+ if !commandLine.HasSwitch("ignore-gpu-blocklist") {
312+ t.Fatal("expected ignore-gpu-blocklist switch")
313+ }
314+ if !commandLine.HasSwitch("enable-zero-copy") {
315+ t.Fatal("expected enable-zero-copy switch")
316+ }
317+}
318+
319+func TestConfigureCommandLine_ChromiumFlagsEnvAppendsSwitches(t *testing.T) {
320+ t.Setenv(cefChromiumFlagsEnvVar, `--enable-features=VaapiVideoDecoder,VaapiIgnoreDriverChecks --ignore-gpu-blocklist --autoplay-policy=document-user-activation-required ignored-arg`)
321+
322+ commandLine := newMutableCommandLineStub()
323+ commandLine.AppendSwitchWithValue(chromiumEnableFeaturesSwitch, "ExistingFeature")
324+
325+ configureCommandLine(commandLine)
326+
327+ if got := commandLine.GetSwitchValue(chromiumEnableFeaturesSwitch); got != "ExistingFeature,VaapiVideoDecoder,VaapiIgnoreDriverChecks" {
328+ t.Fatalf("enable-features = %q, want env features appended", got)
329+ }
330+ if !commandLine.HasSwitch("ignore-gpu-blocklist") {
331+ t.Fatal("expected ignore-gpu-blocklist switch")
332+ }
333+ if got := commandLine.GetSwitchValue("autoplay-policy"); got != "document-user-activation-required" {
334+ t.Fatalf("autoplay-policy = %q, want env override", got)
335+ }
336+ if commandLine.HasSwitch("ignored-arg") {
337+ t.Fatal("non-switch env token should be ignored")
338+ }
339+}
340+
341+func TestParseChromiumSwitchToken(t *testing.T) {
342+ t.Parallel()
343+
344+ tests := []struct {
345+ name string
346+ token string
347+ wantName string
348+ wantValue string
349+ wantOK bool
350+ }{
351+ {name: "long switch with value", token: "--enable-features=VaapiVideoDecoder", wantName: "enable-features", wantValue: "VaapiVideoDecoder", wantOK: true},
352+ {name: "long switch without value", token: "--ignore-gpu-blocklist", wantName: "ignore-gpu-blocklist", wantOK: true},
353+ {name: "single dash switch", token: "-enable-zero-copy", wantName: "enable-zero-copy", wantOK: true},
354+ {name: "empty value", token: "--flag=", wantName: "flag", wantOK: true},
355+ {name: "embedded equals", token: "--flag=a=b", wantName: "flag", wantValue: "a=b", wantOK: true},
356+ {name: "non switch", token: "youtube.com", wantOK: false},
357+ {name: "sentinel", token: "--", wantOK: false},
358+ {name: "empty", token: " ", wantOK: false},
359+ }
360+
361+ for _, tt := range tests {
362+ t.Run(tt.name, func(t *testing.T) {
363+ gotName, gotValue, gotOK := parseChromiumSwitchToken(tt.token)
364+ if gotName != tt.wantName || gotValue != tt.wantValue || gotOK != tt.wantOK {
365+ t.Fatalf("parseChromiumSwitchToken(%q) = (%q, %q, %v), want (%q, %q, %v)", tt.token, gotName, gotValue, gotOK, tt.wantName, tt.wantValue, tt.wantOK)
366+ }
367+ })
368+ }
369+}
370+
300 func TestAppendUniqueCommaSeparatedSwitchValues(t *testing.T) {371 func TestAppendUniqueCommaSeparatedSwitchValues(t *testing.T) {
301 t.Run("trims whitespace and skips empty existing values", func(t *testing.T) {372 t.Run("trims whitespace and skips empty existing values", func(t *testing.T) {
302 commandLine := newMutableCommandLineStub()373 commandLine := newMutableCommandLineStub()
M internal/infrastructure/cef/cef2gtk_adapter.go +19 -11
@@ -28,8 +28,8 @@ type Cef2gtkAdapter struct {
2828 destroyed atomic.Bool
2929 }
3030
31-// NewCef2gtkAdapter creates a GtkGLArea-backed accelerated CEF view and wraps
32-// it in a thin Dumber adapter.
31+// NewCef2gtkAdapter creates an accelerated CEF view and wraps it in a thin
32+// Dumber adapter.
3333 func NewCef2gtkAdapter() *Cef2gtkAdapter {
3434 v := cef2gtk.NewView()
3535 if v == nil {
@@ -47,8 +47,8 @@ func (a *Cef2gtkAdapter) Widget() *gtk.Widget {
4747 return a.view.Widget()
4848 }
4949
50-// GLArea returns the underlying GtkGLArea for signal connections (resize,
51-// tick, etc.). Signal connection/disconnection must happen on the GTK thread.
50+// GLArea returns the underlying GtkGLArea when the GLArea backend is active.
51+// It returns nil for the GDK DMABUF backend.
5252 func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea {
5353 if a == nil || a.destroyed.Load() || a.view == nil {
5454 return nil
@@ -56,14 +56,14 @@ func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea {
5656 return a.view.GLArea()
5757 }
5858
59-// NativeWidget returns the uintptr of the GLArea for embedding via
59+// NativeWidget returns the uintptr of the active GTK widget for embedding via
6060 // port.NativeWidgetProvider. Call on the GTK main thread.
6161 func (a *Cef2gtkAdapter) NativeWidget() uintptr {
62- area := a.GLArea()
63- if area == nil {
62+ widget := a.Widget()
63+ if widget == nil {
6464 return 0
6565 }
66- return area.GoPointer()
66+ return widget.GoPointer()
6767 }
6868
6969 // Size returns the bridge's last observed positive widget size, or 1x1 before
@@ -112,7 +112,7 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) {
112112 a.view.SetCursorFromName(name)
113113 }
114114
115-// PrepareOnGTKThread initializes GL/EGL resources owned by purego-cef2gtk.
115+// PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk.
116116 // Must be called on the GTK main thread.
117117 func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
118118 if a == nil || a.destroyed.Load() || a.view == nil {
@@ -123,7 +123,7 @@ func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
123123
124124 // RenderHandler returns a CEF render handler that delegates accelerated paint
125125 // handling to purego-cef2gtk. The returned handler satisfies cef.RenderHandler
126-// and uses the bridge's DMABUF/EGL import path.
126+// and uses the bridge's selected accelerated DMABUF import path.
127127 //
128128 // hooks may be a zero-value cef2gtk.Hooks if no custom callbacks are needed.
129129 func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {
@@ -133,6 +133,14 @@ func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandle
133133 return a.view.RenderHandler(hooks)
134134 }
135135
136+// ConfigureProfiling enables or disables purego-cef2gtk render profiling for this view.
137+func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
138+ if a == nil || a.destroyed.Load() || a.view == nil {
139+ return ErrAdapterDestroyed
140+ }
141+ return a.view.ConfigureProfiling(opts)
142+}
143+
136144 // AttachInput attaches GTK event controllers to the view and forwards input
137145 // to the given CEF browser host. Must be called on the GTK main thread.
138146 //
@@ -172,7 +180,7 @@ func (a *Cef2gtkAdapter) Diagnostics() cef2gtk.Diagnostics {
172180 return a.view.Diagnostics()
173181 }
174182
175-// Destroy releases GL resources owned by purego-cef2gtk and disconnects GTK
183+// Destroy releases renderer resources owned by purego-cef2gtk and disconnects GTK
176184 // signal handlers. Must be called on the GTK main thread. The atomic destroyed
177185 // flag only communicates lifecycle state; accesses to view are not otherwise
178186 // synchronized, so callers must ensure no concurrent adapter method is using the
@@ -28,8 +28,8 @@ type Cef2gtkAdapter struct {
28 destroyed atomic.Bool28 destroyed atomic.Bool
29 }29 }
30 30
31-// NewCef2gtkAdapter creates a GtkGLArea-backed accelerated CEF view and wraps31+// NewCef2gtkAdapter creates an accelerated CEF view and wraps it in a thin
32-// it in a thin Dumber adapter.32+// Dumber adapter.
33 func NewCef2gtkAdapter() *Cef2gtkAdapter {33 func NewCef2gtkAdapter() *Cef2gtkAdapter {
34 v := cef2gtk.NewView()34 v := cef2gtk.NewView()
35 if v == nil {35 if v == nil {
@@ -47,8 +47,8 @@ func (a *Cef2gtkAdapter) Widget() *gtk.Widget {
47 return a.view.Widget()47 return a.view.Widget()
48 }48 }
49 49
50-// GLArea returns the underlying GtkGLArea for signal connections (resize,50+// GLArea returns the underlying GtkGLArea when the GLArea backend is active.
51-// tick, etc.). Signal connection/disconnection must happen on the GTK thread.51+// It returns nil for the GDK DMABUF backend.
52 func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea {52 func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea {
53 if a == nil || a.destroyed.Load() || a.view == nil {53 if a == nil || a.destroyed.Load() || a.view == nil {
54 return nil54 return nil
@@ -56,14 +56,14 @@ func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea {
56 return a.view.GLArea()56 return a.view.GLArea()
57 }57 }
58 58
59-// NativeWidget returns the uintptr of the GLArea for embedding via59+// NativeWidget returns the uintptr of the active GTK widget for embedding via
60 // port.NativeWidgetProvider. Call on the GTK main thread.60 // port.NativeWidgetProvider. Call on the GTK main thread.
61 func (a *Cef2gtkAdapter) NativeWidget() uintptr {61 func (a *Cef2gtkAdapter) NativeWidget() uintptr {
62- area := a.GLArea()62+ widget := a.Widget()
63- if area == nil {63+ if widget == nil {
64 return 064 return 0
65 }65 }
66- return area.GoPointer()66+ return widget.GoPointer()
67 }67 }
68 68
69 // Size returns the bridge's last observed positive widget size, or 1x1 before69 // Size returns the bridge's last observed positive widget size, or 1x1 before
@@ -112,7 +112,7 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) {
112 a.view.SetCursorFromName(name)112 a.view.SetCursorFromName(name)
113 }113 }
114 114
115-// PrepareOnGTKThread initializes GL/EGL resources owned by purego-cef2gtk.115+// PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk.
116 // Must be called on the GTK main thread.116 // Must be called on the GTK main thread.
117 func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {117 func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
118 if a == nil || a.destroyed.Load() || a.view == nil {118 if a == nil || a.destroyed.Load() || a.view == nil {
@@ -123,7 +123,7 @@ func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
123 123
124 // RenderHandler returns a CEF render handler that delegates accelerated paint124 // RenderHandler returns a CEF render handler that delegates accelerated paint
125 // handling to purego-cef2gtk. The returned handler satisfies cef.RenderHandler125 // handling to purego-cef2gtk. The returned handler satisfies cef.RenderHandler
126-// and uses the bridge's DMABUF/EGL import path.126+// and uses the bridge's selected accelerated DMABUF import path.
127 //127 //
128 // hooks may be a zero-value cef2gtk.Hooks if no custom callbacks are needed.128 // hooks may be a zero-value cef2gtk.Hooks if no custom callbacks are needed.
129 func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {129 func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {
@@ -133,6 +133,14 @@ func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandle
133 return a.view.RenderHandler(hooks)133 return a.view.RenderHandler(hooks)
134 }134 }
135 135
136+// ConfigureProfiling enables or disables purego-cef2gtk render profiling for this view.
137+func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
138+ if a == nil || a.destroyed.Load() || a.view == nil {
139+ return ErrAdapterDestroyed
140+ }
141+ return a.view.ConfigureProfiling(opts)
142+}
143+
136 // AttachInput attaches GTK event controllers to the view and forwards input144 // AttachInput attaches GTK event controllers to the view and forwards input
137 // to the given CEF browser host. Must be called on the GTK main thread.145 // to the given CEF browser host. Must be called on the GTK main thread.
138 //146 //
@@ -172,7 +180,7 @@ func (a *Cef2gtkAdapter) Diagnostics() cef2gtk.Diagnostics {
172 return a.view.Diagnostics()180 return a.view.Diagnostics()
173 }181 }
174 182
175-// Destroy releases GL resources owned by purego-cef2gtk and disconnects GTK183+// Destroy releases renderer resources owned by purego-cef2gtk and disconnects GTK
176 // signal handlers. Must be called on the GTK main thread. The atomic destroyed184 // signal handlers. Must be called on the GTK main thread. The atomic destroyed
177 // flag only communicates lifecycle state; accesses to view are not otherwise185 // flag only communicates lifecycle state; accesses to view are not otherwise
178 // synchronized, so callers must ensure no concurrent adapter method is using the186 // synchronized, so callers must ensure no concurrent adapter method is using the
M internal/infrastructure/cef/context_menu.go +9 -9
@@ -76,15 +76,15 @@ func (h *handlerSet) RunContextMenu(
7676 executor := h.contextMenuExecutor()
7777 wv := h.wv
7878 wv.runOnGTK(func() {
79- if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil {
79+ if wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
8080 callback.Cancel()
8181 return
8282 }
83- glArea := wv.viewBridge.GLArea()
84- logContextMenuPopupRequest(h, glArea, params, x, y)
83+ widget := wv.viewBridge.Widget()
84+ logContextMenuPopupRequest(h, widget, params, x, y)
8585 gtkmenu.NewRenderer(nil).Show(
8686 items,
87- &glArea.Widget,
87+ widget,
8888 x,
8989 y,
9090 func(item port.MenuItem) {
@@ -126,23 +126,23 @@ func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (i
126126
127127 func logContextMenuPopupRequest(
128128 h *handlerSet,
129- glArea *gtk.GLArea,
129+ widget *gtk.Widget,
130130 params purecef.ContextMenuParams,
131131 x, y int32,
132132 ) {
133- if h == nil || h.wv == nil || h.wv.ctx == nil || glArea == nil {
133+ if h == nil || h.wv == nil || h.wv.ctx == nil || widget == nil {
134134 return
135135 }
136136 rawX, rawY := contextMenuRawPosition(params)
137- parent := glArea.GetParent()
137+ parent := widget.GetParent()
138138 logging.FromContext(h.wv.ctx).Debug().
139139 Int32("raw_x", rawX).
140140 Int32("raw_y", rawY).
141141 Int32("popup_x", x).
142142 Int32("popup_y", y).
143143 Int32("scale", h.wv.viewBridgeScale()).
144- Int("anchor_width", glArea.Widget.GetAllocatedWidth()).
145- Int("anchor_height", glArea.Widget.GetAllocatedHeight()).
144+ Int("anchor_width", widget.GetAllocatedWidth()).
145+ Int("anchor_height", widget.GetAllocatedHeight()).
146146 Int("parent_width", cefWidgetAllocatedWidth(parent)).
147147 Int("parent_height", cefWidgetAllocatedHeight(parent)).
148148 Msg("cef: context menu popup request")
@@ -76,15 +76,15 @@ func (h *handlerSet) RunContextMenu(
76 executor := h.contextMenuExecutor()76 executor := h.contextMenuExecutor()
77 wv := h.wv77 wv := h.wv
78 wv.runOnGTK(func() {78 wv.runOnGTK(func() {
79- if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil {79+ if wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
80 callback.Cancel()80 callback.Cancel()
81 return81 return
82 }82 }
83- glArea := wv.viewBridge.GLArea()83+ widget := wv.viewBridge.Widget()
84- logContextMenuPopupRequest(h, glArea, params, x, y)84+ logContextMenuPopupRequest(h, widget, params, x, y)
85 gtkmenu.NewRenderer(nil).Show(85 gtkmenu.NewRenderer(nil).Show(
86 items,86 items,
87- &glArea.Widget,87+ widget,
88 x,88 x,
89 y,89 y,
90 func(item port.MenuItem) {90 func(item port.MenuItem) {
@@ -126,23 +126,23 @@ func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (i
126 126
127 func logContextMenuPopupRequest(127 func logContextMenuPopupRequest(
128 h *handlerSet,128 h *handlerSet,
129- glArea *gtk.GLArea,129+ widget *gtk.Widget,
130 params purecef.ContextMenuParams,130 params purecef.ContextMenuParams,
131 x, y int32,131 x, y int32,
132 ) {132 ) {
133- if h == nil || h.wv == nil || h.wv.ctx == nil || glArea == nil {133+ if h == nil || h.wv == nil || h.wv.ctx == nil || widget == nil {
134 return134 return
135 }135 }
136 rawX, rawY := contextMenuRawPosition(params)136 rawX, rawY := contextMenuRawPosition(params)
137- parent := glArea.GetParent()137+ parent := widget.GetParent()
138 logging.FromContext(h.wv.ctx).Debug().138 logging.FromContext(h.wv.ctx).Debug().
139 Int32("raw_x", rawX).139 Int32("raw_x", rawX).
140 Int32("raw_y", rawY).140 Int32("raw_y", rawY).
141 Int32("popup_x", x).141 Int32("popup_x", x).
142 Int32("popup_y", y).142 Int32("popup_y", y).
143 Int32("scale", h.wv.viewBridgeScale()).143 Int32("scale", h.wv.viewBridgeScale()).
144- Int("anchor_width", glArea.Widget.GetAllocatedWidth()).144+ Int("anchor_width", widget.GetAllocatedWidth()).
145- Int("anchor_height", glArea.Widget.GetAllocatedHeight()).145+ Int("anchor_height", widget.GetAllocatedHeight()).
146 Int("parent_width", cefWidgetAllocatedWidth(parent)).146 Int("parent_width", cefWidgetAllocatedWidth(parent)).
147 Int("parent_height", cefWidgetAllocatedHeight(parent)).147 Int("parent_height", cefWidgetAllocatedHeight(parent)).
148 Msg("cef: context menu popup request")148 Msg("cef: context menu popup request")
M internal/infrastructure/cef/engine.go +11 -5
@@ -23,9 +23,10 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
2323 // Engine implements port.Engine for the CEF browser backend.
2424 // It manages the CEF lifecycle and provides access to all engine subsystems.
2525 type Engine struct {
26- ctx context.Context
27- factory *WebViewFactory
28- pool *WebViewPool
26+ ctx context.Context
27+ factory *WebViewFactory
28+ pool *WebViewPool
29+ profileLogDir string
2930
3031 messageRouter *MessageRouter
3132 schemeHandler *dumbSchemeHandler
@@ -55,6 +56,12 @@ type Engine struct {
5556 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
5657 shutdownNotify chan struct{}
5758
59+ // Cross-layer render diagnostics.
60+ cefHeartbeatStop chan struct{}
61+ cefHeartbeatDone chan struct{}
62+ cefUIHeartbeat cefThreadHeartbeatState
63+ cefIOHeartbeat cefThreadHeartbeatState
64+
5865 // Diagnostic counters.
5966 contextInitializedCount atomic.Uint64
6067 childLaunchRenderer atomic.Uint64
@@ -68,7 +75,6 @@ type Engine struct {
6875 browserCreateLastWidth atomic.Int32
6976 browserCreateLastHeight atomic.Int32
7077 browserCreateComplete atomic.Bool
71-
7278 }
7379
7480 func (e *Engine) Factory() port.WebViewFactory {
@@ -196,6 +202,7 @@ func (e *Engine) Close() error {
196202
197203 e.closeActiveWebViews()
198204 e.pool.Close()
205+ e.stopCEFHeartbeat()
199206
200207 if activeAfter := e.activeWebViewCount(); activeAfter > 0 {
201208 log.Warn().
@@ -474,4 +481,3 @@ func (e *Engine) currentDownloadHandler() *downloadHandler {
474481 defer e.downloadMu.RUnlock()
475482 return e.downloadHandler
476483 }
477-
@@ -23,9 +23,10 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
23 // Engine implements port.Engine for the CEF browser backend.23 // Engine implements port.Engine for the CEF browser backend.
24 // It manages the CEF lifecycle and provides access to all engine subsystems.24 // It manages the CEF lifecycle and provides access to all engine subsystems.
25 type Engine struct {25 type Engine struct {
26- ctx context.Context26+ ctx context.Context
27- factory *WebViewFactory27+ factory *WebViewFactory
28- pool *WebViewPool28+ pool *WebViewPool
29+ profileLogDir string
29 30
30 messageRouter *MessageRouter31 messageRouter *MessageRouter
31 schemeHandler *dumbSchemeHandler32 schemeHandler *dumbSchemeHandler
@@ -55,6 +56,12 @@ type Engine struct {
55 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.56 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
56 shutdownNotify chan struct{}57 shutdownNotify chan struct{}
57 58
59+ // Cross-layer render diagnostics.
60+ cefHeartbeatStop chan struct{}
61+ cefHeartbeatDone chan struct{}
62+ cefUIHeartbeat cefThreadHeartbeatState
63+ cefIOHeartbeat cefThreadHeartbeatState
64+
58 // Diagnostic counters.65 // Diagnostic counters.
59 contextInitializedCount atomic.Uint6466 contextInitializedCount atomic.Uint64
60 childLaunchRenderer atomic.Uint6467 childLaunchRenderer atomic.Uint64
@@ -68,7 +75,6 @@ type Engine struct {
68 browserCreateLastWidth atomic.Int3275 browserCreateLastWidth atomic.Int32
69 browserCreateLastHeight atomic.Int3276 browserCreateLastHeight atomic.Int32
70 browserCreateComplete atomic.Bool77 browserCreateComplete atomic.Bool
71-
72 }78 }
73 79
74 func (e *Engine) Factory() port.WebViewFactory {80 func (e *Engine) Factory() port.WebViewFactory {
@@ -196,6 +202,7 @@ func (e *Engine) Close() error {
196 202
197 e.closeActiveWebViews()203 e.closeActiveWebViews()
198 e.pool.Close()204 e.pool.Close()
205+ e.stopCEFHeartbeat()
199 206
200 if activeAfter := e.activeWebViewCount(); activeAfter > 0 {207 if activeAfter := e.activeWebViewCount(); activeAfter > 0 {
201 log.Warn().208 log.Warn().
@@ -474,4 +481,3 @@ func (e *Engine) currentDownloadHandler() *downloadHandler {
474 defer e.downloadMu.RUnlock()481 defer e.downloadMu.RUnlock()
475 return e.downloadHandler482 return e.downloadHandler
476 }483 }
477-
M internal/infrastructure/cef/engine_init.go +5 -2
@@ -26,8 +26,9 @@ const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
2626
2727 // RuntimePaths contains the concrete filesystem paths the CEF adapter needs.
2828 type RuntimePaths struct {
29- StateRoot string
30- LogFile string
29+ StateRoot string
30+ LogFile string
31+ ProfileLogDir string
3132 }
3233
3334 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
@@ -56,6 +57,7 @@ func NewEngine(
5657
5758 eng := &Engine{
5859 ctx: ctx,
60+ profileLogDir: paths.ProfileLogDir,
5961 registerHandlers: deps.RegisterHandlers,
6062 registerAccentHandlers: deps.RegisterAccentHandlers,
6163 currentConfigPayload: deps.CurrentConfigPayload,
@@ -186,6 +188,7 @@ func wireEngine(
186188 }
187189 eng.messageRouter = messageRouter
188190 eng.schemeHandler = schemeHandler
191+ eng.startCEFHeartbeat()
189192
190193 registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler))
191194 return eng, nil
@@ -26,8 +26,9 @@ const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
26 26
27 // RuntimePaths contains the concrete filesystem paths the CEF adapter needs.27 // RuntimePaths contains the concrete filesystem paths the CEF adapter needs.
28 type RuntimePaths struct {28 type RuntimePaths struct {
29- StateRoot string29+ StateRoot string
30- LogFile string30+ LogFile string
31+ ProfileLogDir string
31 }32 }
32 33
33 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.34 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
@@ -56,6 +57,7 @@ func NewEngine(
56 57
57 eng := &Engine{58 eng := &Engine{
58 ctx: ctx,59 ctx: ctx,
60+ profileLogDir: paths.ProfileLogDir,
59 registerHandlers: deps.RegisterHandlers,61 registerHandlers: deps.RegisterHandlers,
60 registerAccentHandlers: deps.RegisterAccentHandlers,62 registerAccentHandlers: deps.RegisterAccentHandlers,
61 currentConfigPayload: deps.CurrentConfigPayload,63 currentConfigPayload: deps.CurrentConfigPayload,
@@ -186,6 +188,7 @@ func wireEngine(
186 }188 }
187 eng.messageRouter = messageRouter189 eng.messageRouter = messageRouter
188 eng.schemeHandler = schemeHandler190 eng.schemeHandler = schemeHandler
191+ eng.startCEFHeartbeat()
189 192
190 registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler))193 registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler))
191 return eng, nil194 return eng, nil
M internal/infrastructure/cef/env.go +12 -0
@@ -8,6 +8,10 @@ import (
88 const (
99 cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME"
1010 cefEnableWebAuthnUnsafeEnvVar = "DUMBER_CEF_ENABLE_WEBAUTHN_UNSAFE"
11+ cefChromiumFlagsEnvVar = "DUMBER_CEF_CHROMIUM_FLAGS"
12+ cefEnableVAAPIEnvVar = "DUMBER_CEF_ENABLE_VAAPI"
13+ cefRenderStallRecoveryEnvVar = "DUMBER_CEF_RENDER_STALL_RECOVERY"
14+ cefRenderStallBacktraceEnvVar = "DUMBER_CEF_RENDER_STALL_BACKTRACE"
1115 )
1216
1317 // envBoolEnabled returns true when the given environment variable is set
@@ -28,3 +32,11 @@ func externalBeginFrameEnabled() bool {
2832 func cefWebAuthnUnsafeEnabled() bool {
2933 return envBoolEnabled(cefEnableWebAuthnUnsafeEnvVar)
3034 }
35+
36+func renderStallRecoveryEnabled() bool {
37+ return envBoolEnabled(cefRenderStallRecoveryEnvVar)
38+}
39+
40+func renderStallBacktraceEnabled() bool {
41+ return envBoolEnabled(cefRenderStallBacktraceEnvVar)
42+}
@@ -8,6 +8,10 @@ import (
8 const (8 const (
9 cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME"9 cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME"
10 cefEnableWebAuthnUnsafeEnvVar = "DUMBER_CEF_ENABLE_WEBAUTHN_UNSAFE"10 cefEnableWebAuthnUnsafeEnvVar = "DUMBER_CEF_ENABLE_WEBAUTHN_UNSAFE"
11+ cefChromiumFlagsEnvVar = "DUMBER_CEF_CHROMIUM_FLAGS"
12+ cefEnableVAAPIEnvVar = "DUMBER_CEF_ENABLE_VAAPI"
13+ cefRenderStallRecoveryEnvVar = "DUMBER_CEF_RENDER_STALL_RECOVERY"
14+ cefRenderStallBacktraceEnvVar = "DUMBER_CEF_RENDER_STALL_BACKTRACE"
11 )15 )
12 16
13 // envBoolEnabled returns true when the given environment variable is set17 // envBoolEnabled returns true when the given environment variable is set
@@ -28,3 +32,11 @@ func externalBeginFrameEnabled() bool {
28 func cefWebAuthnUnsafeEnabled() bool {32 func cefWebAuthnUnsafeEnabled() bool {
29 return envBoolEnabled(cefEnableWebAuthnUnsafeEnvVar)33 return envBoolEnabled(cefEnableWebAuthnUnsafeEnvVar)
30 }34 }
35+
36+func renderStallRecoveryEnabled() bool {
37+ return envBoolEnabled(cefRenderStallRecoveryEnvVar)
38+}
39+
40+func renderStallBacktraceEnabled() bool {
41+ return envBoolEnabled(cefRenderStallBacktraceEnvVar)
42+}
M internal/infrastructure/cef/factory.go +6 -0
@@ -126,6 +126,12 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
126126 // Build a CEF client backed by our handlerSet.
127127 // Store on WebView to prevent GC collection before CEF AddRef's it.
128128 wv.client = purecef.NewClient(wv.handlers)
129+ if opts := f.engine.cef2gtkProfileOptions(wv); opts.Enabled {
130+ if err := viewBridge.ConfigureProfiling(opts); err != nil {
131+ logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
132+ }
133+ }
134+ wv.startRenderStallWatchdog()
129135 return wv, nil
130136 }
131137
@@ -126,6 +126,12 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
126 // Build a CEF client backed by our handlerSet.126 // Build a CEF client backed by our handlerSet.
127 // Store on WebView to prevent GC collection before CEF AddRef's it.127 // Store on WebView to prevent GC collection before CEF AddRef's it.
128 wv.client = purecef.NewClient(wv.handlers)128 wv.client = purecef.NewClient(wv.handlers)
129+ if opts := f.engine.cef2gtkProfileOptions(wv); opts.Enabled {
130+ if err := viewBridge.ConfigureProfiling(opts); err != nil {
131+ logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
132+ }
133+ }
134+ wv.startRenderStallWatchdog()
129 return wv, nil135 return wv, nil
130 }136 }
131 137
A internal/infrastructure/cef/heartbeat.go +174 -0
@@ -0,0 +1,174 @@
1+package cef
2+
3+import (
4+ "sync/atomic"
5+ "time"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+const (
13+ cefHeartbeatInterval = 1 * time.Second
14+ cefHeartbeatLogEvery = 10 * time.Second
15+ cefHeartbeatStopTimeout = 2 * time.Second
16+)
17+
18+type cefThreadHeartbeatState struct {
19+ seq atomic.Uint64
20+ lastPostUnixNS atomic.Int64
21+ lastAckUnixNS atomic.Int64
22+ lastLatencyNS atomic.Int64
23+ lastPostResult atomic.Int32
24+ lastLogUnixNS atomic.Int64
25+ inFlight atomic.Bool
26+}
27+
28+func (s *cefThreadHeartbeatState) snapshot(now time.Time) cefHeartbeatSnapshot {
29+ if s == nil {
30+ return cefHeartbeatSnapshot{}
31+ }
32+ lastPost := unixNSTime(s.lastPostUnixNS.Load())
33+ lastAck := unixNSTime(s.lastAckUnixNS.Load())
34+ snap := cefHeartbeatSnapshot{
35+ Seq: s.seq.Load(),
36+ PostResult: s.lastPostResult.Load(),
37+ Latency: time.Duration(s.lastLatencyNS.Load()),
38+ LastPostTime: lastPost,
39+ LastAckTime: lastAck,
40+ InFlight: s.inFlight.Load(),
41+ }
42+ if !lastPost.IsZero() {
43+ snap.LastPostAge = now.Sub(lastPost)
44+ }
45+ if !lastAck.IsZero() {
46+ snap.LastAckAge = now.Sub(lastAck)
47+ }
48+ return snap
49+}
50+
51+type cefHeartbeatSnapshot struct {
52+ Seq uint64
53+ PostResult int32
54+ Latency time.Duration
55+ LastPostTime time.Time
56+ LastAckTime time.Time
57+ LastPostAge time.Duration
58+ LastAckAge time.Duration
59+ InFlight bool
60+}
61+
62+func (e *Engine) startCEFHeartbeat() {
63+ if e == nil || e.ctx == nil || e.cefHeartbeatStop != nil {
64+ return
65+ }
66+ stop := make(chan struct{})
67+ done := make(chan struct{})
68+ e.cefHeartbeatStop = stop
69+ e.cefHeartbeatDone = done
70+ go e.cefHeartbeatLoop(stop, done)
71+}
72+
73+func (e *Engine) stopCEFHeartbeat() {
74+ if e == nil || e.cefHeartbeatStop == nil {
75+ return
76+ }
77+ stop := e.cefHeartbeatStop
78+ done := e.cefHeartbeatDone
79+ e.cefHeartbeatStop = nil
80+ e.cefHeartbeatDone = nil
81+ close(stop)
82+ if done == nil {
83+ return
84+ }
85+ select {
86+ case <-done:
87+ case <-time.After(cefHeartbeatStopTimeout):
88+ if e.ctx != nil {
89+ logging.FromContext(e.ctx).Warn().Msg("cef: heartbeat stop timed out")
90+ }
91+ }
92+}
93+
94+func (e *Engine) cefHeartbeatLoop(stop <-chan struct{}, done chan<- struct{}) {
95+ defer close(done)
96+ ticker := time.NewTicker(cefHeartbeatInterval)
97+ defer ticker.Stop()
98+ for {
99+ select {
100+ case <-stop:
101+ return
102+ case now := <-ticker.C:
103+ e.postCEFThreadHeartbeat("ui", purecef.ThreadIDTidUi, &e.cefUIHeartbeat, now)
104+ e.postCEFThreadHeartbeat("io", purecef.ThreadIDTidIo, &e.cefIOHeartbeat, now)
105+ }
106+ }
107+}
108+
109+func (e *Engine) postCEFThreadHeartbeat(name string, threadID purecef.ThreadID, state *cefThreadHeartbeatState, now time.Time) {
110+ if e == nil || state == nil {
111+ return
112+ }
113+ seq := state.seq.Add(1)
114+ postedAt := now
115+ state.lastPostUnixNS.Store(postedAt.UnixNano())
116+ state.inFlight.Store(true)
117+ task := cefNewTask(cefTaskFunc(func() {
118+ ackedAt := time.Now()
119+ state.lastAckUnixNS.Store(ackedAt.UnixNano())
120+ state.lastLatencyNS.Store(ackedAt.Sub(postedAt).Nanoseconds())
121+ state.inFlight.Store(false)
122+ }))
123+ if task == nil {
124+ state.lastPostResult.Store(0)
125+ state.inFlight.Store(false)
126+ return
127+ }
128+ result := cefPostTask(threadID, task)
129+ state.lastPostResult.Store(result)
130+ if result != 1 {
131+ state.inFlight.Store(false)
132+ }
133+ lastLog := unixNSTime(state.lastLogUnixNS.Load())
134+ if result != 1 || lastLog.IsZero() || now.Sub(lastLog) >= cefHeartbeatLogEvery {
135+ state.lastLogUnixNS.Store(now.UnixNano())
136+ snap := state.snapshot(now)
137+ logging.FromContext(e.ctx).Debug().
138+ Str("thread", name).
139+ Uint64("seq", seq).
140+ Int32("post_result", result).
141+ Int64("last_ack_age_ms", durationMillis(snap.LastAckAge)).
142+ Int64("latency_ms", durationMillis(snap.Latency)).
143+ Bool("in_flight", snap.InFlight).
144+ Msg("cef: thread heartbeat")
145+ }
146+}
147+
148+func (e *Engine) cefHeartbeatSnapshots(now time.Time) (cefHeartbeatSnapshot, cefHeartbeatSnapshot) {
149+ if e == nil {
150+ return cefHeartbeatSnapshot{}, cefHeartbeatSnapshot{}
151+ }
152+ return e.cefUIHeartbeat.snapshot(now), e.cefIOHeartbeat.snapshot(now)
153+}
154+
155+func (wv *WebView) cefHeartbeatSnapshots(now time.Time) (cefHeartbeatSnapshot, cefHeartbeatSnapshot) {
156+ if wv == nil || wv.engine == nil {
157+ return cefHeartbeatSnapshot{}, cefHeartbeatSnapshot{}
158+ }
159+ return wv.engine.cefHeartbeatSnapshots(now)
160+}
161+
162+func unixNSTime(ns int64) time.Time {
163+ if ns <= 0 {
164+ return time.Time{}
165+ }
166+ return time.Unix(0, ns)
167+}
168+
169+func durationMillis(d time.Duration) int64 {
170+ if d <= 0 {
171+ return 0
172+ }
173+ return d.Milliseconds()
174+}
@@ -0,0 +1,174 @@
1+package cef
2+
3+import (
4+ "sync/atomic"
5+ "time"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+const (
13+ cefHeartbeatInterval = 1 * time.Second
14+ cefHeartbeatLogEvery = 10 * time.Second
15+ cefHeartbeatStopTimeout = 2 * time.Second
16+)
17+
18+type cefThreadHeartbeatState struct {
19+ seq atomic.Uint64
20+ lastPostUnixNS atomic.Int64
21+ lastAckUnixNS atomic.Int64
22+ lastLatencyNS atomic.Int64
23+ lastPostResult atomic.Int32
24+ lastLogUnixNS atomic.Int64
25+ inFlight atomic.Bool
26+}
27+
28+func (s *cefThreadHeartbeatState) snapshot(now time.Time) cefHeartbeatSnapshot {
29+ if s == nil {
30+ return cefHeartbeatSnapshot{}
31+ }
32+ lastPost := unixNSTime(s.lastPostUnixNS.Load())
33+ lastAck := unixNSTime(s.lastAckUnixNS.Load())
34+ snap := cefHeartbeatSnapshot{
35+ Seq: s.seq.Load(),
36+ PostResult: s.lastPostResult.Load(),
37+ Latency: time.Duration(s.lastLatencyNS.Load()),
38+ LastPostTime: lastPost,
39+ LastAckTime: lastAck,
40+ InFlight: s.inFlight.Load(),
41+ }
42+ if !lastPost.IsZero() {
43+ snap.LastPostAge = now.Sub(lastPost)
44+ }
45+ if !lastAck.IsZero() {
46+ snap.LastAckAge = now.Sub(lastAck)
47+ }
48+ return snap
49+}
50+
51+type cefHeartbeatSnapshot struct {
52+ Seq uint64
53+ PostResult int32
54+ Latency time.Duration
55+ LastPostTime time.Time
56+ LastAckTime time.Time
57+ LastPostAge time.Duration
58+ LastAckAge time.Duration
59+ InFlight bool
60+}
61+
62+func (e *Engine) startCEFHeartbeat() {
63+ if e == nil || e.ctx == nil || e.cefHeartbeatStop != nil {
64+ return
65+ }
66+ stop := make(chan struct{})
67+ done := make(chan struct{})
68+ e.cefHeartbeatStop = stop
69+ e.cefHeartbeatDone = done
70+ go e.cefHeartbeatLoop(stop, done)
71+}
72+
73+func (e *Engine) stopCEFHeartbeat() {
74+ if e == nil || e.cefHeartbeatStop == nil {
75+ return
76+ }
77+ stop := e.cefHeartbeatStop
78+ done := e.cefHeartbeatDone
79+ e.cefHeartbeatStop = nil
80+ e.cefHeartbeatDone = nil
81+ close(stop)
82+ if done == nil {
83+ return
84+ }
85+ select {
86+ case <-done:
87+ case <-time.After(cefHeartbeatStopTimeout):
88+ if e.ctx != nil {
89+ logging.FromContext(e.ctx).Warn().Msg("cef: heartbeat stop timed out")
90+ }
91+ }
92+}
93+
94+func (e *Engine) cefHeartbeatLoop(stop <-chan struct{}, done chan<- struct{}) {
95+ defer close(done)
96+ ticker := time.NewTicker(cefHeartbeatInterval)
97+ defer ticker.Stop()
98+ for {
99+ select {
100+ case <-stop:
101+ return
102+ case now := <-ticker.C:
103+ e.postCEFThreadHeartbeat("ui", purecef.ThreadIDTidUi, &e.cefUIHeartbeat, now)
104+ e.postCEFThreadHeartbeat("io", purecef.ThreadIDTidIo, &e.cefIOHeartbeat, now)
105+ }
106+ }
107+}
108+
109+func (e *Engine) postCEFThreadHeartbeat(name string, threadID purecef.ThreadID, state *cefThreadHeartbeatState, now time.Time) {
110+ if e == nil || state == nil {
111+ return
112+ }
113+ seq := state.seq.Add(1)
114+ postedAt := now
115+ state.lastPostUnixNS.Store(postedAt.UnixNano())
116+ state.inFlight.Store(true)
117+ task := cefNewTask(cefTaskFunc(func() {
118+ ackedAt := time.Now()
119+ state.lastAckUnixNS.Store(ackedAt.UnixNano())
120+ state.lastLatencyNS.Store(ackedAt.Sub(postedAt).Nanoseconds())
121+ state.inFlight.Store(false)
122+ }))
123+ if task == nil {
124+ state.lastPostResult.Store(0)
125+ state.inFlight.Store(false)
126+ return
127+ }
128+ result := cefPostTask(threadID, task)
129+ state.lastPostResult.Store(result)
130+ if result != 1 {
131+ state.inFlight.Store(false)
132+ }
133+ lastLog := unixNSTime(state.lastLogUnixNS.Load())
134+ if result != 1 || lastLog.IsZero() || now.Sub(lastLog) >= cefHeartbeatLogEvery {
135+ state.lastLogUnixNS.Store(now.UnixNano())
136+ snap := state.snapshot(now)
137+ logging.FromContext(e.ctx).Debug().
138+ Str("thread", name).
139+ Uint64("seq", seq).
140+ Int32("post_result", result).
141+ Int64("last_ack_age_ms", durationMillis(snap.LastAckAge)).
142+ Int64("latency_ms", durationMillis(snap.Latency)).
143+ Bool("in_flight", snap.InFlight).
144+ Msg("cef: thread heartbeat")
145+ }
146+}
147+
148+func (e *Engine) cefHeartbeatSnapshots(now time.Time) (cefHeartbeatSnapshot, cefHeartbeatSnapshot) {
149+ if e == nil {
150+ return cefHeartbeatSnapshot{}, cefHeartbeatSnapshot{}
151+ }
152+ return e.cefUIHeartbeat.snapshot(now), e.cefIOHeartbeat.snapshot(now)
153+}
154+
155+func (wv *WebView) cefHeartbeatSnapshots(now time.Time) (cefHeartbeatSnapshot, cefHeartbeatSnapshot) {
156+ if wv == nil || wv.engine == nil {
157+ return cefHeartbeatSnapshot{}, cefHeartbeatSnapshot{}
158+ }
159+ return wv.engine.cefHeartbeatSnapshots(now)
160+}
161+
162+func unixNSTime(ns int64) time.Time {
163+ if ns <= 0 {
164+ return time.Time{}
165+ }
166+ return time.Unix(0, ns)
167+}
168+
169+func durationMillis(d time.Duration) int64 {
170+ if d <= 0 {
171+ return 0
172+ }
173+ return d.Milliseconds()
174+}
A internal/infrastructure/cef/process_diagnostics.go +307 -0
@@ -0,0 +1,307 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "sort"
11+ "strconv"
12+ "strings"
13+ "sync/atomic"
14+ "time"
15+
16+ "github.com/bnema/dumber/internal/logging"
17+)
18+
19+const (
20+ maxLoggedThreadsPerProcess = 8
21+ maxDetailedThreadsPerProcess = 48
22+ renderStallBacktraceCooldown = 30 * time.Second
23+ renderStallBacktraceCommandLimit = 64 * 1024
24+)
25+
26+var renderStallBacktraceLastUnixNS atomic.Int64
27+
28+func (wv *WebView) logCEFProcessDiagnostics(reason string, classification renderStallClassification) {
29+ if wv == nil || wv.ctx == nil {
30+ return
31+ }
32+ pids := append([]int{os.Getpid()}, childPIDs(os.Getpid())...)
33+ for _, pid := range pids {
34+ cmdline := procCmdline(pid)
35+ processType := chromiumProcessType(cmdline)
36+ threads := procThreadSummaries(pid, maxLoggedThreadsPerProcess)
37+ detailedThreads := []string(nil)
38+ if classification.CEFUIBlocked && processType == "browser" {
39+ detailedThreads = procDetailedThreadSummaries(pid, maxDetailedThreadsPerProcess)
40+ }
41+ logging.FromContext(wv.ctx).Warn().
42+ Str("reason", reason).
43+ Str("classification", classification.Category).
44+ Bool("cef_ui_blocked", classification.CEFUIBlocked).
45+ Bool("cef_io_alive", classification.CEFIOAlive).
46+ Int("pid", pid).
47+ Str("process_type", processType).
48+ Str("cmdline", logging.TruncateURL(cmdline, 1024)).
49+ Int("thread_count", procThreadCount(pid)).
50+ Strs("hot_threads", threads).
51+ Strs("detailed_threads", detailedThreads).
52+ Strs("gpu_fdinfo", gpuFDInfoSummaries(pid)).
53+ Msg("cef: process diagnostic snapshot")
54+ }
55+ if classification.CEFUIBlocked {
56+ wv.maybeCaptureRenderStallBacktrace(reason, classification)
57+ }
58+}
59+
60+func childPIDs(parent int) []int {
61+ if out := childPIDsFromProcChildren(parent); len(out) > 0 {
62+ return out
63+ }
64+ entries, err := os.ReadDir("/proc")
65+ if err != nil {
66+ return nil
67+ }
68+ var out []int
69+ for _, entry := range entries {
70+ if !entry.IsDir() {
71+ continue
72+ }
73+ pid, err := strconv.Atoi(entry.Name())
74+ if err != nil {
75+ continue
76+ }
77+ if procPPID(pid) == parent {
78+ out = append(out, pid)
79+ }
80+ }
81+ sort.Ints(out)
82+ return out
83+}
84+
85+func childPIDsFromProcChildren(parent int) []int {
86+ path := filepath.Join("/proc", strconv.Itoa(parent), "task", strconv.Itoa(parent), "children")
87+ b, err := os.ReadFile(path)
88+ if err != nil {
89+ return nil
90+ }
91+ fields := strings.Fields(string(b))
92+ out := make([]int, 0, len(fields))
93+ for _, field := range fields {
94+ pid, err := strconv.Atoi(field)
95+ if err != nil {
96+ continue
97+ }
98+ out = append(out, pid)
99+ }
100+ sort.Ints(out)
101+ return out
102+}
103+
104+func procPPID(pid int) int {
105+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))
106+ if err != nil {
107+ return 0
108+ }
109+ for _, line := range strings.Split(string(b), "\n") {
110+ if !strings.HasPrefix(line, "PPid:") {
111+ continue
112+ }
113+ fields := strings.Fields(line)
114+ if len(fields) >= 2 {
115+ ppid, _ := strconv.Atoi(fields[1])
116+ return ppid
117+ }
118+ }
119+ return 0
120+}
121+
122+func procCmdline(pid int) string {
123+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
124+ if err != nil {
125+ return ""
126+ }
127+ return strings.TrimSpace(string(bytes.ReplaceAll(b, []byte{0}, []byte{' '})))
128+}
129+
130+func chromiumProcessType(cmdline string) string {
131+ for _, field := range strings.Fields(cmdline) {
132+ if strings.HasPrefix(field, "--type=") {
133+ return strings.TrimPrefix(field, "--type=")
134+ }
135+ }
136+ return "browser"
137+}
138+
139+func procThreadCount(pid int) int {
140+ entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "task"))
141+ if err != nil {
142+ return 0
143+ }
144+ return len(entries)
145+}
146+
147+type procThreadSummary struct {
148+ text string
149+ ticks uint64
150+}
151+
152+func procThreadSummaries(pid, limit int) []string {
153+ return procThreadSummariesWithDetails(pid, limit, false)
154+}
155+
156+func procDetailedThreadSummaries(pid, limit int) []string {
157+ return procThreadSummariesWithDetails(pid, limit, true)
158+}
159+
160+func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string {
161+ entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "task"))
162+ if err != nil {
163+ return nil
164+ }
165+ items := make([]procThreadSummary, 0, len(entries))
166+ for _, entry := range entries {
167+ tid, err := strconv.Atoi(entry.Name())
168+ if err != nil {
169+ continue
170+ }
171+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "stat"))
172+ if err != nil {
173+ continue
174+ }
175+ text, ticks := parseThreadStat(tid, string(b))
176+ if detailed {
177+ text = text + " wchan=" + procThreadWchan(pid, tid)
178+ if stack := procThreadKernelStack(pid, tid); stack != "" {
179+ text = text + " kernel_stack=" + stack
180+ }
181+ }
182+ items = append(items, procThreadSummary{text: text, ticks: ticks})
183+ }
184+ sort.Slice(items, func(i, j int) bool { return items[i].ticks > items[j].ticks })
185+ if limit > 0 && len(items) > limit {
186+ items = items[:limit]
187+ }
188+ out := make([]string, 0, len(items))
189+ for _, item := range items {
190+ out = append(out, item.text)
191+ }
192+ return out
193+}
194+
195+func parseThreadStat(tid int, stat string) (string, uint64) {
196+ open := strings.IndexByte(stat, '(')
197+ close := strings.LastIndexByte(stat, ')')
198+ if open < 0 || close <= open || close+2 >= len(stat) {
199+ return strconv.Itoa(tid), 0
200+ }
201+ comm := stat[open+1 : close]
202+ rest := strings.Fields(stat[close+2:])
203+ state := "?"
204+ if len(rest) > 0 {
205+ state = rest[0]
206+ }
207+ var ticks uint64
208+ if len(rest) > 12 {
209+ utime, _ := strconv.ParseUint(rest[11], 10, 64)
210+ stime, _ := strconv.ParseUint(rest[12], 10, 64)
211+ ticks = utime + stime
212+ }
213+ return "tid=" + strconv.Itoa(tid) + " state=" + state + " ticks=" + strconv.FormatUint(ticks, 10) + " comm=" + comm, ticks
214+}
215+
216+func procThreadWchan(pid, tid int) string {
217+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "wchan"))
218+ if err != nil {
219+ return ""
220+ }
221+ return strings.TrimSpace(string(b))
222+}
223+
224+func procThreadKernelStack(pid, tid int) string {
225+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "stack"))
226+ if err != nil {
227+ return ""
228+ }
229+ lines := strings.Split(strings.TrimSpace(string(b)), "\n")
230+ if len(lines) > 6 {
231+ lines = lines[:6]
232+ }
233+ return strings.Join(lines, " | ")
234+}
235+
236+func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classification renderStallClassification) {
237+ if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() {
238+ return
239+ }
240+ now := time.Now()
241+ last := unixNSTime(renderStallBacktraceLastUnixNS.Load())
242+ if !last.IsZero() && now.Sub(last) < renderStallBacktraceCooldown {
243+ return
244+ }
245+ renderStallBacktraceLastUnixNS.Store(now.UnixNano())
246+ pid := os.Getpid()
247+ if _, err := exec.LookPath("gdb"); err != nil {
248+ logging.FromContext(wv.ctx).Warn().
249+ Err(err).
250+ Str("reason", reason).
251+ Str("classification", classification.Category).
252+ Int("pid", pid).
253+ Str("external_command", fmt.Sprintf("gdb -p %d -batch -ex 'thread apply all bt'", pid)).
254+ Msg("cef: render stall native backtrace skipped; gdb not available")
255+ return
256+ }
257+ if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() {
258+ return
259+ }
260+ logCtx := wv.ctx
261+ go func() {
262+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
263+ defer cancel()
264+ cmd := exec.CommandContext(ctx, "gdb", "-p", strconv.Itoa(pid), "-batch", "-ex", "thread apply all bt")
265+ out, err := cmd.CombinedOutput()
266+ truncated := false
267+ if len(out) > renderStallBacktraceCommandLimit {
268+ out = out[:renderStallBacktraceCommandLimit]
269+ truncated = true
270+ }
271+ log := logging.FromContext(logCtx).Warn().
272+ Str("reason", reason).
273+ Str("classification", classification.Category).
274+ Int("pid", pid).
275+ Bool("truncated", truncated).
276+ Str("backtrace", string(out))
277+ if ctx.Err() != nil {
278+ log = log.Err(ctx.Err())
279+ } else if err != nil {
280+ log = log.Err(err)
281+ }
282+ log.Msg("cef: render stall native backtrace")
283+ }()
284+}
285+
286+func gpuFDInfoSummaries(pid int) []string {
287+ entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "fdinfo"))
288+ if err != nil {
289+ return nil
290+ }
291+ var out []string
292+ for _, entry := range entries {
293+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "fdinfo", entry.Name()))
294+ if err != nil {
295+ continue
296+ }
297+ for _, line := range strings.Split(string(b), "\n") {
298+ if strings.Contains(line, "drm-engine-") || strings.HasPrefix(line, "drm-client-id") || strings.HasPrefix(line, "drm-pdev") {
299+ out = append(out, "fd="+entry.Name()+" "+strings.TrimSpace(line))
300+ }
301+ }
302+ }
303+ if len(out) > 32 {
304+ out = out[:32]
305+ }
306+ return out
307+}
@@ -0,0 +1,307 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "sort"
11+ "strconv"
12+ "strings"
13+ "sync/atomic"
14+ "time"
15+
16+ "github.com/bnema/dumber/internal/logging"
17+)
18+
19+const (
20+ maxLoggedThreadsPerProcess = 8
21+ maxDetailedThreadsPerProcess = 48
22+ renderStallBacktraceCooldown = 30 * time.Second
23+ renderStallBacktraceCommandLimit = 64 * 1024
24+)
25+
26+var renderStallBacktraceLastUnixNS atomic.Int64
27+
28+func (wv *WebView) logCEFProcessDiagnostics(reason string, classification renderStallClassification) {
29+ if wv == nil || wv.ctx == nil {
30+ return
31+ }
32+ pids := append([]int{os.Getpid()}, childPIDs(os.Getpid())...)
33+ for _, pid := range pids {
34+ cmdline := procCmdline(pid)
35+ processType := chromiumProcessType(cmdline)
36+ threads := procThreadSummaries(pid, maxLoggedThreadsPerProcess)
37+ detailedThreads := []string(nil)
38+ if classification.CEFUIBlocked && processType == "browser" {
39+ detailedThreads = procDetailedThreadSummaries(pid, maxDetailedThreadsPerProcess)
40+ }
41+ logging.FromContext(wv.ctx).Warn().
42+ Str("reason", reason).
43+ Str("classification", classification.Category).
44+ Bool("cef_ui_blocked", classification.CEFUIBlocked).
45+ Bool("cef_io_alive", classification.CEFIOAlive).
46+ Int("pid", pid).
47+ Str("process_type", processType).
48+ Str("cmdline", logging.TruncateURL(cmdline, 1024)).
49+ Int("thread_count", procThreadCount(pid)).
50+ Strs("hot_threads", threads).
51+ Strs("detailed_threads", detailedThreads).
52+ Strs("gpu_fdinfo", gpuFDInfoSummaries(pid)).
53+ Msg("cef: process diagnostic snapshot")
54+ }
55+ if classification.CEFUIBlocked {
56+ wv.maybeCaptureRenderStallBacktrace(reason, classification)
57+ }
58+}
59+
60+func childPIDs(parent int) []int {
61+ if out := childPIDsFromProcChildren(parent); len(out) > 0 {
62+ return out
63+ }
64+ entries, err := os.ReadDir("/proc")
65+ if err != nil {
66+ return nil
67+ }
68+ var out []int
69+ for _, entry := range entries {
70+ if !entry.IsDir() {
71+ continue
72+ }
73+ pid, err := strconv.Atoi(entry.Name())
74+ if err != nil {
75+ continue
76+ }
77+ if procPPID(pid) == parent {
78+ out = append(out, pid)
79+ }
80+ }
81+ sort.Ints(out)
82+ return out
83+}
84+
85+func childPIDsFromProcChildren(parent int) []int {
86+ path := filepath.Join("/proc", strconv.Itoa(parent), "task", strconv.Itoa(parent), "children")
87+ b, err := os.ReadFile(path)
88+ if err != nil {
89+ return nil
90+ }
91+ fields := strings.Fields(string(b))
92+ out := make([]int, 0, len(fields))
93+ for _, field := range fields {
94+ pid, err := strconv.Atoi(field)
95+ if err != nil {
96+ continue
97+ }
98+ out = append(out, pid)
99+ }
100+ sort.Ints(out)
101+ return out
102+}
103+
104+func procPPID(pid int) int {
105+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))
106+ if err != nil {
107+ return 0
108+ }
109+ for _, line := range strings.Split(string(b), "\n") {
110+ if !strings.HasPrefix(line, "PPid:") {
111+ continue
112+ }
113+ fields := strings.Fields(line)
114+ if len(fields) >= 2 {
115+ ppid, _ := strconv.Atoi(fields[1])
116+ return ppid
117+ }
118+ }
119+ return 0
120+}
121+
122+func procCmdline(pid int) string {
123+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
124+ if err != nil {
125+ return ""
126+ }
127+ return strings.TrimSpace(string(bytes.ReplaceAll(b, []byte{0}, []byte{' '})))
128+}
129+
130+func chromiumProcessType(cmdline string) string {
131+ for _, field := range strings.Fields(cmdline) {
132+ if strings.HasPrefix(field, "--type=") {
133+ return strings.TrimPrefix(field, "--type=")
134+ }
135+ }
136+ return "browser"
137+}
138+
139+func procThreadCount(pid int) int {
140+ entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "task"))
141+ if err != nil {
142+ return 0
143+ }
144+ return len(entries)
145+}
146+
147+type procThreadSummary struct {
148+ text string
149+ ticks uint64
150+}
151+
152+func procThreadSummaries(pid, limit int) []string {
153+ return procThreadSummariesWithDetails(pid, limit, false)
154+}
155+
156+func procDetailedThreadSummaries(pid, limit int) []string {
157+ return procThreadSummariesWithDetails(pid, limit, true)
158+}
159+
160+func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string {
161+ entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "task"))
162+ if err != nil {
163+ return nil
164+ }
165+ items := make([]procThreadSummary, 0, len(entries))
166+ for _, entry := range entries {
167+ tid, err := strconv.Atoi(entry.Name())
168+ if err != nil {
169+ continue
170+ }
171+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "stat"))
172+ if err != nil {
173+ continue
174+ }
175+ text, ticks := parseThreadStat(tid, string(b))
176+ if detailed {
177+ text = text + " wchan=" + procThreadWchan(pid, tid)
178+ if stack := procThreadKernelStack(pid, tid); stack != "" {
179+ text = text + " kernel_stack=" + stack
180+ }
181+ }
182+ items = append(items, procThreadSummary{text: text, ticks: ticks})
183+ }
184+ sort.Slice(items, func(i, j int) bool { return items[i].ticks > items[j].ticks })
185+ if limit > 0 && len(items) > limit {
186+ items = items[:limit]
187+ }
188+ out := make([]string, 0, len(items))
189+ for _, item := range items {
190+ out = append(out, item.text)
191+ }
192+ return out
193+}
194+
195+func parseThreadStat(tid int, stat string) (string, uint64) {
196+ open := strings.IndexByte(stat, '(')
197+ close := strings.LastIndexByte(stat, ')')
198+ if open < 0 || close <= open || close+2 >= len(stat) {
199+ return strconv.Itoa(tid), 0
200+ }
201+ comm := stat[open+1 : close]
202+ rest := strings.Fields(stat[close+2:])
203+ state := "?"
204+ if len(rest) > 0 {
205+ state = rest[0]
206+ }
207+ var ticks uint64
208+ if len(rest) > 12 {
209+ utime, _ := strconv.ParseUint(rest[11], 10, 64)
210+ stime, _ := strconv.ParseUint(rest[12], 10, 64)
211+ ticks = utime + stime
212+ }
213+ return "tid=" + strconv.Itoa(tid) + " state=" + state + " ticks=" + strconv.FormatUint(ticks, 10) + " comm=" + comm, ticks
214+}
215+
216+func procThreadWchan(pid, tid int) string {
217+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "wchan"))
218+ if err != nil {
219+ return ""
220+ }
221+ return strings.TrimSpace(string(b))
222+}
223+
224+func procThreadKernelStack(pid, tid int) string {
225+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "stack"))
226+ if err != nil {
227+ return ""
228+ }
229+ lines := strings.Split(strings.TrimSpace(string(b)), "\n")
230+ if len(lines) > 6 {
231+ lines = lines[:6]
232+ }
233+ return strings.Join(lines, " | ")
234+}
235+
236+func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classification renderStallClassification) {
237+ if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() {
238+ return
239+ }
240+ now := time.Now()
241+ last := unixNSTime(renderStallBacktraceLastUnixNS.Load())
242+ if !last.IsZero() && now.Sub(last) < renderStallBacktraceCooldown {
243+ return
244+ }
245+ renderStallBacktraceLastUnixNS.Store(now.UnixNano())
246+ pid := os.Getpid()
247+ if _, err := exec.LookPath("gdb"); err != nil {
248+ logging.FromContext(wv.ctx).Warn().
249+ Err(err).
250+ Str("reason", reason).
251+ Str("classification", classification.Category).
252+ Int("pid", pid).
253+ Str("external_command", fmt.Sprintf("gdb -p %d -batch -ex 'thread apply all bt'", pid)).
254+ Msg("cef: render stall native backtrace skipped; gdb not available")
255+ return
256+ }
257+ if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() {
258+ return
259+ }
260+ logCtx := wv.ctx
261+ go func() {
262+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
263+ defer cancel()
264+ cmd := exec.CommandContext(ctx, "gdb", "-p", strconv.Itoa(pid), "-batch", "-ex", "thread apply all bt")
265+ out, err := cmd.CombinedOutput()
266+ truncated := false
267+ if len(out) > renderStallBacktraceCommandLimit {
268+ out = out[:renderStallBacktraceCommandLimit]
269+ truncated = true
270+ }
271+ log := logging.FromContext(logCtx).Warn().
272+ Str("reason", reason).
273+ Str("classification", classification.Category).
274+ Int("pid", pid).
275+ Bool("truncated", truncated).
276+ Str("backtrace", string(out))
277+ if ctx.Err() != nil {
278+ log = log.Err(ctx.Err())
279+ } else if err != nil {
280+ log = log.Err(err)
281+ }
282+ log.Msg("cef: render stall native backtrace")
283+ }()
284+}
285+
286+func gpuFDInfoSummaries(pid int) []string {
287+ entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "fdinfo"))
288+ if err != nil {
289+ return nil
290+ }
291+ var out []string
292+ for _, entry := range entries {
293+ b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "fdinfo", entry.Name()))
294+ if err != nil {
295+ continue
296+ }
297+ for _, line := range strings.Split(string(b), "\n") {
298+ if strings.Contains(line, "drm-engine-") || strings.HasPrefix(line, "drm-client-id") || strings.HasPrefix(line, "drm-pdev") {
299+ out = append(out, "fd="+entry.Name()+" "+strings.TrimSpace(line))
300+ }
301+ }
302+ }
303+ if len(out) > 32 {
304+ out = out[:32]
305+ }
306+ return out
307+}
A internal/infrastructure/cef/profile.go +172 -0
@@ -0,0 +1,172 @@
1+package cef
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ cef2gtk "github.com/bnema/purego-cef2gtk"
15+
16+ "github.com/bnema/dumber/internal/logging"
17+)
18+
19+const (
20+ cef2gtkProfileEnv = "DUMBER_CEF2GTK_PROFILE"
21+ cef2gtkProfileIntervalEnv = "DUMBER_CEF2GTK_PROFILE_INTERVAL"
22+ cef2gtkProfileOutputEnv = "DUMBER_CEF2GTK_PROFILE_OUTPUT"
23+)
24+
25+var errProfileWriterClosed = errors.New("cef2gtk profile writer is closed")
26+
27+type lockedProfileWriter struct {
28+ mu sync.Mutex
29+ file *os.File
30+}
31+
32+func (w *lockedProfileWriter) Write(p []byte) (int, error) {
33+ if w == nil {
34+ return 0, errProfileWriterClosed
35+ }
36+ w.mu.Lock()
37+ defer w.mu.Unlock()
38+ if w.file == nil {
39+ return 0, errProfileWriterClosed
40+ }
41+ return w.file.Write(p)
42+}
43+
44+func (w *lockedProfileWriter) Close() error {
45+ if w == nil || w.file == nil {
46+ return nil
47+ }
48+ w.mu.Lock()
49+ defer w.mu.Unlock()
50+ err := w.file.Close()
51+ w.file = nil
52+ return err
53+}
54+
55+func cef2gtkProfileEnabled() bool {
56+ v := strings.TrimSpace(strings.ToLower(os.Getenv(cef2gtkProfileEnv)))
57+ if v == "" {
58+ return false
59+ }
60+ if b, err := strconv.ParseBool(v); err == nil {
61+ return b
62+ }
63+ switch v {
64+ case "1", "yes", "on", "enabled":
65+ return true
66+ default:
67+ return false
68+ }
69+}
70+
71+func cef2gtkProfileInterval() time.Duration {
72+ v := strings.TrimSpace(os.Getenv(cef2gtkProfileIntervalEnv))
73+ if v == "" {
74+ return time.Second
75+ }
76+ d, err := time.ParseDuration(v)
77+ if err != nil || d <= 0 {
78+ return time.Second
79+ }
80+ return d
81+}
82+
83+type cef2gtkProfileRecord struct {
84+ WebViewID uint64 `json:"webview_id"`
85+ Snapshot cef2gtk.ProfileSnapshot `json:"snapshot"`
86+}
87+
88+func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions {
89+ if e == nil || wv == nil || !cef2gtkProfileEnabled() {
90+ return cef2gtk.ProfileOptions{}
91+ }
92+ path := e.cef2gtkProfilePath()
93+ if path == "" {
94+ return cef2gtk.ProfileOptions{}
95+ }
96+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
97+ logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to create profile log directory")
98+ return cef2gtk.ProfileOptions{}
99+ }
100+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
101+ if err != nil {
102+ logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to open profile log")
103+ return cef2gtk.ProfileOptions{}
104+ }
105+ writer := &lockedProfileWriter{file: file}
106+ wv.profileCleanup = func() { _ = writer.Close() }
107+ logging.FromContext(e.ctx).Info().Str("profile_log", path).Uint64("webview_id", uint64(wv.id)).Msg("cef2gtk profiling enabled")
108+ return cef2gtk.ProfileOptions{
109+ Enabled: true,
110+ Interval: cef2gtkProfileInterval(),
111+ OnSnapshot: func(s cef2gtk.ProfileSnapshot) {
112+ wv.recordCEF2GTKProfileSnapshot(s)
113+ record := cef2gtkProfileRecord{WebViewID: uint64(wv.id), Snapshot: s}
114+ if b, err := json.Marshal(record); err == nil {
115+ _, _ = writer.Write(append(b, '\n'))
116+ }
117+ logging.FromContext(e.ctx).Debug().
118+ Uint64("webview_id", uint64(wv.id)).
119+ Uint64("frames_received", s.FramesReceived).
120+ Uint64("frames_rendered", s.FramesRendered).
121+ Uint32("gc_delta", s.GC.NumGCDelta).
122+ Msg("cef2gtk profile snapshot")
123+ },
124+ }
125+}
126+
127+func (wv *WebView) recordCEF2GTKProfileSnapshot(snapshot cef2gtk.ProfileSnapshot) {
128+ if wv == nil {
129+ return
130+ }
131+ wv.profileMu.Lock()
132+ wv.previousProfileSnapshot = wv.latestProfileSnapshot
133+ wv.latestProfileSnapshot = snapshot
134+ wv.latestProfileSnapshotAt = snapshot.Time
135+ wv.profileMu.Unlock()
136+}
137+
138+func (wv *WebView) latestCEF2GTKProfileSnapshot(now time.Time) (cef2gtk.ProfileSnapshot, time.Duration, bool) {
139+ if wv == nil {
140+ return cef2gtk.ProfileSnapshot{}, 0, false
141+ }
142+ wv.profileMu.Lock()
143+ snapshot := wv.latestProfileSnapshot
144+ snapshotAt := wv.latestProfileSnapshotAt
145+ wv.profileMu.Unlock()
146+ if snapshotAt.IsZero() {
147+ return cef2gtk.ProfileSnapshot{}, 0, false
148+ }
149+ age := now.Sub(snapshotAt)
150+ if age < 0 {
151+ age = 0
152+ }
153+ return snapshot, age, true
154+}
155+
156+func (e *Engine) cef2gtkProfilePath() string {
157+ if explicit := strings.TrimSpace(os.Getenv(cef2gtkProfileOutputEnv)); explicit != "" {
158+ return explicit
159+ }
160+ metadata, ok := logging.SessionMetadataFromContext(e.ctx)
161+ if !ok || metadata.ID == "" {
162+ return ""
163+ }
164+ logDir := e.profileLogDir
165+ if metadata.LogPath != "" {
166+ logDir = filepath.Dir(metadata.LogPath)
167+ }
168+ if logDir == "" {
169+ return ""
170+ }
171+ return filepath.Join(logDir, fmt.Sprintf("session_%s_cef2gtk_profile.jsonl", metadata.ID))
172+}
@@ -0,0 +1,172 @@
1+package cef
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ cef2gtk "github.com/bnema/purego-cef2gtk"
15+
16+ "github.com/bnema/dumber/internal/logging"
17+)
18+
19+const (
20+ cef2gtkProfileEnv = "DUMBER_CEF2GTK_PROFILE"
21+ cef2gtkProfileIntervalEnv = "DUMBER_CEF2GTK_PROFILE_INTERVAL"
22+ cef2gtkProfileOutputEnv = "DUMBER_CEF2GTK_PROFILE_OUTPUT"
23+)
24+
25+var errProfileWriterClosed = errors.New("cef2gtk profile writer is closed")
26+
27+type lockedProfileWriter struct {
28+ mu sync.Mutex
29+ file *os.File
30+}
31+
32+func (w *lockedProfileWriter) Write(p []byte) (int, error) {
33+ if w == nil {
34+ return 0, errProfileWriterClosed
35+ }
36+ w.mu.Lock()
37+ defer w.mu.Unlock()
38+ if w.file == nil {
39+ return 0, errProfileWriterClosed
40+ }
41+ return w.file.Write(p)
42+}
43+
44+func (w *lockedProfileWriter) Close() error {
45+ if w == nil || w.file == nil {
46+ return nil
47+ }
48+ w.mu.Lock()
49+ defer w.mu.Unlock()
50+ err := w.file.Close()
51+ w.file = nil
52+ return err
53+}
54+
55+func cef2gtkProfileEnabled() bool {
56+ v := strings.TrimSpace(strings.ToLower(os.Getenv(cef2gtkProfileEnv)))
57+ if v == "" {
58+ return false
59+ }
60+ if b, err := strconv.ParseBool(v); err == nil {
61+ return b
62+ }
63+ switch v {
64+ case "1", "yes", "on", "enabled":
65+ return true
66+ default:
67+ return false
68+ }
69+}
70+
71+func cef2gtkProfileInterval() time.Duration {
72+ v := strings.TrimSpace(os.Getenv(cef2gtkProfileIntervalEnv))
73+ if v == "" {
74+ return time.Second
75+ }
76+ d, err := time.ParseDuration(v)
77+ if err != nil || d <= 0 {
78+ return time.Second
79+ }
80+ return d
81+}
82+
83+type cef2gtkProfileRecord struct {
84+ WebViewID uint64 `json:"webview_id"`
85+ Snapshot cef2gtk.ProfileSnapshot `json:"snapshot"`
86+}
87+
88+func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions {
89+ if e == nil || wv == nil || !cef2gtkProfileEnabled() {
90+ return cef2gtk.ProfileOptions{}
91+ }
92+ path := e.cef2gtkProfilePath()
93+ if path == "" {
94+ return cef2gtk.ProfileOptions{}
95+ }
96+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
97+ logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to create profile log directory")
98+ return cef2gtk.ProfileOptions{}
99+ }
100+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
101+ if err != nil {
102+ logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to open profile log")
103+ return cef2gtk.ProfileOptions{}
104+ }
105+ writer := &lockedProfileWriter{file: file}
106+ wv.profileCleanup = func() { _ = writer.Close() }
107+ logging.FromContext(e.ctx).Info().Str("profile_log", path).Uint64("webview_id", uint64(wv.id)).Msg("cef2gtk profiling enabled")
108+ return cef2gtk.ProfileOptions{
109+ Enabled: true,
110+ Interval: cef2gtkProfileInterval(),
111+ OnSnapshot: func(s cef2gtk.ProfileSnapshot) {
112+ wv.recordCEF2GTKProfileSnapshot(s)
113+ record := cef2gtkProfileRecord{WebViewID: uint64(wv.id), Snapshot: s}
114+ if b, err := json.Marshal(record); err == nil {
115+ _, _ = writer.Write(append(b, '\n'))
116+ }
117+ logging.FromContext(e.ctx).Debug().
118+ Uint64("webview_id", uint64(wv.id)).
119+ Uint64("frames_received", s.FramesReceived).
120+ Uint64("frames_rendered", s.FramesRendered).
121+ Uint32("gc_delta", s.GC.NumGCDelta).
122+ Msg("cef2gtk profile snapshot")
123+ },
124+ }
125+}
126+
127+func (wv *WebView) recordCEF2GTKProfileSnapshot(snapshot cef2gtk.ProfileSnapshot) {
128+ if wv == nil {
129+ return
130+ }
131+ wv.profileMu.Lock()
132+ wv.previousProfileSnapshot = wv.latestProfileSnapshot
133+ wv.latestProfileSnapshot = snapshot
134+ wv.latestProfileSnapshotAt = snapshot.Time
135+ wv.profileMu.Unlock()
136+}
137+
138+func (wv *WebView) latestCEF2GTKProfileSnapshot(now time.Time) (cef2gtk.ProfileSnapshot, time.Duration, bool) {
139+ if wv == nil {
140+ return cef2gtk.ProfileSnapshot{}, 0, false
141+ }
142+ wv.profileMu.Lock()
143+ snapshot := wv.latestProfileSnapshot
144+ snapshotAt := wv.latestProfileSnapshotAt
145+ wv.profileMu.Unlock()
146+ if snapshotAt.IsZero() {
147+ return cef2gtk.ProfileSnapshot{}, 0, false
148+ }
149+ age := now.Sub(snapshotAt)
150+ if age < 0 {
151+ age = 0
152+ }
153+ return snapshot, age, true
154+}
155+
156+func (e *Engine) cef2gtkProfilePath() string {
157+ if explicit := strings.TrimSpace(os.Getenv(cef2gtkProfileOutputEnv)); explicit != "" {
158+ return explicit
159+ }
160+ metadata, ok := logging.SessionMetadataFromContext(e.ctx)
161+ if !ok || metadata.ID == "" {
162+ return ""
163+ }
164+ logDir := e.profileLogDir
165+ if metadata.LogPath != "" {
166+ logDir = filepath.Dir(metadata.LogPath)
167+ }
168+ if logDir == "" {
169+ return ""
170+ }
171+ return filepath.Join(logDir, fmt.Sprintf("session_%s_cef2gtk_profile.jsonl", metadata.ID))
172+}
A internal/infrastructure/cef/profile_test.go +55 -0
@@ -0,0 +1,55 @@
1+package cef
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+func TestCEF2GTKProfilePathUsesSessionLogDirectory(t *testing.T) {
14+ t.Setenv(cef2gtkProfileEnv, "1")
15+ logDir := t.TempDir()
16+ ctx := logging.WithSessionMetadata(context.Background(), "20260430_120000_abcd", filepath.Join(logDir, "session_20260430_120000_abcd.log"))
17+ eng := &Engine{ctx: ctx, profileLogDir: filepath.Join(t.TempDir(), "fallback")}
18+
19+ got := eng.cef2gtkProfilePath()
20+ want := filepath.Join(logDir, "session_20260430_120000_abcd_cef2gtk_profile.jsonl")
21+ if got != want {
22+ t.Fatalf("path = %q, want %q", got, want)
23+ }
24+}
25+
26+func TestCEF2GTKProfilePathAllowsExplicitOutput(t *testing.T) {
27+ explicit := filepath.Join(t.TempDir(), "profile.jsonl")
28+ t.Setenv(cef2gtkProfileOutputEnv, explicit)
29+ eng := &Engine{ctx: context.Background()}
30+
31+ if got := eng.cef2gtkProfilePath(); got != explicit {
32+ t.Fatalf("path = %q, want explicit output", got)
33+ }
34+}
35+
36+func TestCEF2GTKProfileOptionsDisabledByDefault(t *testing.T) {
37+ eng := &Engine{ctx: context.Background()}
38+ wv := &WebView{id: port.WebViewID(7)}
39+
40+ if opts := eng.cef2gtkProfileOptions(wv); opts.Enabled {
41+ t.Fatal("profile options enabled without env")
42+ }
43+}
44+
45+func TestLockedProfileWriterWriteReturnsErrorWhenClosed(t *testing.T) {
46+ var nilWriter *lockedProfileWriter
47+ if _, err := nilWriter.Write([]byte("x")); !errors.Is(err, errProfileWriterClosed) {
48+ t.Fatalf("nil writer error = %v, want %v", err, errProfileWriterClosed)
49+ }
50+
51+ writer := &lockedProfileWriter{}
52+ if _, err := writer.Write([]byte("x")); !errors.Is(err, errProfileWriterClosed) {
53+ t.Fatalf("closed writer error = %v, want %v", err, errProfileWriterClosed)
54+ }
55+}
@@ -0,0 +1,55 @@
1+package cef
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+func TestCEF2GTKProfilePathUsesSessionLogDirectory(t *testing.T) {
14+ t.Setenv(cef2gtkProfileEnv, "1")
15+ logDir := t.TempDir()
16+ ctx := logging.WithSessionMetadata(context.Background(), "20260430_120000_abcd", filepath.Join(logDir, "session_20260430_120000_abcd.log"))
17+ eng := &Engine{ctx: ctx, profileLogDir: filepath.Join(t.TempDir(), "fallback")}
18+
19+ got := eng.cef2gtkProfilePath()
20+ want := filepath.Join(logDir, "session_20260430_120000_abcd_cef2gtk_profile.jsonl")
21+ if got != want {
22+ t.Fatalf("path = %q, want %q", got, want)
23+ }
24+}
25+
26+func TestCEF2GTKProfilePathAllowsExplicitOutput(t *testing.T) {
27+ explicit := filepath.Join(t.TempDir(), "profile.jsonl")
28+ t.Setenv(cef2gtkProfileOutputEnv, explicit)
29+ eng := &Engine{ctx: context.Background()}
30+
31+ if got := eng.cef2gtkProfilePath(); got != explicit {
32+ t.Fatalf("path = %q, want explicit output", got)
33+ }
34+}
35+
36+func TestCEF2GTKProfileOptionsDisabledByDefault(t *testing.T) {
37+ eng := &Engine{ctx: context.Background()}
38+ wv := &WebView{id: port.WebViewID(7)}
39+
40+ if opts := eng.cef2gtkProfileOptions(wv); opts.Enabled {
41+ t.Fatal("profile options enabled without env")
42+ }
43+}
44+
45+func TestLockedProfileWriterWriteReturnsErrorWhenClosed(t *testing.T) {
46+ var nilWriter *lockedProfileWriter
47+ if _, err := nilWriter.Write([]byte("x")); !errors.Is(err, errProfileWriterClosed) {
48+ t.Fatalf("nil writer error = %v, want %v", err, errProfileWriterClosed)
49+ }
50+
51+ writer := &lockedProfileWriter{}
52+ if _, err := writer.Write([]byte("x")); !errors.Is(err, errProfileWriterClosed) {
53+ t.Fatalf("closed writer error = %v, want %v", err, errProfileWriterClosed)
54+ }
55+}
A internal/infrastructure/cef/render_diagnostics.go +181 -0
@@ -0,0 +1,181 @@
1+package cef
2+
3+import (
4+ "time"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ cef2gtk "github.com/bnema/purego-cef2gtk"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+const renderStallRecoveryCooldown = 10 * time.Second
14+
15+func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, stalledID port.WebViewID, reason string, classification renderStallClassification) {
16+ if e == nil {
17+ return
18+ }
19+ e.activeWebViews.Range(func(_, value any) bool {
20+ wv, ok := value.(*WebView)
21+ if !ok || wv == nil || wv.viewBridge == nil || wv.destroyed.Load() {
22+ return true
23+ }
24+ diag := wv.viewBridge.Diagnostics()
25+ lastPaintAge := time.Duration(0)
26+ if lastPaint := latestDiagnosticEventTime(diag, "accelerated-paint"); !lastPaint.IsZero() {
27+ lastPaintAge = now.Sub(lastPaint)
28+ }
29+ wv.mu.RLock()
30+ browser := wv.browser
31+ uri := wv.uri
32+ title := wv.title
33+ isLoading := wv.isLoading
34+ pendingURI := wv.pendingURI
35+ wv.mu.RUnlock()
36+ wv.logRenderDiagnosticSnapshot(now, reason, classification, wv.viewBridge, diag, lastPaintAge, browser, uri, title, isLoading, pendingURI, wv.audioPlaying.Load())
37+ return true
38+ })
39+}
40+
41+func (wv *WebView) logRenderDiagnosticSnapshot(
42+ now time.Time,
43+ reason string,
44+ classification renderStallClassification,
45+ bridge *Cef2gtkAdapter,
46+ diag cef2gtk.Diagnostics,
47+ lastPaintAge time.Duration,
48+ browser purecef.Browser,
49+ uri, title string,
50+ isLoading bool,
51+ pendingURI string,
52+ audioPlaying bool,
53+) {
54+ if wv == nil || wv.ctx == nil {
55+ return
56+ }
57+ var browserID int32
58+ if browser != nil {
59+ browserID = browser.GetIdentifier()
60+ }
61+ width, height := int32(0), int32(0)
62+ scale := float32(0)
63+ if bridge != nil {
64+ width, height = bridge.Size()
65+ scale = bridge.DeviceScaleFactor()
66+ }
67+ uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now)
68+ profile, profileAge, hasProfile := wv.latestCEF2GTKProfileSnapshot(now)
69+ logging.FromContext(wv.ctx).Warn().
70+ Str("reason", reason).
71+ Str("classification", classification.Category).
72+ Bool("cef_ui_blocked", classification.CEFUIBlocked).
73+ Bool("cef_io_alive", classification.CEFIOAlive).
74+ Uint64("webview_id", uint64(wv.id)).
75+ Int32("browser_id", browserID).
76+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
77+ Str("title", title).
78+ Bool("is_loading", isLoading).
79+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
80+ Bool("audio_playing", audioPlaying).
81+ Uint64("audio_packets", wv.audioPacketCount.Load()).
82+ Uint64("audio_writes", wv.audioWriteCount.Load()).
83+ Int64("last_accelerated_paint_age_ms", durationMillis(lastPaintAge)).
84+ Int("accelerated_paints", diag.AcceleratedPaints).
85+ Int("unsupported_paints", diag.UnsupportedPaints).
86+ Int("accelerated_paint_errors", diag.AcceleratedPaintErrors).
87+ Int("import_failures", diag.ImportFailures).
88+ Int("render_failures", diag.RenderFailures).
89+ Int32("surface_width", width).
90+ Int32("surface_height", height).
91+ Float32("surface_scale", scale).
92+ Bool("external_begin_frame", externalBeginFrameEnabled()).
93+ Bool("profile_snapshot_available", hasProfile).
94+ Int64("profile_snapshot_age_ms", durationMillis(profileAge)).
95+ Uint64("profile_frames_received", profile.FramesReceived).
96+ Uint64("profile_frames_queued", profile.FramesQueued).
97+ Uint64("profile_frames_rendered", profile.FramesRendered).
98+ Uint64("profile_import_failures", profile.ImportFailures).
99+ Uint64("profile_render_failures", profile.RenderFailures).
100+ Uint32("profile_gc_delta", profile.GC.NumGCDelta).
101+ Int64("cef_ui_heartbeat_ack_age_ms", durationMillis(uiHeartbeat.LastAckAge)).
102+ Int64("cef_ui_heartbeat_latency_ms", durationMillis(uiHeartbeat.Latency)).
103+ Int32("cef_ui_heartbeat_post_result", uiHeartbeat.PostResult).
104+ Bool("cef_ui_heartbeat_in_flight", uiHeartbeat.InFlight).
105+ Int64("cef_io_heartbeat_ack_age_ms", durationMillis(ioHeartbeat.LastAckAge)).
106+ Int64("cef_io_heartbeat_latency_ms", durationMillis(ioHeartbeat.Latency)).
107+ Int32("cef_io_heartbeat_post_result", ioHeartbeat.PostResult).
108+ Bool("cef_io_heartbeat_in_flight", ioHeartbeat.InFlight).
109+ Msg("cef: render diagnostic snapshot")
110+}
111+
112+func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAdapter, paintsBefore int) {
113+ if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallRecoveryEnabled() {
114+ return
115+ }
116+ wv.renderStallMu.Lock()
117+ last := wv.renderStallRecoveryLastAt
118+ if !last.IsZero() && now.Sub(last) < renderStallRecoveryCooldown {
119+ wv.renderStallMu.Unlock()
120+ return
121+ }
122+ wv.renderStallRecoveryLastAt = now
123+ wv.renderStallMu.Unlock()
124+
125+ wv.mu.RLock()
126+ host := wv.host
127+ browser := wv.browser
128+ wv.mu.RUnlock()
129+ if host == nil {
130+ return
131+ }
132+ browserID := int32(0)
133+ if browser != nil {
134+ browserID = browser.GetIdentifier()
135+ }
136+ logging.FromContext(wv.ctx).Warn().
137+ Uint64("webview_id", uint64(wv.id)).
138+ Int32("browser_id", browserID).
139+ Int("accelerated_paints_before", paintsBefore).
140+ Msg("cef: render stall recovery pulse scheduled")
141+ task := cefNewTask(cefTaskFunc(func() {
142+ if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil || host == nil {
143+ return
144+ }
145+ host.Invalidate(purecef.PaintElementTypePetView)
146+ host.WasHidden(1)
147+ host.WasHidden(0)
148+ host.NotifyScreenInfoChanged()
149+ host.WasResized()
150+ host.Invalidate(purecef.PaintElementTypePetView)
151+ logging.FromContext(wv.ctx).Warn().
152+ Uint64("webview_id", uint64(wv.id)).
153+ Int32("browser_id", browserID).
154+ Msg("cef: render stall recovery pulse executed")
155+ }))
156+ if task == nil {
157+ return
158+ }
159+ result := cefPostTask(purecef.ThreadIDTidUi, task)
160+ logging.FromContext(wv.ctx).Warn().
161+ Uint64("webview_id", uint64(wv.id)).
162+ Int32("browser_id", browserID).
163+ Int32("post_result", result).
164+ Msg("cef: render stall recovery pulse posted")
165+ if result != 1 {
166+ return
167+ }
168+ cefScheduleAfter(2*time.Second, func() {
169+ if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil {
170+ return
171+ }
172+ after := wv.viewBridge.Diagnostics().AcceleratedPaints
173+ logging.FromContext(wv.ctx).Warn().
174+ Uint64("webview_id", uint64(wv.id)).
175+ Int32("browser_id", browserID).
176+ Int("accelerated_paints_before", paintsBefore).
177+ Int("accelerated_paints_after", after).
178+ Bool("recovered", after > paintsBefore).
179+ Msg("cef: render stall recovery pulse result")
180+ })
181+}
@@ -0,0 +1,181 @@
1+package cef
2+
3+import (
4+ "time"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ cef2gtk "github.com/bnema/purego-cef2gtk"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+const renderStallRecoveryCooldown = 10 * time.Second
14+
15+func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, stalledID port.WebViewID, reason string, classification renderStallClassification) {
16+ if e == nil {
17+ return
18+ }
19+ e.activeWebViews.Range(func(_, value any) bool {
20+ wv, ok := value.(*WebView)
21+ if !ok || wv == nil || wv.viewBridge == nil || wv.destroyed.Load() {
22+ return true
23+ }
24+ diag := wv.viewBridge.Diagnostics()
25+ lastPaintAge := time.Duration(0)
26+ if lastPaint := latestDiagnosticEventTime(diag, "accelerated-paint"); !lastPaint.IsZero() {
27+ lastPaintAge = now.Sub(lastPaint)
28+ }
29+ wv.mu.RLock()
30+ browser := wv.browser
31+ uri := wv.uri
32+ title := wv.title
33+ isLoading := wv.isLoading
34+ pendingURI := wv.pendingURI
35+ wv.mu.RUnlock()
36+ wv.logRenderDiagnosticSnapshot(now, reason, classification, wv.viewBridge, diag, lastPaintAge, browser, uri, title, isLoading, pendingURI, wv.audioPlaying.Load())
37+ return true
38+ })
39+}
40+
41+func (wv *WebView) logRenderDiagnosticSnapshot(
42+ now time.Time,
43+ reason string,
44+ classification renderStallClassification,
45+ bridge *Cef2gtkAdapter,
46+ diag cef2gtk.Diagnostics,
47+ lastPaintAge time.Duration,
48+ browser purecef.Browser,
49+ uri, title string,
50+ isLoading bool,
51+ pendingURI string,
52+ audioPlaying bool,
53+) {
54+ if wv == nil || wv.ctx == nil {
55+ return
56+ }
57+ var browserID int32
58+ if browser != nil {
59+ browserID = browser.GetIdentifier()
60+ }
61+ width, height := int32(0), int32(0)
62+ scale := float32(0)
63+ if bridge != nil {
64+ width, height = bridge.Size()
65+ scale = bridge.DeviceScaleFactor()
66+ }
67+ uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now)
68+ profile, profileAge, hasProfile := wv.latestCEF2GTKProfileSnapshot(now)
69+ logging.FromContext(wv.ctx).Warn().
70+ Str("reason", reason).
71+ Str("classification", classification.Category).
72+ Bool("cef_ui_blocked", classification.CEFUIBlocked).
73+ Bool("cef_io_alive", classification.CEFIOAlive).
74+ Uint64("webview_id", uint64(wv.id)).
75+ Int32("browser_id", browserID).
76+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
77+ Str("title", title).
78+ Bool("is_loading", isLoading).
79+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
80+ Bool("audio_playing", audioPlaying).
81+ Uint64("audio_packets", wv.audioPacketCount.Load()).
82+ Uint64("audio_writes", wv.audioWriteCount.Load()).
83+ Int64("last_accelerated_paint_age_ms", durationMillis(lastPaintAge)).
84+ Int("accelerated_paints", diag.AcceleratedPaints).
85+ Int("unsupported_paints", diag.UnsupportedPaints).
86+ Int("accelerated_paint_errors", diag.AcceleratedPaintErrors).
87+ Int("import_failures", diag.ImportFailures).
88+ Int("render_failures", diag.RenderFailures).
89+ Int32("surface_width", width).
90+ Int32("surface_height", height).
91+ Float32("surface_scale", scale).
92+ Bool("external_begin_frame", externalBeginFrameEnabled()).
93+ Bool("profile_snapshot_available", hasProfile).
94+ Int64("profile_snapshot_age_ms", durationMillis(profileAge)).
95+ Uint64("profile_frames_received", profile.FramesReceived).
96+ Uint64("profile_frames_queued", profile.FramesQueued).
97+ Uint64("profile_frames_rendered", profile.FramesRendered).
98+ Uint64("profile_import_failures", profile.ImportFailures).
99+ Uint64("profile_render_failures", profile.RenderFailures).
100+ Uint32("profile_gc_delta", profile.GC.NumGCDelta).
101+ Int64("cef_ui_heartbeat_ack_age_ms", durationMillis(uiHeartbeat.LastAckAge)).
102+ Int64("cef_ui_heartbeat_latency_ms", durationMillis(uiHeartbeat.Latency)).
103+ Int32("cef_ui_heartbeat_post_result", uiHeartbeat.PostResult).
104+ Bool("cef_ui_heartbeat_in_flight", uiHeartbeat.InFlight).
105+ Int64("cef_io_heartbeat_ack_age_ms", durationMillis(ioHeartbeat.LastAckAge)).
106+ Int64("cef_io_heartbeat_latency_ms", durationMillis(ioHeartbeat.Latency)).
107+ Int32("cef_io_heartbeat_post_result", ioHeartbeat.PostResult).
108+ Bool("cef_io_heartbeat_in_flight", ioHeartbeat.InFlight).
109+ Msg("cef: render diagnostic snapshot")
110+}
111+
112+func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAdapter, paintsBefore int) {
113+ if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallRecoveryEnabled() {
114+ return
115+ }
116+ wv.renderStallMu.Lock()
117+ last := wv.renderStallRecoveryLastAt
118+ if !last.IsZero() && now.Sub(last) < renderStallRecoveryCooldown {
119+ wv.renderStallMu.Unlock()
120+ return
121+ }
122+ wv.renderStallRecoveryLastAt = now
123+ wv.renderStallMu.Unlock()
124+
125+ wv.mu.RLock()
126+ host := wv.host
127+ browser := wv.browser
128+ wv.mu.RUnlock()
129+ if host == nil {
130+ return
131+ }
132+ browserID := int32(0)
133+ if browser != nil {
134+ browserID = browser.GetIdentifier()
135+ }
136+ logging.FromContext(wv.ctx).Warn().
137+ Uint64("webview_id", uint64(wv.id)).
138+ Int32("browser_id", browserID).
139+ Int("accelerated_paints_before", paintsBefore).
140+ Msg("cef: render stall recovery pulse scheduled")
141+ task := cefNewTask(cefTaskFunc(func() {
142+ if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil || host == nil {
143+ return
144+ }
145+ host.Invalidate(purecef.PaintElementTypePetView)
146+ host.WasHidden(1)
147+ host.WasHidden(0)
148+ host.NotifyScreenInfoChanged()
149+ host.WasResized()
150+ host.Invalidate(purecef.PaintElementTypePetView)
151+ logging.FromContext(wv.ctx).Warn().
152+ Uint64("webview_id", uint64(wv.id)).
153+ Int32("browser_id", browserID).
154+ Msg("cef: render stall recovery pulse executed")
155+ }))
156+ if task == nil {
157+ return
158+ }
159+ result := cefPostTask(purecef.ThreadIDTidUi, task)
160+ logging.FromContext(wv.ctx).Warn().
161+ Uint64("webview_id", uint64(wv.id)).
162+ Int32("browser_id", browserID).
163+ Int32("post_result", result).
164+ Msg("cef: render stall recovery pulse posted")
165+ if result != 1 {
166+ return
167+ }
168+ cefScheduleAfter(2*time.Second, func() {
169+ if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil {
170+ return
171+ }
172+ after := wv.viewBridge.Diagnostics().AcceleratedPaints
173+ logging.FromContext(wv.ctx).Warn().
174+ Uint64("webview_id", uint64(wv.id)).
175+ Int32("browser_id", browserID).
176+ Int("accelerated_paints_before", paintsBefore).
177+ Int("accelerated_paints_after", after).
178+ Bool("recovered", after > paintsBefore).
179+ Msg("cef: render stall recovery pulse result")
180+ })
181+}
A internal/infrastructure/cef/render_stack_env.go +80 -0
@@ -0,0 +1,80 @@
1+package cef
2+
3+import (
4+ "context"
5+ "os"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+const (
12+ dumberRenderStackEnvVar = "DUMBER_RENDER_STACK"
13+
14+ renderStackAuto = "auto"
15+ renderStackVulkanDMABUF = "vulkan-dmabuf"
16+ renderStackLegacyGL = "legacy-gl"
17+ cef2gtkBackendEnvVar = "PUREGO_CEF2GTK_BACKEND"
18+ cef2gtkAngleBackendVar = "PUREGO_CEF2GTK_ANGLE_BACKEND"
19+ gskRendererEnvVar = "GSK_RENDERER"
20+ cef2gtkBackendGDKDMABUF = "gdk-dmabuf"
21+ cef2gtkBackendGLArea = "glarea"
22+ cef2gtkAngleVulkan = "vulkan"
23+ cef2gtkAngleGLEGL = "gl-egl"
24+ gskRendererVulkan = "vulkan"
25+ gskRendererOpenGL = "opengl"
26+)
27+
28+// ApplyDefaultRenderStackEnvironment configures the process environment for
29+// Dumber's default CEF presentation stack before GTK/libadwaita initialize.
30+//
31+// DUMBER_RENDER_STACK accepts:
32+// - auto or empty: use the GPU-first GDK DMABUF stack
33+// - vulkan-dmabuf: force GDK DMABUF presentation with GSK Vulkan and CEF ANGLE GL/EGL
34+// - legacy-gl: force the older GtkGLArea/OpenGL bridge for diagnostics
35+//
36+// Low-level env vars remain diagnostic escape hatches: when explicitly set,
37+// they are preserved instead of overwritten.
38+func ApplyDefaultRenderStackEnvironment(ctx context.Context) string {
39+ return applyDefaultRenderStackEnvironment(ctx)
40+}
41+
42+func applyDefaultRenderStackEnvironment(ctx context.Context) string {
43+ switch normalizeRenderStack(os.Getenv(dumberRenderStackEnvVar)) {
44+ case renderStackLegacyGL:
45+ setEnvDefault(ctx, gskRendererEnvVar, gskRendererOpenGL)
46+ setEnvDefault(ctx, cef2gtkBackendEnvVar, cef2gtkBackendGLArea)
47+ setEnvDefault(ctx, cef2gtkAngleBackendVar, cef2gtkAngleGLEGL)
48+ return renderStackLegacyGL
49+ default:
50+ setEnvDefault(ctx, gskRendererEnvVar, gskRendererVulkan)
51+ setEnvDefault(ctx, cef2gtkBackendEnvVar, cef2gtkBackendGDKDMABUF)
52+ setEnvDefault(ctx, cef2gtkAngleBackendVar, cef2gtkAngleGLEGL)
53+ return renderStackVulkanDMABUF
54+ }
55+}
56+
57+func normalizeRenderStack(value string) string {
58+ switch strings.ToLower(strings.TrimSpace(value)) {
59+ case "", renderStackAuto, renderStackVulkanDMABUF:
60+ return renderStackVulkanDMABUF
61+ case renderStackLegacyGL:
62+ return renderStackLegacyGL
63+ default:
64+ return renderStackVulkanDMABUF
65+ }
66+}
67+
68+func setEnvDefault(ctx context.Context, key, value string) {
69+ if existing := strings.TrimSpace(os.Getenv(key)); existing != "" {
70+ if ctx == nil {
71+ ctx = context.Background()
72+ }
73+ logging.FromContext(ctx).Debug().
74+ Str("key", key).
75+ Str("value", existing).
76+ Msg("cef: preserving explicit render stack environment override")
77+ return
78+ }
79+ _ = os.Setenv(key, value)
80+}
@@ -0,0 +1,80 @@
1+package cef
2+
3+import (
4+ "context"
5+ "os"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+const (
12+ dumberRenderStackEnvVar = "DUMBER_RENDER_STACK"
13+
14+ renderStackAuto = "auto"
15+ renderStackVulkanDMABUF = "vulkan-dmabuf"
16+ renderStackLegacyGL = "legacy-gl"
17+ cef2gtkBackendEnvVar = "PUREGO_CEF2GTK_BACKEND"
18+ cef2gtkAngleBackendVar = "PUREGO_CEF2GTK_ANGLE_BACKEND"
19+ gskRendererEnvVar = "GSK_RENDERER"
20+ cef2gtkBackendGDKDMABUF = "gdk-dmabuf"
21+ cef2gtkBackendGLArea = "glarea"
22+ cef2gtkAngleVulkan = "vulkan"
23+ cef2gtkAngleGLEGL = "gl-egl"
24+ gskRendererVulkan = "vulkan"
25+ gskRendererOpenGL = "opengl"
26+)
27+
28+// ApplyDefaultRenderStackEnvironment configures the process environment for
29+// Dumber's default CEF presentation stack before GTK/libadwaita initialize.
30+//
31+// DUMBER_RENDER_STACK accepts:
32+// - auto or empty: use the GPU-first GDK DMABUF stack
33+// - vulkan-dmabuf: force GDK DMABUF presentation with GSK Vulkan and CEF ANGLE GL/EGL
34+// - legacy-gl: force the older GtkGLArea/OpenGL bridge for diagnostics
35+//
36+// Low-level env vars remain diagnostic escape hatches: when explicitly set,
37+// they are preserved instead of overwritten.
38+func ApplyDefaultRenderStackEnvironment(ctx context.Context) string {
39+ return applyDefaultRenderStackEnvironment(ctx)
40+}
41+
42+func applyDefaultRenderStackEnvironment(ctx context.Context) string {
43+ switch normalizeRenderStack(os.Getenv(dumberRenderStackEnvVar)) {
44+ case renderStackLegacyGL:
45+ setEnvDefault(ctx, gskRendererEnvVar, gskRendererOpenGL)
46+ setEnvDefault(ctx, cef2gtkBackendEnvVar, cef2gtkBackendGLArea)
47+ setEnvDefault(ctx, cef2gtkAngleBackendVar, cef2gtkAngleGLEGL)
48+ return renderStackLegacyGL
49+ default:
50+ setEnvDefault(ctx, gskRendererEnvVar, gskRendererVulkan)
51+ setEnvDefault(ctx, cef2gtkBackendEnvVar, cef2gtkBackendGDKDMABUF)
52+ setEnvDefault(ctx, cef2gtkAngleBackendVar, cef2gtkAngleGLEGL)
53+ return renderStackVulkanDMABUF
54+ }
55+}
56+
57+func normalizeRenderStack(value string) string {
58+ switch strings.ToLower(strings.TrimSpace(value)) {
59+ case "", renderStackAuto, renderStackVulkanDMABUF:
60+ return renderStackVulkanDMABUF
61+ case renderStackLegacyGL:
62+ return renderStackLegacyGL
63+ default:
64+ return renderStackVulkanDMABUF
65+ }
66+}
67+
68+func setEnvDefault(ctx context.Context, key, value string) {
69+ if existing := strings.TrimSpace(os.Getenv(key)); existing != "" {
70+ if ctx == nil {
71+ ctx = context.Background()
72+ }
73+ logging.FromContext(ctx).Debug().
74+ Str("key", key).
75+ Str("value", existing).
76+ Msg("cef: preserving explicit render stack environment override")
77+ return
78+ }
79+ _ = os.Setenv(key, value)
80+}
A internal/infrastructure/cef/render_stack_env_test.go +70 -0
@@ -0,0 +1,70 @@
1+package cef
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+)
8+
9+func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEGL(t *testing.T) {
10+ t.Setenv(dumberRenderStackEnvVar, "")
11+ t.Setenv("GSK_RENDERER", "")
12+ t.Setenv("PUREGO_CEF2GTK_BACKEND", "")
13+ t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "")
14+
15+ got := applyDefaultRenderStackEnvironment(context.Background())
16+
17+ if got != renderStackVulkanDMABUF {
18+ t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF)
19+ }
20+ if got := os.Getenv("GSK_RENDERER"); got != "vulkan" {
21+ t.Fatalf("GSK_RENDERER = %q, want vulkan", got)
22+ }
23+ if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "gdk-dmabuf" {
24+ t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want gdk-dmabuf", got)
25+ }
26+ if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" {
27+ t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want gl-egl", got)
28+ }
29+}
30+
31+func TestApplyDefaultRenderStackEnvironment_PreservesExplicitLowLevelOverrides(t *testing.T) {
32+ t.Setenv(dumberRenderStackEnvVar, "")
33+ t.Setenv("GSK_RENDERER", "ngl")
34+ t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea")
35+ t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl")
36+
37+ got := applyDefaultRenderStackEnvironment(context.Background())
38+
39+ if got != renderStackVulkanDMABUF {
40+ t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF)
41+ }
42+ if got := os.Getenv("GSK_RENDERER"); got != "ngl" {
43+ t.Fatalf("GSK_RENDERER = %q, want explicit ngl", got)
44+ }
45+ if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "glarea" {
46+ t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want explicit glarea", got)
47+ }
48+ if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" {
49+ t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want explicit gl-egl", got)
50+ }
51+}
52+
53+func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) {
54+ t.Setenv(dumberRenderStackEnvVar, "legacy-gl")
55+ t.Setenv("GSK_RENDERER", "")
56+ t.Setenv("PUREGO_CEF2GTK_BACKEND", "")
57+ t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "")
58+
59+ got := applyDefaultRenderStackEnvironment(context.Background())
60+
61+ if got != renderStackLegacyGL {
62+ t.Fatalf("render stack = %q, want %q", got, renderStackLegacyGL)
63+ }
64+ if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "glarea" {
65+ t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want glarea", got)
66+ }
67+ if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" {
68+ t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want gl-egl", got)
69+ }
70+}
@@ -0,0 +1,70 @@
1+package cef
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+)
8+
9+func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEGL(t *testing.T) {
10+ t.Setenv(dumberRenderStackEnvVar, "")
11+ t.Setenv("GSK_RENDERER", "")
12+ t.Setenv("PUREGO_CEF2GTK_BACKEND", "")
13+ t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "")
14+
15+ got := applyDefaultRenderStackEnvironment(context.Background())
16+
17+ if got != renderStackVulkanDMABUF {
18+ t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF)
19+ }
20+ if got := os.Getenv("GSK_RENDERER"); got != "vulkan" {
21+ t.Fatalf("GSK_RENDERER = %q, want vulkan", got)
22+ }
23+ if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "gdk-dmabuf" {
24+ t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want gdk-dmabuf", got)
25+ }
26+ if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" {
27+ t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want gl-egl", got)
28+ }
29+}
30+
31+func TestApplyDefaultRenderStackEnvironment_PreservesExplicitLowLevelOverrides(t *testing.T) {
32+ t.Setenv(dumberRenderStackEnvVar, "")
33+ t.Setenv("GSK_RENDERER", "ngl")
34+ t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea")
35+ t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl")
36+
37+ got := applyDefaultRenderStackEnvironment(context.Background())
38+
39+ if got != renderStackVulkanDMABUF {
40+ t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF)
41+ }
42+ if got := os.Getenv("GSK_RENDERER"); got != "ngl" {
43+ t.Fatalf("GSK_RENDERER = %q, want explicit ngl", got)
44+ }
45+ if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "glarea" {
46+ t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want explicit glarea", got)
47+ }
48+ if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" {
49+ t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want explicit gl-egl", got)
50+ }
51+}
52+
53+func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) {
54+ t.Setenv(dumberRenderStackEnvVar, "legacy-gl")
55+ t.Setenv("GSK_RENDERER", "")
56+ t.Setenv("PUREGO_CEF2GTK_BACKEND", "")
57+ t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "")
58+
59+ got := applyDefaultRenderStackEnvironment(context.Background())
60+
61+ if got != renderStackLegacyGL {
62+ t.Fatalf("render stack = %q, want %q", got, renderStackLegacyGL)
63+ }
64+ if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "glarea" {
65+ t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want glarea", got)
66+ }
67+ if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" {
68+ t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want gl-egl", got)
69+ }
70+}
A internal/infrastructure/cef/render_stall_watchdog.go +240 -0
@@ -0,0 +1,240 @@
1+package cef
2+
3+import (
4+ "time"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ cef2gtk "github.com/bnema/purego-cef2gtk"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+const (
13+ renderStallWatchdogInterval = 1 * time.Second
14+ renderStallWarnAfter = 3 * time.Second
15+ renderStallWarnRepeat = 10 * time.Second
16+ renderStallCEFUIBlockedAfter = 3 * time.Second
17+)
18+
19+type renderStallClassification struct {
20+ Category string
21+ CEFUIBlocked bool
22+ CEFIOAlive bool
23+}
24+
25+type renderStallDiagnostics struct {
26+ browser purecef.Browser
27+ uri string
28+ title string
29+ isLoading bool
30+ pendingURI string
31+ audioPlaying bool
32+ diag cef2gtk.Diagnostics
33+ lastPaintAge time.Duration
34+ latestEvent cef2gtk.DiagnosticEvent
35+}
36+
37+func classifyRenderStall(uiHeartbeat, ioHeartbeat cefHeartbeatSnapshot) renderStallClassification {
38+ uiBlocked := uiHeartbeat.PostResult == 1 && uiHeartbeat.InFlight && uiHeartbeat.LastAckAge >= renderStallCEFUIBlockedAfter
39+ ioAlive := ioHeartbeat.PostResult == 1 && !ioHeartbeat.InFlight && ioHeartbeat.LastAckAge > 0 && ioHeartbeat.LastAckAge < renderStallCEFUIBlockedAfter
40+ category := "osr-paint-stalled"
41+ if uiBlocked {
42+ category = "cef-ui-task-runner-blocked"
43+ if ioAlive {
44+ category = "cef-ui-task-runner-blocked-io-alive"
45+ }
46+ }
47+ return renderStallClassification{Category: category, CEFUIBlocked: uiBlocked, CEFIOAlive: ioAlive}
48+}
49+
50+func (wv *WebView) startRenderStallWatchdog() {
51+ if wv == nil || wv.ctx == nil || wv.viewBridge == nil {
52+ return
53+ }
54+
55+ wv.renderStallMu.Lock()
56+ defer wv.renderStallMu.Unlock()
57+ if wv.renderStallStop != nil {
58+ return
59+ }
60+
61+ stop := make(chan struct{})
62+ done := make(chan struct{})
63+ bridge := wv.viewBridge
64+ wv.renderStallStop = stop
65+ wv.renderStallDone = done
66+
67+ go wv.renderStallWatchdogLoop(bridge, stop, done)
68+}
69+
70+func (wv *WebView) stopRenderStallWatchdog() {
71+ if wv == nil {
72+ return
73+ }
74+ wv.renderStallMu.Lock()
75+ stop := wv.renderStallStop
76+ done := wv.renderStallDone
77+ wv.renderStallStop = nil
78+ wv.renderStallDone = nil
79+ wv.renderStallMu.Unlock()
80+
81+ if stop == nil || done == nil {
82+ return
83+ }
84+ close(stop)
85+ <-done
86+}
87+
88+func (wv *WebView) renderStallWatchdogLoop(bridge *Cef2gtkAdapter, stop <-chan struct{}, done chan<- struct{}) {
89+ defer close(done)
90+ ticker := time.NewTicker(renderStallWatchdogInterval)
91+ defer ticker.Stop()
92+
93+ var lastWarn time.Time
94+ for {
95+ select {
96+ case <-stop:
97+ return
98+ case now := <-ticker.C:
99+ if wv.maybeLogRenderStall(bridge, now, lastWarn) {
100+ lastWarn = now
101+ }
102+ }
103+ }
104+}
105+
106+func (wv *WebView) maybeLogRenderStall(bridge *Cef2gtkAdapter, now, lastWarn time.Time) bool {
107+ diag, ok := wv.shouldLogRenderStall(bridge, now, lastWarn)
108+ if !ok {
109+ return false
110+ }
111+ wv.logRenderStall(bridge, now, diag)
112+ return true
113+}
114+
115+func (wv *WebView) shouldLogRenderStall(bridge *Cef2gtkAdapter, now, lastWarn time.Time) (renderStallDiagnostics, bool) {
116+ if wv == nil || bridge == nil || wv.destroyed.Load() || wv.ctx == nil {
117+ return renderStallDiagnostics{}, false
118+ }
119+ diag, ok := wv.collectStallDiagnostics(bridge, now)
120+ if !ok {
121+ return renderStallDiagnostics{}, false
122+ }
123+ if diag.lastPaintAge < renderStallWarnAfter {
124+ return renderStallDiagnostics{}, false
125+ }
126+ if !lastWarn.IsZero() && now.Sub(lastWarn) < renderStallWarnRepeat {
127+ return renderStallDiagnostics{}, false
128+ }
129+ return diag, true
130+}
131+
132+func (wv *WebView) collectStallDiagnostics(bridge *Cef2gtkAdapter, now time.Time) (renderStallDiagnostics, bool) {
133+ wv.mu.RLock()
134+ browser := wv.browser
135+ uri := wv.uri
136+ title := wv.title
137+ isLoading := wv.isLoading
138+ pendingURI := wv.pendingURI
139+ wv.mu.RUnlock()
140+ if browser == nil {
141+ return renderStallDiagnostics{}, false
142+ }
143+
144+ audioPlaying := wv.audioPlaying.Load()
145+ if !audioPlaying && !isLoading && pendingURI == "" {
146+ return renderStallDiagnostics{}, false
147+ }
148+
149+ diag := bridge.Diagnostics()
150+ lastPaint := latestDiagnosticEventTime(diag, "accelerated-paint")
151+ if lastPaint.IsZero() {
152+ return renderStallDiagnostics{}, false
153+ }
154+ return renderStallDiagnostics{
155+ browser: browser,
156+ uri: uri,
157+ title: title,
158+ isLoading: isLoading,
159+ pendingURI: pendingURI,
160+ audioPlaying: audioPlaying,
161+ diag: diag,
162+ lastPaintAge: now.Sub(lastPaint),
163+ latestEvent: latestDiagnosticEvent(diag),
164+ }, true
165+}
166+
167+func (wv *WebView) logRenderStall(bridge *Cef2gtkAdapter, now time.Time, diag renderStallDiagnostics) {
168+ uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now)
169+ classification := classifyRenderStall(uiHeartbeat, ioHeartbeat)
170+ wv.logRenderDiagnosticSnapshot(now, "stall", classification, bridge, diag.diag, diag.lastPaintAge, diag.browser, diag.uri, diag.title, diag.isLoading, diag.pendingURI, diag.audioPlaying)
171+ wv.logCEFProcessDiagnostics("stall", classification)
172+ if wv.engine != nil {
173+ wv.engine.logAllWebViewRenderSnapshots(now, wv.id, "stall-global", classification)
174+ }
175+ wv.maybeRunRenderStallRecovery(now, bridge, diag.diag.AcceleratedPaints)
176+ profile, profileAge, hasProfile := wv.latestCEF2GTKProfileSnapshot(now)
177+ logging.FromContext(wv.ctx).Warn().
178+ Str("classification", classification.Category).
179+ Bool("cef_ui_blocked", classification.CEFUIBlocked).
180+ Bool("cef_io_alive", classification.CEFIOAlive).
181+ Uint64("webview_id", uint64(wv.id)).
182+ Int32("browser_id", diag.browser.GetIdentifier()).
183+ Str("uri", logging.TruncateURL(diag.uri, logging.PermissionLogURLMaxLen)).
184+ Str("title", diag.title).
185+ Bool("is_loading", diag.isLoading).
186+ Str("pending_uri", logging.TruncateURL(diag.pendingURI, logging.PermissionLogURLMaxLen)).
187+ Bool("audio_playing", diag.audioPlaying).
188+ Int64("cef_ui_heartbeat_ack_age_ms", durationMillis(uiHeartbeat.LastAckAge)).
189+ Int64("cef_ui_heartbeat_latency_ms", durationMillis(uiHeartbeat.Latency)).
190+ Int32("cef_ui_heartbeat_post_result", uiHeartbeat.PostResult).
191+ Bool("cef_ui_heartbeat_in_flight", uiHeartbeat.InFlight).
192+ Int64("cef_io_heartbeat_ack_age_ms", durationMillis(ioHeartbeat.LastAckAge)).
193+ Int64("cef_io_heartbeat_latency_ms", durationMillis(ioHeartbeat.Latency)).
194+ Int32("cef_io_heartbeat_post_result", ioHeartbeat.PostResult).
195+ Bool("cef_io_heartbeat_in_flight", ioHeartbeat.InFlight).
196+ Uint64("audio_packets", wv.audioPacketCount.Load()).
197+ Uint64("audio_writes", wv.audioWriteCount.Load()).
198+ Int64("last_accelerated_paint_age_ms", diag.lastPaintAge.Milliseconds()).
199+ Bool("profile_snapshot_available", hasProfile).
200+ Int64("profile_snapshot_age_ms", durationMillis(profileAge)).
201+ Uint64("profile_frames_received", profile.FramesReceived).
202+ Uint64("profile_frames_queued", profile.FramesQueued).
203+ Uint64("profile_frames_rendered", profile.FramesRendered).
204+ Uint32("profile_gc_delta", profile.GC.NumGCDelta).
205+ Int("accelerated_paints", diag.diag.AcceleratedPaints).
206+ Int("unsupported_paints", diag.diag.UnsupportedPaints).
207+ Int("accelerated_paint_errors", diag.diag.AcceleratedPaintErrors).
208+ Int("import_failures", diag.diag.ImportFailures).
209+ Int("render_failures", diag.diag.RenderFailures).
210+ Str("latest_render_event_kind", diag.latestEvent.Kind).
211+ Str("latest_render_event_message", diag.latestEvent.Message).
212+ Msg("cef: accelerated rendering appears stalled")
213+}
214+
215+func latestDiagnosticEventTime(diag cef2gtk.Diagnostics, kind string) time.Time {
216+ return latestDiagnosticEventOfKind(diag, kind).Time
217+}
218+
219+func latestDiagnosticEventOfKind(diag cef2gtk.Diagnostics, kind string) cef2gtk.DiagnosticEvent {
220+ var latest cef2gtk.DiagnosticEvent
221+ for _, event := range diag.Events {
222+ if event.Kind != kind {
223+ continue
224+ }
225+ if latest.Time.IsZero() || event.Time.After(latest.Time) {
226+ latest = event
227+ }
228+ }
229+ return latest
230+}
231+
232+func latestDiagnosticEvent(diag cef2gtk.Diagnostics) cef2gtk.DiagnosticEvent {
233+ var latest cef2gtk.DiagnosticEvent
234+ for _, event := range diag.Events {
235+ if latest.Time.IsZero() || event.Time.After(latest.Time) {
236+ latest = event
237+ }
238+ }
239+ return latest
240+}
@@ -0,0 +1,240 @@
1+package cef
2+
3+import (
4+ "time"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ cef2gtk "github.com/bnema/purego-cef2gtk"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+const (
13+ renderStallWatchdogInterval = 1 * time.Second
14+ renderStallWarnAfter = 3 * time.Second
15+ renderStallWarnRepeat = 10 * time.Second
16+ renderStallCEFUIBlockedAfter = 3 * time.Second
17+)
18+
19+type renderStallClassification struct {
20+ Category string
21+ CEFUIBlocked bool
22+ CEFIOAlive bool
23+}
24+
25+type renderStallDiagnostics struct {
26+ browser purecef.Browser
27+ uri string
28+ title string
29+ isLoading bool
30+ pendingURI string
31+ audioPlaying bool
32+ diag cef2gtk.Diagnostics
33+ lastPaintAge time.Duration
34+ latestEvent cef2gtk.DiagnosticEvent
35+}
36+
37+func classifyRenderStall(uiHeartbeat, ioHeartbeat cefHeartbeatSnapshot) renderStallClassification {
38+ uiBlocked := uiHeartbeat.PostResult == 1 && uiHeartbeat.InFlight && uiHeartbeat.LastAckAge >= renderStallCEFUIBlockedAfter
39+ ioAlive := ioHeartbeat.PostResult == 1 && !ioHeartbeat.InFlight && ioHeartbeat.LastAckAge > 0 && ioHeartbeat.LastAckAge < renderStallCEFUIBlockedAfter
40+ category := "osr-paint-stalled"
41+ if uiBlocked {
42+ category = "cef-ui-task-runner-blocked"
43+ if ioAlive {
44+ category = "cef-ui-task-runner-blocked-io-alive"
45+ }
46+ }
47+ return renderStallClassification{Category: category, CEFUIBlocked: uiBlocked, CEFIOAlive: ioAlive}
48+}
49+
50+func (wv *WebView) startRenderStallWatchdog() {
51+ if wv == nil || wv.ctx == nil || wv.viewBridge == nil {
52+ return
53+ }
54+
55+ wv.renderStallMu.Lock()
56+ defer wv.renderStallMu.Unlock()
57+ if wv.renderStallStop != nil {
58+ return
59+ }
60+
61+ stop := make(chan struct{})
62+ done := make(chan struct{})
63+ bridge := wv.viewBridge
64+ wv.renderStallStop = stop
65+ wv.renderStallDone = done
66+
67+ go wv.renderStallWatchdogLoop(bridge, stop, done)
68+}
69+
70+func (wv *WebView) stopRenderStallWatchdog() {
71+ if wv == nil {
72+ return
73+ }
74+ wv.renderStallMu.Lock()
75+ stop := wv.renderStallStop
76+ done := wv.renderStallDone
77+ wv.renderStallStop = nil
78+ wv.renderStallDone = nil
79+ wv.renderStallMu.Unlock()
80+
81+ if stop == nil || done == nil {
82+ return
83+ }
84+ close(stop)
85+ <-done
86+}
87+
88+func (wv *WebView) renderStallWatchdogLoop(bridge *Cef2gtkAdapter, stop <-chan struct{}, done chan<- struct{}) {
89+ defer close(done)
90+ ticker := time.NewTicker(renderStallWatchdogInterval)
91+ defer ticker.Stop()
92+
93+ var lastWarn time.Time
94+ for {
95+ select {
96+ case <-stop:
97+ return
98+ case now := <-ticker.C:
99+ if wv.maybeLogRenderStall(bridge, now, lastWarn) {
100+ lastWarn = now
101+ }
102+ }
103+ }
104+}
105+
106+func (wv *WebView) maybeLogRenderStall(bridge *Cef2gtkAdapter, now, lastWarn time.Time) bool {
107+ diag, ok := wv.shouldLogRenderStall(bridge, now, lastWarn)
108+ if !ok {
109+ return false
110+ }
111+ wv.logRenderStall(bridge, now, diag)
112+ return true
113+}
114+
115+func (wv *WebView) shouldLogRenderStall(bridge *Cef2gtkAdapter, now, lastWarn time.Time) (renderStallDiagnostics, bool) {
116+ if wv == nil || bridge == nil || wv.destroyed.Load() || wv.ctx == nil {
117+ return renderStallDiagnostics{}, false
118+ }
119+ diag, ok := wv.collectStallDiagnostics(bridge, now)
120+ if !ok {
121+ return renderStallDiagnostics{}, false
122+ }
123+ if diag.lastPaintAge < renderStallWarnAfter {
124+ return renderStallDiagnostics{}, false
125+ }
126+ if !lastWarn.IsZero() && now.Sub(lastWarn) < renderStallWarnRepeat {
127+ return renderStallDiagnostics{}, false
128+ }
129+ return diag, true
130+}
131+
132+func (wv *WebView) collectStallDiagnostics(bridge *Cef2gtkAdapter, now time.Time) (renderStallDiagnostics, bool) {
133+ wv.mu.RLock()
134+ browser := wv.browser
135+ uri := wv.uri
136+ title := wv.title
137+ isLoading := wv.isLoading
138+ pendingURI := wv.pendingURI
139+ wv.mu.RUnlock()
140+ if browser == nil {
141+ return renderStallDiagnostics{}, false
142+ }
143+
144+ audioPlaying := wv.audioPlaying.Load()
145+ if !audioPlaying && !isLoading && pendingURI == "" {
146+ return renderStallDiagnostics{}, false
147+ }
148+
149+ diag := bridge.Diagnostics()
150+ lastPaint := latestDiagnosticEventTime(diag, "accelerated-paint")
151+ if lastPaint.IsZero() {
152+ return renderStallDiagnostics{}, false
153+ }
154+ return renderStallDiagnostics{
155+ browser: browser,
156+ uri: uri,
157+ title: title,
158+ isLoading: isLoading,
159+ pendingURI: pendingURI,
160+ audioPlaying: audioPlaying,
161+ diag: diag,
162+ lastPaintAge: now.Sub(lastPaint),
163+ latestEvent: latestDiagnosticEvent(diag),
164+ }, true
165+}
166+
167+func (wv *WebView) logRenderStall(bridge *Cef2gtkAdapter, now time.Time, diag renderStallDiagnostics) {
168+ uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now)
169+ classification := classifyRenderStall(uiHeartbeat, ioHeartbeat)
170+ wv.logRenderDiagnosticSnapshot(now, "stall", classification, bridge, diag.diag, diag.lastPaintAge, diag.browser, diag.uri, diag.title, diag.isLoading, diag.pendingURI, diag.audioPlaying)
171+ wv.logCEFProcessDiagnostics("stall", classification)
172+ if wv.engine != nil {
173+ wv.engine.logAllWebViewRenderSnapshots(now, wv.id, "stall-global", classification)
174+ }
175+ wv.maybeRunRenderStallRecovery(now, bridge, diag.diag.AcceleratedPaints)
176+ profile, profileAge, hasProfile := wv.latestCEF2GTKProfileSnapshot(now)
177+ logging.FromContext(wv.ctx).Warn().
178+ Str("classification", classification.Category).
179+ Bool("cef_ui_blocked", classification.CEFUIBlocked).
180+ Bool("cef_io_alive", classification.CEFIOAlive).
181+ Uint64("webview_id", uint64(wv.id)).
182+ Int32("browser_id", diag.browser.GetIdentifier()).
183+ Str("uri", logging.TruncateURL(diag.uri, logging.PermissionLogURLMaxLen)).
184+ Str("title", diag.title).
185+ Bool("is_loading", diag.isLoading).
186+ Str("pending_uri", logging.TruncateURL(diag.pendingURI, logging.PermissionLogURLMaxLen)).
187+ Bool("audio_playing", diag.audioPlaying).
188+ Int64("cef_ui_heartbeat_ack_age_ms", durationMillis(uiHeartbeat.LastAckAge)).
189+ Int64("cef_ui_heartbeat_latency_ms", durationMillis(uiHeartbeat.Latency)).
190+ Int32("cef_ui_heartbeat_post_result", uiHeartbeat.PostResult).
191+ Bool("cef_ui_heartbeat_in_flight", uiHeartbeat.InFlight).
192+ Int64("cef_io_heartbeat_ack_age_ms", durationMillis(ioHeartbeat.LastAckAge)).
193+ Int64("cef_io_heartbeat_latency_ms", durationMillis(ioHeartbeat.Latency)).
194+ Int32("cef_io_heartbeat_post_result", ioHeartbeat.PostResult).
195+ Bool("cef_io_heartbeat_in_flight", ioHeartbeat.InFlight).
196+ Uint64("audio_packets", wv.audioPacketCount.Load()).
197+ Uint64("audio_writes", wv.audioWriteCount.Load()).
198+ Int64("last_accelerated_paint_age_ms", diag.lastPaintAge.Milliseconds()).
199+ Bool("profile_snapshot_available", hasProfile).
200+ Int64("profile_snapshot_age_ms", durationMillis(profileAge)).
201+ Uint64("profile_frames_received", profile.FramesReceived).
202+ Uint64("profile_frames_queued", profile.FramesQueued).
203+ Uint64("profile_frames_rendered", profile.FramesRendered).
204+ Uint32("profile_gc_delta", profile.GC.NumGCDelta).
205+ Int("accelerated_paints", diag.diag.AcceleratedPaints).
206+ Int("unsupported_paints", diag.diag.UnsupportedPaints).
207+ Int("accelerated_paint_errors", diag.diag.AcceleratedPaintErrors).
208+ Int("import_failures", diag.diag.ImportFailures).
209+ Int("render_failures", diag.diag.RenderFailures).
210+ Str("latest_render_event_kind", diag.latestEvent.Kind).
211+ Str("latest_render_event_message", diag.latestEvent.Message).
212+ Msg("cef: accelerated rendering appears stalled")
213+}
214+
215+func latestDiagnosticEventTime(diag cef2gtk.Diagnostics, kind string) time.Time {
216+ return latestDiagnosticEventOfKind(diag, kind).Time
217+}
218+
219+func latestDiagnosticEventOfKind(diag cef2gtk.Diagnostics, kind string) cef2gtk.DiagnosticEvent {
220+ var latest cef2gtk.DiagnosticEvent
221+ for _, event := range diag.Events {
222+ if event.Kind != kind {
223+ continue
224+ }
225+ if latest.Time.IsZero() || event.Time.After(latest.Time) {
226+ latest = event
227+ }
228+ }
229+ return latest
230+}
231+
232+func latestDiagnosticEvent(diag cef2gtk.Diagnostics) cef2gtk.DiagnosticEvent {
233+ var latest cef2gtk.DiagnosticEvent
234+ for _, event := range diag.Events {
235+ if latest.Time.IsZero() || event.Time.After(latest.Time) {
236+ latest = event
237+ }
238+ }
239+ return latest
240+}
A internal/infrastructure/cef/render_stall_watchdog_test.go +81 -0
@@ -0,0 +1,81 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ cef2gtk "github.com/bnema/purego-cef2gtk"
9+)
10+
11+func TestRenderStallWatchdogStartStopIdempotent(t *testing.T) {
12+ wv := &WebView{ctx: context.Background(), viewBridge: &Cef2gtkAdapter{}}
13+
14+ wv.startRenderStallWatchdog()
15+ firstStop := wv.renderStallStop
16+ firstDone := wv.renderStallDone
17+ if firstStop == nil || firstDone == nil {
18+ t.Fatal("startRenderStallWatchdog did not initialize stop/done channels")
19+ }
20+
21+ wv.startRenderStallWatchdog()
22+ if wv.renderStallStop != firstStop || wv.renderStallDone != firstDone {
23+ t.Fatal("second start should not replace active watchdog channels")
24+ }
25+
26+ wv.stopRenderStallWatchdog()
27+ if wv.renderStallStop != nil || wv.renderStallDone != nil {
28+ t.Fatal("stopRenderStallWatchdog should clear watchdog channels")
29+ }
30+
31+ // A second stop should be a no-op, not a double close panic.
32+ wv.stopRenderStallWatchdog()
33+}
34+
35+func TestClassifyRenderStallDetectsCEFUIBlockedWithIOAlive(t *testing.T) {
36+ classification := classifyRenderStall(
37+ cefHeartbeatSnapshot{PostResult: 1, InFlight: true, LastAckAge: 5 * time.Second},
38+ cefHeartbeatSnapshot{PostResult: 1, InFlight: false, LastAckAge: 100 * time.Millisecond},
39+ )
40+ if classification.Category != "cef-ui-task-runner-blocked-io-alive" {
41+ t.Fatalf("category = %q", classification.Category)
42+ }
43+ if !classification.CEFUIBlocked {
44+ t.Fatal("CEF UI should be classified as blocked")
45+ }
46+ if !classification.CEFIOAlive {
47+ t.Fatal("CEF IO should be classified as alive")
48+ }
49+}
50+
51+func TestClassifyRenderStallKeepsOSRCategoryWhenUIHeartbeatHealthy(t *testing.T) {
52+ classification := classifyRenderStall(
53+ cefHeartbeatSnapshot{PostResult: 1, InFlight: false, LastAckAge: 100 * time.Millisecond},
54+ cefHeartbeatSnapshot{PostResult: 1, InFlight: false, LastAckAge: 100 * time.Millisecond},
55+ )
56+ if classification.Category != "osr-paint-stalled" {
57+ t.Fatalf("category = %q", classification.Category)
58+ }
59+ if classification.CEFUIBlocked {
60+ t.Fatal("CEF UI should not be classified as blocked")
61+ }
62+}
63+
64+func TestLatestDiagnosticEventHelpers(t *testing.T) {
65+ now := time.Now()
66+ diag := cef2gtk.Diagnostics{Events: []cef2gtk.DiagnosticEvent{
67+ {Time: now.Add(-3 * time.Second), Kind: "accelerated-paint"},
68+ {Time: now.Add(-2 * time.Second), Kind: "render-failure", Message: "old"},
69+ {Time: now.Add(-1 * time.Second), Kind: "render-failure", Message: "new"},
70+ }}
71+
72+ if got := latestDiagnosticEventOfKind(diag, "render-failure"); got.Message != "new" {
73+ t.Fatalf("latest render-failure message = %q, want new", got.Message)
74+ }
75+ if got := latestDiagnosticEvent(diag); got.Kind != "render-failure" || got.Message != "new" {
76+ t.Fatalf("latest event = (%q, %q), want (render-failure, new)", got.Kind, got.Message)
77+ }
78+ if got := latestDiagnosticEventTime(diag, "accelerated-paint"); !got.Equal(now.Add(-3 * time.Second)) {
79+ t.Fatalf("latest accelerated paint time = %v, want %v", got, now.Add(-3*time.Second))
80+ }
81+}
@@ -0,0 +1,81 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ cef2gtk "github.com/bnema/purego-cef2gtk"
9+)
10+
11+func TestRenderStallWatchdogStartStopIdempotent(t *testing.T) {
12+ wv := &WebView{ctx: context.Background(), viewBridge: &Cef2gtkAdapter{}}
13+
14+ wv.startRenderStallWatchdog()
15+ firstStop := wv.renderStallStop
16+ firstDone := wv.renderStallDone
17+ if firstStop == nil || firstDone == nil {
18+ t.Fatal("startRenderStallWatchdog did not initialize stop/done channels")
19+ }
20+
21+ wv.startRenderStallWatchdog()
22+ if wv.renderStallStop != firstStop || wv.renderStallDone != firstDone {
23+ t.Fatal("second start should not replace active watchdog channels")
24+ }
25+
26+ wv.stopRenderStallWatchdog()
27+ if wv.renderStallStop != nil || wv.renderStallDone != nil {
28+ t.Fatal("stopRenderStallWatchdog should clear watchdog channels")
29+ }
30+
31+ // A second stop should be a no-op, not a double close panic.
32+ wv.stopRenderStallWatchdog()
33+}
34+
35+func TestClassifyRenderStallDetectsCEFUIBlockedWithIOAlive(t *testing.T) {
36+ classification := classifyRenderStall(
37+ cefHeartbeatSnapshot{PostResult: 1, InFlight: true, LastAckAge: 5 * time.Second},
38+ cefHeartbeatSnapshot{PostResult: 1, InFlight: false, LastAckAge: 100 * time.Millisecond},
39+ )
40+ if classification.Category != "cef-ui-task-runner-blocked-io-alive" {
41+ t.Fatalf("category = %q", classification.Category)
42+ }
43+ if !classification.CEFUIBlocked {
44+ t.Fatal("CEF UI should be classified as blocked")
45+ }
46+ if !classification.CEFIOAlive {
47+ t.Fatal("CEF IO should be classified as alive")
48+ }
49+}
50+
51+func TestClassifyRenderStallKeepsOSRCategoryWhenUIHeartbeatHealthy(t *testing.T) {
52+ classification := classifyRenderStall(
53+ cefHeartbeatSnapshot{PostResult: 1, InFlight: false, LastAckAge: 100 * time.Millisecond},
54+ cefHeartbeatSnapshot{PostResult: 1, InFlight: false, LastAckAge: 100 * time.Millisecond},
55+ )
56+ if classification.Category != "osr-paint-stalled" {
57+ t.Fatalf("category = %q", classification.Category)
58+ }
59+ if classification.CEFUIBlocked {
60+ t.Fatal("CEF UI should not be classified as blocked")
61+ }
62+}
63+
64+func TestLatestDiagnosticEventHelpers(t *testing.T) {
65+ now := time.Now()
66+ diag := cef2gtk.Diagnostics{Events: []cef2gtk.DiagnosticEvent{
67+ {Time: now.Add(-3 * time.Second), Kind: "accelerated-paint"},
68+ {Time: now.Add(-2 * time.Second), Kind: "render-failure", Message: "old"},
69+ {Time: now.Add(-1 * time.Second), Kind: "render-failure", Message: "new"},
70+ }}
71+
72+ if got := latestDiagnosticEventOfKind(diag, "render-failure"); got.Message != "new" {
73+ t.Fatalf("latest render-failure message = %q, want new", got.Message)
74+ }
75+ if got := latestDiagnosticEvent(diag); got.Kind != "render-failure" || got.Message != "new" {
76+ t.Fatalf("latest event = (%q, %q), want (render-failure, new)", got.Kind, got.Message)
77+ }
78+ if got := latestDiagnosticEventTime(diag, "accelerated-paint"); !got.Equal(now.Add(-3 * time.Second)) {
79+ t.Fatalf("latest accelerated paint time = %v, want %v", got, now.Add(-3*time.Second))
80+ }
81+}
M internal/infrastructure/cef/webview.go +46 -19
@@ -14,6 +14,7 @@ import (
1414 "time"
1515
1616 purecef "github.com/bnema/purego-cef/cef"
17+ cef2gtk "github.com/bnema/purego-cef2gtk"
1718 "github.com/bnema/puregotk/v4/glib"
1819 "github.com/bnema/puregotk/v4/gtk"
1920
@@ -68,15 +69,24 @@ var (
6869 // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the
6970 // bridge owns GTK rendering and input forwarding.
7071 type WebView struct {
71- id port.WebViewID
72- ctx context.Context
73- engine *Engine
74- browser purecef.Browser
75- host purecef.BrowserHost
76- client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
77- viewBridge *Cef2gtkAdapter
78- handlers *handlerSet
79- findCtrl *cefFindController
72+ id port.WebViewID
73+ ctx context.Context
74+ engine *Engine
75+ browser purecef.Browser
76+ host purecef.BrowserHost
77+ client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
78+ viewBridge *Cef2gtkAdapter
79+ handlers *handlerSet
80+ findCtrl *cefFindController
81+ profileCleanup func()
82+ profileMu sync.Mutex
83+ renderStallMu sync.Mutex
84+ renderStallStop chan struct{}
85+ renderStallDone chan struct{}
86+ renderStallRecoveryLastAt time.Time
87+ latestProfileSnapshot cef2gtk.ProfileSnapshot
88+ previousProfileSnapshot cef2gtk.ProfileSnapshot
89+ latestProfileSnapshotAt time.Time
8090
8191 removeSizeObserver func()
8292
@@ -137,6 +147,7 @@ type WebView struct {
137147 isLoading bool
138148 selectedText string
139149 focusedEditable bool
150+ inputAttached bool
140151 bridgeNonce string
141152 selectionDebounceTimer stoppableTimer
142153 selectionDebounceSeq uint64
@@ -513,9 +524,9 @@ func (wv *WebView) SetOnReadyToShow(fn func()) {
513524 }
514525 wv.mu.Lock()
515526 wv.popupReadyToShow = fn
516- alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil
527+ shouldTryReady := wv.browser != nil && fn != nil
517528 wv.mu.Unlock()
518- if alreadyReady {
529+ if shouldTryReady {
519530 wv.fireReadyToShow()
520531 }
521532 }
@@ -596,7 +607,7 @@ func (wv *WebView) fireReadyToShow() {
596607 return
597608 }
598609 wv.mu.Lock()
599- if wv.popupReadyShown || wv.popupReadyToShow == nil {
610+ if wv.popupReadyShown || wv.popupReadyToShow == nil || !wv.inputAttached {
600611 wv.mu.Unlock()
601612 return
602613 }
@@ -606,6 +617,16 @@ func (wv *WebView) fireReadyToShow() {
606617 wv.runOnGTK(fn)
607618 }
608619
620+func (wv *WebView) markInputAttached() {
621+ if wv == nil || wv.destroyed.Load() {
622+ return
623+ }
624+ wv.mu.Lock()
625+ wv.inputAttached = true
626+ wv.mu.Unlock()
627+ wv.fireReadyToShow()
628+}
629+
609630 func (wv *WebView) markNativePopupCandidate(parent *WebView) {
610631 if wv == nil {
611632 return
@@ -1000,6 +1021,7 @@ func (wv *WebView) Destroy() {
10001021 wv.openerMessageCallbacks = nil
10011022 wv.openerNavigationCallbacks = nil
10021023 wv.popupReadyToShow = nil
1024+ wv.inputAttached = false
10031025 wv.pendingNativePopups = nil
10041026 wv.nativePopupParent = nil
10051027 wv.nativePopupCandidate = false
@@ -1009,6 +1031,7 @@ func (wv *WebView) Destroy() {
10091031 wv.popupOpenerBridgeParentURI = ""
10101032 wv.mu.Unlock()
10111033 wv.stopNativePopupFallbackTimer()
1034+ wv.stopRenderStallWatchdog()
10121035 wv.cancelSelectionDebounce()
10131036 wv.closeAudioStream()
10141037 wv.scheduleStopBeginFrameLoop()
@@ -1030,13 +1053,17 @@ func (wv *WebView) Destroy() {
10301053 _ = bridge.Destroy()
10311054 })
10321055 }
1056+ if wv.profileCleanup != nil {
1057+ wv.profileCleanup()
1058+ wv.profileCleanup = nil
1059+ }
10331060 }
10341061
10351062 // ---------------------------------------------------------------------------
10361063 // NativeWidgetProvider
10371064 // ---------------------------------------------------------------------------
10381065
1039-// NativeWidget returns the uintptr for embedding the bridge GtkGLArea into GTK.
1066+// NativeWidget returns the uintptr for embedding the bridge GTK widget.
10401067 func (wv *WebView) NativeWidget() uintptr {
10411068 if wv.viewBridge == nil {
10421069 return 0
@@ -1563,7 +1590,7 @@ func (wv *WebView) scheduleStopBeginFrameLoop() {
15631590 }
15641591
15651592 func (wv *WebView) startBeginFrameLoop() {
1566- if wv.destroyed.Load() || wv.viewBridge == nil || wv.viewBridge.GLArea() == nil {
1593+ if wv.destroyed.Load() || wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
15671594 return
15681595 }
15691596
@@ -1573,7 +1600,7 @@ func (wv *WebView) startBeginFrameLoop() {
15731600 return
15741601 }
15751602
1576- glArea := wv.viewBridge.GLArea()
1603+ widget := wv.viewBridge.Widget()
15771604 cb := new(gtk.TickCallback)
15781605 *cb = func(_, _, _ uintptr) bool {
15791606 if wv.destroyed.Load() {
@@ -1589,7 +1616,7 @@ func (wv *WebView) startBeginFrameLoop() {
15891616 return true
15901617 }
15911618 wv.beginFrameTick = cb
1592- wv.beginFrameTickID = glArea.AddTickCallback(cb, 0, nil)
1619+ wv.beginFrameTickID = widget.AddTickCallback(cb, 0, nil)
15931620 host := wv.host
15941621 wv.mu.Unlock()
15951622
@@ -1599,7 +1626,7 @@ func (wv *WebView) startBeginFrameLoop() {
15991626 }
16001627
16011628 func (wv *WebView) stopBeginFrameLoop() {
1602- if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil {
1629+ if wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
16031630 return
16041631 }
16051632
@@ -1607,11 +1634,11 @@ func (wv *WebView) stopBeginFrameLoop() {
16071634 tickID := wv.beginFrameTickID
16081635 wv.beginFrameTickID = 0
16091636 wv.beginFrameTick = nil
1610- glArea := wv.viewBridge.GLArea()
1637+ widget := wv.viewBridge.Widget()
16111638 wv.mu.Unlock()
16121639
16131640 if tickID != 0 {
1614- glArea.RemoveTickCallback(tickID)
1641+ widget.RemoveTickCallback(tickID)
16151642 }
16161643 }
16171644
@@ -14,6 +14,7 @@ import (
14 "time"14 "time"
15 15
16 purecef "github.com/bnema/purego-cef/cef"16 purecef "github.com/bnema/purego-cef/cef"
17+ cef2gtk "github.com/bnema/purego-cef2gtk"
17 "github.com/bnema/puregotk/v4/glib"18 "github.com/bnema/puregotk/v4/glib"
18 "github.com/bnema/puregotk/v4/gtk"19 "github.com/bnema/puregotk/v4/gtk"
19 20
@@ -68,15 +69,24 @@ var (
68 // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the69 // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the
69 // bridge owns GTK rendering and input forwarding.70 // bridge owns GTK rendering and input forwarding.
70 type WebView struct {71 type WebView struct {
71- id port.WebViewID72+ id port.WebViewID
72- ctx context.Context73+ ctx context.Context
73- engine *Engine74+ engine *Engine
74- browser purecef.Browser75+ browser purecef.Browser
75- host purecef.BrowserHost76+ host purecef.BrowserHost
76- client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it77+ client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
77- viewBridge *Cef2gtkAdapter78+ viewBridge *Cef2gtkAdapter
78- handlers *handlerSet79+ handlers *handlerSet
79- findCtrl *cefFindController80+ findCtrl *cefFindController
81+ profileCleanup func()
82+ profileMu sync.Mutex
83+ renderStallMu sync.Mutex
84+ renderStallStop chan struct{}
85+ renderStallDone chan struct{}
86+ renderStallRecoveryLastAt time.Time
87+ latestProfileSnapshot cef2gtk.ProfileSnapshot
88+ previousProfileSnapshot cef2gtk.ProfileSnapshot
89+ latestProfileSnapshotAt time.Time
80 90
81 removeSizeObserver func()91 removeSizeObserver func()
82 92
@@ -137,6 +147,7 @@ type WebView struct {
137 isLoading bool147 isLoading bool
138 selectedText string148 selectedText string
139 focusedEditable bool149 focusedEditable bool
150+ inputAttached bool
140 bridgeNonce string151 bridgeNonce string
141 selectionDebounceTimer stoppableTimer152 selectionDebounceTimer stoppableTimer
142 selectionDebounceSeq uint64153 selectionDebounceSeq uint64
@@ -513,9 +524,9 @@ func (wv *WebView) SetOnReadyToShow(fn func()) {
513 }524 }
514 wv.mu.Lock()525 wv.mu.Lock()
515 wv.popupReadyToShow = fn526 wv.popupReadyToShow = fn
516- alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil527+ shouldTryReady := wv.browser != nil && fn != nil
517 wv.mu.Unlock()528 wv.mu.Unlock()
518- if alreadyReady {529+ if shouldTryReady {
519 wv.fireReadyToShow()530 wv.fireReadyToShow()
520 }531 }
521 }532 }
@@ -596,7 +607,7 @@ func (wv *WebView) fireReadyToShow() {
596 return607 return
597 }608 }
598 wv.mu.Lock()609 wv.mu.Lock()
599- if wv.popupReadyShown || wv.popupReadyToShow == nil {610+ if wv.popupReadyShown || wv.popupReadyToShow == nil || !wv.inputAttached {
600 wv.mu.Unlock()611 wv.mu.Unlock()
601 return612 return
602 }613 }
@@ -606,6 +617,16 @@ func (wv *WebView) fireReadyToShow() {
606 wv.runOnGTK(fn)617 wv.runOnGTK(fn)
607 }618 }
608 619
620+func (wv *WebView) markInputAttached() {
621+ if wv == nil || wv.destroyed.Load() {
622+ return
623+ }
624+ wv.mu.Lock()
625+ wv.inputAttached = true
626+ wv.mu.Unlock()
627+ wv.fireReadyToShow()
628+}
629+
609 func (wv *WebView) markNativePopupCandidate(parent *WebView) {630 func (wv *WebView) markNativePopupCandidate(parent *WebView) {
610 if wv == nil {631 if wv == nil {
611 return632 return
@@ -1000,6 +1021,7 @@ func (wv *WebView) Destroy() {
1000 wv.openerMessageCallbacks = nil1021 wv.openerMessageCallbacks = nil
1001 wv.openerNavigationCallbacks = nil1022 wv.openerNavigationCallbacks = nil
1002 wv.popupReadyToShow = nil1023 wv.popupReadyToShow = nil
1024+ wv.inputAttached = false
1003 wv.pendingNativePopups = nil1025 wv.pendingNativePopups = nil
1004 wv.nativePopupParent = nil1026 wv.nativePopupParent = nil
1005 wv.nativePopupCandidate = false1027 wv.nativePopupCandidate = false
@@ -1009,6 +1031,7 @@ func (wv *WebView) Destroy() {
1009 wv.popupOpenerBridgeParentURI = ""1031 wv.popupOpenerBridgeParentURI = ""
1010 wv.mu.Unlock()1032 wv.mu.Unlock()
1011 wv.stopNativePopupFallbackTimer()1033 wv.stopNativePopupFallbackTimer()
1034+ wv.stopRenderStallWatchdog()
1012 wv.cancelSelectionDebounce()1035 wv.cancelSelectionDebounce()
1013 wv.closeAudioStream()1036 wv.closeAudioStream()
1014 wv.scheduleStopBeginFrameLoop()1037 wv.scheduleStopBeginFrameLoop()
@@ -1030,13 +1053,17 @@ func (wv *WebView) Destroy() {
1030 _ = bridge.Destroy()1053 _ = bridge.Destroy()
1031 })1054 })
1032 }1055 }
1056+ if wv.profileCleanup != nil {
1057+ wv.profileCleanup()
1058+ wv.profileCleanup = nil
1059+ }
1033 }1060 }
1034 1061
1035 // ---------------------------------------------------------------------------1062 // ---------------------------------------------------------------------------
1036 // NativeWidgetProvider1063 // NativeWidgetProvider
1037 // ---------------------------------------------------------------------------1064 // ---------------------------------------------------------------------------
1038 1065
1039-// NativeWidget returns the uintptr for embedding the bridge GtkGLArea into GTK.1066+// NativeWidget returns the uintptr for embedding the bridge GTK widget.
1040 func (wv *WebView) NativeWidget() uintptr {1067 func (wv *WebView) NativeWidget() uintptr {
1041 if wv.viewBridge == nil {1068 if wv.viewBridge == nil {
1042 return 01069 return 0
@@ -1563,7 +1590,7 @@ func (wv *WebView) scheduleStopBeginFrameLoop() {
1563 }1590 }
1564 1591
1565 func (wv *WebView) startBeginFrameLoop() {1592 func (wv *WebView) startBeginFrameLoop() {
1566- if wv.destroyed.Load() || wv.viewBridge == nil || wv.viewBridge.GLArea() == nil {1593+ if wv.destroyed.Load() || wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
1567 return1594 return
1568 }1595 }
1569 1596
@@ -1573,7 +1600,7 @@ func (wv *WebView) startBeginFrameLoop() {
1573 return1600 return
1574 }1601 }
1575 1602
1576- glArea := wv.viewBridge.GLArea()1603+ widget := wv.viewBridge.Widget()
1577 cb := new(gtk.TickCallback)1604 cb := new(gtk.TickCallback)
1578 *cb = func(_, _, _ uintptr) bool {1605 *cb = func(_, _, _ uintptr) bool {
1579 if wv.destroyed.Load() {1606 if wv.destroyed.Load() {
@@ -1589,7 +1616,7 @@ func (wv *WebView) startBeginFrameLoop() {
1589 return true1616 return true
1590 }1617 }
1591 wv.beginFrameTick = cb1618 wv.beginFrameTick = cb
1592- wv.beginFrameTickID = glArea.AddTickCallback(cb, 0, nil)1619+ wv.beginFrameTickID = widget.AddTickCallback(cb, 0, nil)
1593 host := wv.host1620 host := wv.host
1594 wv.mu.Unlock()1621 wv.mu.Unlock()
1595 1622
@@ -1599,7 +1626,7 @@ func (wv *WebView) startBeginFrameLoop() {
1599 }1626 }
1600 1627
1601 func (wv *WebView) stopBeginFrameLoop() {1628 func (wv *WebView) stopBeginFrameLoop() {
1602- if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil {1629+ if wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
1603 return1630 return
1604 }1631 }
1605 1632
@@ -1607,11 +1634,11 @@ func (wv *WebView) stopBeginFrameLoop() {
1607 tickID := wv.beginFrameTickID1634 tickID := wv.beginFrameTickID
1608 wv.beginFrameTickID = 01635 wv.beginFrameTickID = 0
1609 wv.beginFrameTick = nil1636 wv.beginFrameTick = nil
1610- glArea := wv.viewBridge.GLArea()1637+ widget := wv.viewBridge.Widget()
1611 wv.mu.Unlock()1638 wv.mu.Unlock()
1612 1639
1613 if tickID != 0 {1640 if tickID != 0 {
1614- glArea.RemoveTickCallback(tickID)1641+ widget.RemoveTickCallback(tickID)
1615 }1642 }
1616 }1643 }
1617 1644
M internal/infrastructure/cef/webview_handlers.go +32 -17
@@ -2,6 +2,7 @@ package cef
22
33 import (
44 "context"
5+ "errors"
56 "strings"
67
78 purecef "github.com/bnema/purego-cef/cef"
@@ -531,6 +532,9 @@ func (h *handlerSet) attachAfterCreatedBrowser(
531532 h.wv.browser = browser
532533 h.wv.host = host
533534 h.wv.pendingCreate = nil
535+ bridge := h.wv.viewBridge
536+ h.wv.inputAttached = bridge == nil
537+ state.inputAttached = h.wv.inputAttached
534538 if h.wv.findCtrl != nil {
535539 h.wv.findCtrl.setHost(host)
536540 }
@@ -542,10 +546,11 @@ func (h *handlerSet) attachAfterCreatedBrowser(
542546 state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
543547 h.wv.mu.Unlock()
544548
545- if h.wv.viewBridge != nil {
549+ if bridge != nil {
546550 wv := h.wv
547551 wv.runOnGTK(func() {
548- if wv.viewBridge == nil {
552+ if wv.destroyed.Load() || wv.viewBridge == nil {
553+ wv.handleInputAttachFailure(ErrAdapterDestroyed, host)
549554 return
550555 }
551556 if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{
@@ -559,28 +564,39 @@ func (h *handlerSet) attachAfterCreatedBrowser(
559564 wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
560565 }
561566 },
562- }); err != nil && wv.ctx != nil {
563- logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge")
567+ }); err != nil {
568+ wv.handleInputAttachFailure(err, host)
569+ return
564570 }
571+ if wv.viewBridge != nil && wv.viewBridge.HasFocus() {
572+ syncWindowlessBrowserFocus(host)
573+ } else {
574+ host.Invalidate(purecef.PaintElementTypePetView)
575+ }
576+ wv.markInputAttached()
565577 })
566578 }
567579
568580 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
569581 // painting/caret updates until explicitly told the browser is shown.
570582 host.WasHidden(0)
571- state.shouldSyncFocus = h.afterCreatedShouldSyncFocus()
572583 return state
573584 }
574585
575-func (h *handlerSet) afterCreatedShouldSyncFocus() bool {
576- if h == nil || h.wv == nil || h.wv.viewBridge == nil {
577- return false
586+func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost) {
587+ if wv == nil || err == nil {
588+ return
589+ }
590+ if wv.ctx != nil {
591+ logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge")
592+ }
593+ if errors.Is(err, ErrAdapterDestroyed) {
594+ if host != nil && !wv.destroyed.Load() {
595+ host.CloseBrowser(1)
596+ return
597+ }
598+ wv.runCloseCallbacks()
578599 }
579- focused := false
580- h.wv.runOnGTKSync(func() {
581- focused = h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus()
582- })
583- return focused
584600 }
585601
586602 func (h *handlerSet) finishAfterCreated(
@@ -594,9 +610,7 @@ func (h *handlerSet) finishAfterCreated(
594610 h.wv.engine.bindBrowserWebView(browser, h.wv)
595611 }
596612 h.wv.stopNativePopupFallbackTimer()
597- if state.shouldSyncFocus {
598- syncWindowlessBrowserFocus(host)
599- } else {
613+ if state.inputAttached {
600614 // Request an initial OSR frame even before the first real navigation
601615 // commits. This restores the about:blank warm-up paint that the stable
602616 // startup path relied on and prevents the rendering bridge from staying idle.
@@ -613,7 +627,7 @@ func (h *handlerSet) finishAfterCreated(
613627 }
614628
615629 type afterCreatedState struct {
616- shouldSyncFocus bool
630+ inputAttached bool
617631 hasPendingNavigation bool
618632 nativePopupParent *WebView
619633 nativePopupID int32
@@ -638,6 +652,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
638652 h.wv.mu.Lock()
639653 h.wv.browser = nil
640654 h.wv.host = nil
655+ h.wv.inputAttached = false
641656 if h.wv.findCtrl != nil {
642657 h.wv.findCtrl.setHost(nil)
643658 }
@@ -2,6 +2,7 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "strings"6 "strings"
6 7
7 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
@@ -531,6 +532,9 @@ func (h *handlerSet) attachAfterCreatedBrowser(
531 h.wv.browser = browser532 h.wv.browser = browser
532 h.wv.host = host533 h.wv.host = host
533 h.wv.pendingCreate = nil534 h.wv.pendingCreate = nil
535+ bridge := h.wv.viewBridge
536+ h.wv.inputAttached = bridge == nil
537+ state.inputAttached = h.wv.inputAttached
534 if h.wv.findCtrl != nil {538 if h.wv.findCtrl != nil {
535 h.wv.findCtrl.setHost(host)539 h.wv.findCtrl.setHost(host)
536 }540 }
@@ -542,10 +546,11 @@ func (h *handlerSet) attachAfterCreatedBrowser(
542 state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""546 state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
543 h.wv.mu.Unlock()547 h.wv.mu.Unlock()
544 548
545- if h.wv.viewBridge != nil {549+ if bridge != nil {
546 wv := h.wv550 wv := h.wv
547 wv.runOnGTK(func() {551 wv.runOnGTK(func() {
548- if wv.viewBridge == nil {552+ if wv.destroyed.Load() || wv.viewBridge == nil {
553+ wv.handleInputAttachFailure(ErrAdapterDestroyed, host)
549 return554 return
550 }555 }
551 if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{556 if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{
@@ -559,28 +564,39 @@ func (h *handlerSet) attachAfterCreatedBrowser(
559 wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)564 wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
560 }565 }
561 },566 },
562- }); err != nil && wv.ctx != nil {567+ }); err != nil {
563- logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge")568+ wv.handleInputAttachFailure(err, host)
569+ return
564 }570 }
571+ if wv.viewBridge != nil && wv.viewBridge.HasFocus() {
572+ syncWindowlessBrowserFocus(host)
573+ } else {
574+ host.Invalidate(purecef.PaintElementTypePetView)
575+ }
576+ wv.markInputAttached()
565 })577 })
566 }578 }
567 579
568 // Mark browser as visible — CEF OSR starts in hidden state and suppresses580 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
569 // painting/caret updates until explicitly told the browser is shown.581 // painting/caret updates until explicitly told the browser is shown.
570 host.WasHidden(0)582 host.WasHidden(0)
571- state.shouldSyncFocus = h.afterCreatedShouldSyncFocus()
572 return state583 return state
573 }584 }
574 585
575-func (h *handlerSet) afterCreatedShouldSyncFocus() bool {586+func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost) {
576- if h == nil || h.wv == nil || h.wv.viewBridge == nil {587+ if wv == nil || err == nil {
577- return false588+ return
589+ }
590+ if wv.ctx != nil {
591+ logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge")
592+ }
593+ if errors.Is(err, ErrAdapterDestroyed) {
594+ if host != nil && !wv.destroyed.Load() {
595+ host.CloseBrowser(1)
596+ return
597+ }
598+ wv.runCloseCallbacks()
578 }599 }
579- focused := false
580- h.wv.runOnGTKSync(func() {
581- focused = h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus()
582- })
583- return focused
584 }600 }
585 601
586 func (h *handlerSet) finishAfterCreated(602 func (h *handlerSet) finishAfterCreated(
@@ -594,9 +610,7 @@ func (h *handlerSet) finishAfterCreated(
594 h.wv.engine.bindBrowserWebView(browser, h.wv)610 h.wv.engine.bindBrowserWebView(browser, h.wv)
595 }611 }
596 h.wv.stopNativePopupFallbackTimer()612 h.wv.stopNativePopupFallbackTimer()
597- if state.shouldSyncFocus {613+ if state.inputAttached {
598- syncWindowlessBrowserFocus(host)
599- } else {
600 // Request an initial OSR frame even before the first real navigation614 // Request an initial OSR frame even before the first real navigation
601 // commits. This restores the about:blank warm-up paint that the stable615 // commits. This restores the about:blank warm-up paint that the stable
602 // startup path relied on and prevents the rendering bridge from staying idle.616 // startup path relied on and prevents the rendering bridge from staying idle.
@@ -613,7 +627,7 @@ func (h *handlerSet) finishAfterCreated(
613 }627 }
614 628
615 type afterCreatedState struct {629 type afterCreatedState struct {
616- shouldSyncFocus bool630+ inputAttached bool
617 hasPendingNavigation bool631 hasPendingNavigation bool
618 nativePopupParent *WebView632 nativePopupParent *WebView
619 nativePopupID int32633 nativePopupID int32
@@ -638,6 +652,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
638 h.wv.mu.Lock()652 h.wv.mu.Lock()
639 h.wv.browser = nil653 h.wv.browser = nil
640 h.wv.host = nil654 h.wv.host = nil
655+ h.wv.inputAttached = false
641 if h.wv.findCtrl != nil {656 if h.wv.findCtrl != nil {
642 h.wv.findCtrl.setHost(nil)657 h.wv.findCtrl.setHost(nil)
643 }658 }
M internal/logging/session.go +26 -0
@@ -1,6 +1,7 @@
11 package logging
22
33 import (
4+ "context"
45 "crypto/rand"
56 "encoding/hex"
67 "errors"
@@ -55,6 +56,31 @@ func SessionFilename(sessionID string) string {
5556 return "session_" + sessionID + ".log"
5657 }
5758
59+type sessionMetadataKey struct{}
60+
61+// SessionMetadata describes the active browser session log identity carried by context.
62+type SessionMetadata struct {
63+ ID string
64+ LogPath string
65+}
66+
67+// WithSessionMetadata attaches active session metadata to ctx.
68+func WithSessionMetadata(ctx context.Context, id, logPath string) context.Context {
69+ if ctx == nil {
70+ ctx = context.Background()
71+ }
72+ return context.WithValue(ctx, sessionMetadataKey{}, SessionMetadata{ID: id, LogPath: logPath})
73+}
74+
75+// SessionMetadataFromContext returns active session metadata from ctx, if present.
76+func SessionMetadataFromContext(ctx context.Context) (SessionMetadata, bool) {
77+ if ctx == nil {
78+ return SessionMetadata{}, false
79+ }
80+ metadata, ok := ctx.Value(sessionMetadataKey{}).(SessionMetadata)
81+ return metadata, ok
82+}
83+
5884 type sessionLogFile struct {
5985 path string
6086 filename string
@@ -1,6 +1,7 @@
1 package logging1 package logging
2 2
3 import (3 import (
4+ "context"
4 "crypto/rand"5 "crypto/rand"
5 "encoding/hex"6 "encoding/hex"
6 "errors"7 "errors"
@@ -55,6 +56,31 @@ func SessionFilename(sessionID string) string {
55 return "session_" + sessionID + ".log"56 return "session_" + sessionID + ".log"
56 }57 }
57 58
59+type sessionMetadataKey struct{}
60+
61+// SessionMetadata describes the active browser session log identity carried by context.
62+type SessionMetadata struct {
63+ ID string
64+ LogPath string
65+}
66+
67+// WithSessionMetadata attaches active session metadata to ctx.
68+func WithSessionMetadata(ctx context.Context, id, logPath string) context.Context {
69+ if ctx == nil {
70+ ctx = context.Background()
71+ }
72+ return context.WithValue(ctx, sessionMetadataKey{}, SessionMetadata{ID: id, LogPath: logPath})
73+}
74+
75+// SessionMetadataFromContext returns active session metadata from ctx, if present.
76+func SessionMetadataFromContext(ctx context.Context) (SessionMetadata, bool) {
77+ if ctx == nil {
78+ return SessionMetadata{}, false
79+ }
80+ metadata, ok := ctx.Value(sessionMetadataKey{}).(SessionMetadata)
81+ return metadata, ok
82+}
83+
58 type sessionLogFile struct {84 type sessionLogFile struct {
59 path string85 path string
60 filename string86 filename string
M internal/ui/input/global_shortcuts.go +2 -0
@@ -371,6 +371,8 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time
371371 return false
372372 }
373373 if h.lastDispatchAt == nil {
374+ // Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
375+ // rather than letting a stale GTK callback repeat destructive actions.
374376 return true
375377 }
376378 if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow {
@@ -371,6 +371,8 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time
371 return false371 return false
372 }372 }
373 if h.lastDispatchAt == nil {373 if h.lastDispatchAt == nil {
374+ // Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
375+ // rather than letting a stale GTK callback repeat destructive actions.
374 return true376 return true
375 }377 }
376 if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow {378 if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow {
M internal/ui/input/global_shortcuts_test.go +9 -1
@@ -33,7 +33,15 @@ func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) {
3333 }
3434
3535 func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {
36- h := &GlobalShortcutHandler{}
36+ h := &GlobalShortcutHandler{
37+ registered: make(map[KeyBinding]Action),
38+ lastDispatchAt: make(map[Action]time.Time),
39+ }
40+
41+ if h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) {
42+ t.Fatal("one-shot shortcut was suppressed before detach")
43+ }
44+ h.Detach()
3745 if !h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) {
3846 t.Fatal("one-shot shortcut was not suppressed with detached handler state")
3947 }
@@ -33,7 +33,15 @@ func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) {
33 }33 }
34 34
35 func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {35 func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {
36- h := &GlobalShortcutHandler{}36+ h := &GlobalShortcutHandler{
37+ registered: make(map[KeyBinding]Action),
38+ lastDispatchAt: make(map[Action]time.Time),
39+ }
40+
41+ if h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) {
42+ t.Fatal("one-shot shortcut was suppressed before detach")
43+ }
44+ h.Detach()
37 if !h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) {45 if !h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) {
38 t.Fatal("one-shot shortcut was not suppressed with detached handler state")46 t.Fatal("one-shot shortcut was not suppressed with detached handler state")
39 }47 }
A package-lock.json +6 -0 generated file: collapsed
A scripts/cef-gpu-diagnostics.sh +185 -0
@@ -0,0 +1,185 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+usage() {
5+ cat <<'EOF'
6+Usage: scripts/cef-gpu-diagnostics.sh [options]
7+
8+Capture a short, repeatable Dumber/CEF GPU-video diagnostic bundle while a
9+YouTube video is playing.
10+
11+Options:
12+ -p, --pid PID Dumber browser/root PID. Defaults to newest dumber process.
13+ -o, --out DIR Output directory. Default: .dev/dumber/gpu-diagnostics/<timestamp>
14+ -n, --samples N amdgpu_top JSON samples. Default: 10
15+ -i, --interval-ms MS amdgpu_top sample interval. Default: 1000
16+ --session-log PATH Copy a Dumber session log into the bundle.
17+ --codec-stats TEXT Record manually copied YouTube "Stats for nerds" text.
18+ --codec-stats-file PATH Record YouTube "Stats for nerds" text from a file.
19+ --devtools-port PORT Save http://127.0.0.1:PORT/json/version and /json tabs if enabled.
20+ -h, --help Show this help.
21+
22+Recommended run shape:
23+ 1. Start Dumber with CEF/VAAPI/profiling enabled.
24+ 2. Open YouTube Stats for nerds and copy the Codecs line (or full panel text).
25+ 3. Run this script while playback is active.
26+
27+The bundle includes process/thread CPU, process command lines, /proc DRM fdinfo,
28+amdgpu_top JSON capture, kernel DRM node metadata, optional DevTools tab metadata,
29+and optional YouTube codec stats text.
30+EOF
31+}
32+
33+pid=""
34+out_dir=""
35+samples=10
36+interval_ms=1000
37+session_log=""
38+codec_stats=""
39+codec_stats_file=""
40+devtools_port=""
41+
42+require_value() {
43+ local opt="$1"
44+ local value="${2:-}"
45+ if [[ -z "$value" || "$value" == -* ]]; then
46+ echo "$opt requires a value" >&2
47+ usage >&2
48+ exit 2
49+ fi
50+}
51+
52+while [[ $# -gt 0 ]]; do
53+ case "$1" in
54+ -p|--pid) require_value "$1" "${2:-}"; pid="$2"; shift 2 ;;
55+ -o|--out) require_value "$1" "${2:-}"; out_dir="$2"; shift 2 ;;
56+ -n|--samples) require_value "$1" "${2:-}"; samples="$2"; shift 2 ;;
57+ -i|--interval-ms) require_value "$1" "${2:-}"; interval_ms="$2"; shift 2 ;;
58+ --session-log) require_value "$1" "${2:-}"; session_log="$2"; shift 2 ;;
59+ --codec-stats) require_value "$1" "${2:-}"; codec_stats="$2"; shift 2 ;;
60+ --codec-stats-file) require_value "$1" "${2:-}"; codec_stats_file="$2"; shift 2 ;;
61+ --devtools-port) require_value "$1" "${2:-}"; devtools_port="$2"; shift 2 ;;
62+ -h|--help) usage; exit 0 ;;
63+ *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
64+ esac
65+done
66+
67+if [[ -z "$pid" ]]; then
68+ pid="$(pgrep -n -x dumber || true)"
69+fi
70+if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then
71+ echo "could not find a live dumber PID; pass --pid" >&2
72+ exit 1
73+fi
74+if ! [[ "$samples" =~ ^[0-9]+$ && "$interval_ms" =~ ^[0-9]+$ ]]; then
75+ echo "--samples and --interval-ms must be positive integers" >&2
76+ exit 2
77+fi
78+
79+if [[ -z "$out_dir" ]]; then
80+ out_dir=".dev/dumber/gpu-diagnostics/$(date +%Y%m%d_%H%M%S)_pid${pid}"
81+fi
82+mkdir -p "$out_dir"
83+
84+log() { printf '[cef-gpu-diagnostics] %s\n' "$*" >&2; }
85+
86+collect_children() {
87+ local root="$1"
88+ local child
89+ printf '%s\n' "$root"
90+ for child in $(pgrep -P "$root" || true); do
91+ collect_children "$child"
92+ done
93+}
94+
95+mapfile -t pids < <(collect_children "$pid" | awk '!seen[$0]++')
96+printf '%s\n' "${pids[@]}" > "$out_dir/pids.txt"
97+
98+{
99+ echo "timestamp=$(date --iso-8601=seconds)"
100+ echo "root_pid=$pid"
101+ echo "samples=$samples"
102+ echo "interval_ms=$interval_ms"
103+ echo "uname=$(uname -a)"
104+ echo "wayland_display=${WAYLAND_DISPLAY:-}"
105+ echo "display=${DISPLAY:-}"
106+ command -v amdgpu_top >/dev/null && amdgpu_top --version 2>/dev/null || true
107+} > "$out_dir/metadata.txt"
108+
109+log "writing process command lines and thread CPU"
110+: > "$out_dir/cmdlines.txt"
111+: > "$out_dir/thread_cpu.txt"
112+for p in "${pids[@]}"; do
113+ [[ -d "/proc/$p" ]] || continue
114+ {
115+ echo "--- pid=$p comm=$(cat "/proc/$p/comm" 2>/dev/null || true)"
116+ tr '\0' ' ' < "/proc/$p/cmdline" || true
117+ echo
118+ } >> "$out_dir/cmdlines.txt"
119+ ps -L -p "$p" -o pid,tid,ppid,pcpu,pmem,stat,comm,args --sort=-pcpu >> "$out_dir/thread_cpu.txt" 2>/dev/null || true
120+done
121+
122+log "capturing DRM fdinfo"
123+mkdir -p "$out_dir/fdinfo"
124+for p in "${pids[@]}"; do
125+ [[ -d "/proc/$p/fdinfo" ]] || continue
126+ out="$out_dir/fdinfo/pid_${p}.txt"
127+ : > "$out"
128+ for fd in "/proc/${p}/fdinfo"/*; do
129+ [[ -r "$fd" ]] || continue
130+ if grep -Eq 'drm-|amdgpu|engine|vram|gtt|cycles' "$fd" 2>/dev/null; then
131+ echo "--- $fd" >> "$out"
132+ cat "$fd" >> "$out" 2>/dev/null || true
133+ fi
134+ done
135+ [[ -s "$out" ]] || rm -f "$out"
136+done
137+
138+log "capturing DRM device metadata"
139+mkdir -p "$out_dir/dri"
140+for card in /sys/class/drm/card*; do
141+ [[ -e "$card" && ! "$card" =~ - ]] || continue
142+ name="$(basename "$card")"
143+ {
144+ echo "--- $card"
145+ readlink -f "$card/device" || true
146+ for f in vendor device revision subsystem_vendor subsystem_device; do
147+ [[ -r "$card/device/$f" ]] && printf '%s=' "$f" && cat "$card/device/$f"
148+ done
149+ } > "$out_dir/dri/${name}.txt"
150+done
151+
152+if command -v amdgpu_top >/dev/null; then
153+ log "capturing amdgpu_top JSON (${samples} samples @ ${interval_ms}ms)"
154+ amdgpu_top -J -s "$interval_ms" -n "$samples" -p -gm > "$out_dir/amdgpu_top.json" 2> "$out_dir/amdgpu_top.stderr" || true
155+ if command -v jq >/dev/null; then
156+ jq '{period, devices: [.devices[]? | {name: .Info.DeviceName, render: .Info.DevicePath.render, activity: .gpu_activity, total_fdinfo: .["Total fdinfo"], processes: (.fdinfo // {})}]}' \
157+ "$out_dir/amdgpu_top.json" > "$out_dir/amdgpu_top.summary.json" 2>/dev/null || true
158+ fi
159+else
160+ log "amdgpu_top not found; skipping GPU samples"
161+fi
162+
163+if [[ -n "$session_log" && -r "$session_log" ]]; then
164+ cp "$session_log" "$out_dir/$(basename "$session_log")"
165+fi
166+if [[ -n "$codec_stats_file" && -r "$codec_stats_file" ]]; then
167+ cp "$codec_stats_file" "$out_dir/youtube_stats_for_nerds.txt"
168+elif [[ -n "$codec_stats" ]]; then
169+ printf '%s\n' "$codec_stats" > "$out_dir/youtube_stats_for_nerds.txt"
170+else
171+ cat > "$out_dir/youtube_stats_for_nerds.README.txt" <<'EOF'
172+No YouTube codec stats were supplied. Re-run with --codec-stats or
173+--codec-stats-file after copying the YouTube "Stats for nerds" panel,
174+especially the Codecs line (e.g. vp09/av01/h264) and viewport/resolution.
175+EOF
176+fi
177+
178+if [[ -n "$devtools_port" ]]; then
179+ log "capturing DevTools metadata from port $devtools_port"
180+ curl --connect-timeout 1 --max-time 3 -fsS "http://127.0.0.1:${devtools_port}/json/version" > "$out_dir/devtools_version.json" 2> "$out_dir/devtools.stderr" || true
181+ curl --connect-timeout 1 --max-time 3 -fsS "http://127.0.0.1:${devtools_port}/json" > "$out_dir/devtools_tabs.json" 2>> "$out_dir/devtools.stderr" || true
182+fi
183+
184+log "bundle complete: $out_dir"
185+printf '%s\n' "$out_dir"
@@ -0,0 +1,185 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+usage() {
5+ cat <<'EOF'
6+Usage: scripts/cef-gpu-diagnostics.sh [options]
7+
8+Capture a short, repeatable Dumber/CEF GPU-video diagnostic bundle while a
9+YouTube video is playing.
10+
11+Options:
12+ -p, --pid PID Dumber browser/root PID. Defaults to newest dumber process.
13+ -o, --out DIR Output directory. Default: .dev/dumber/gpu-diagnostics/<timestamp>
14+ -n, --samples N amdgpu_top JSON samples. Default: 10
15+ -i, --interval-ms MS amdgpu_top sample interval. Default: 1000
16+ --session-log PATH Copy a Dumber session log into the bundle.
17+ --codec-stats TEXT Record manually copied YouTube "Stats for nerds" text.
18+ --codec-stats-file PATH Record YouTube "Stats for nerds" text from a file.
19+ --devtools-port PORT Save http://127.0.0.1:PORT/json/version and /json tabs if enabled.
20+ -h, --help Show this help.
21+
22+Recommended run shape:
23+ 1. Start Dumber with CEF/VAAPI/profiling enabled.
24+ 2. Open YouTube Stats for nerds and copy the Codecs line (or full panel text).
25+ 3. Run this script while playback is active.
26+
27+The bundle includes process/thread CPU, process command lines, /proc DRM fdinfo,
28+amdgpu_top JSON capture, kernel DRM node metadata, optional DevTools tab metadata,
29+and optional YouTube codec stats text.
30+EOF
31+}
32+
33+pid=""
34+out_dir=""
35+samples=10
36+interval_ms=1000
37+session_log=""
38+codec_stats=""
39+codec_stats_file=""
40+devtools_port=""
41+
42+require_value() {
43+ local opt="$1"
44+ local value="${2:-}"
45+ if [[ -z "$value" || "$value" == -* ]]; then
46+ echo "$opt requires a value" >&2
47+ usage >&2
48+ exit 2
49+ fi
50+}
51+
52+while [[ $# -gt 0 ]]; do
53+ case "$1" in
54+ -p|--pid) require_value "$1" "${2:-}"; pid="$2"; shift 2 ;;
55+ -o|--out) require_value "$1" "${2:-}"; out_dir="$2"; shift 2 ;;
56+ -n|--samples) require_value "$1" "${2:-}"; samples="$2"; shift 2 ;;
57+ -i|--interval-ms) require_value "$1" "${2:-}"; interval_ms="$2"; shift 2 ;;
58+ --session-log) require_value "$1" "${2:-}"; session_log="$2"; shift 2 ;;
59+ --codec-stats) require_value "$1" "${2:-}"; codec_stats="$2"; shift 2 ;;
60+ --codec-stats-file) require_value "$1" "${2:-}"; codec_stats_file="$2"; shift 2 ;;
61+ --devtools-port) require_value "$1" "${2:-}"; devtools_port="$2"; shift 2 ;;
62+ -h|--help) usage; exit 0 ;;
63+ *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
64+ esac
65+done
66+
67+if [[ -z "$pid" ]]; then
68+ pid="$(pgrep -n -x dumber || true)"
69+fi
70+if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then
71+ echo "could not find a live dumber PID; pass --pid" >&2
72+ exit 1
73+fi
74+if ! [[ "$samples" =~ ^[0-9]+$ && "$interval_ms" =~ ^[0-9]+$ ]]; then
75+ echo "--samples and --interval-ms must be positive integers" >&2
76+ exit 2
77+fi
78+
79+if [[ -z "$out_dir" ]]; then
80+ out_dir=".dev/dumber/gpu-diagnostics/$(date +%Y%m%d_%H%M%S)_pid${pid}"
81+fi
82+mkdir -p "$out_dir"
83+
84+log() { printf '[cef-gpu-diagnostics] %s\n' "$*" >&2; }
85+
86+collect_children() {
87+ local root="$1"
88+ local child
89+ printf '%s\n' "$root"
90+ for child in $(pgrep -P "$root" || true); do
91+ collect_children "$child"
92+ done
93+}
94+
95+mapfile -t pids < <(collect_children "$pid" | awk '!seen[$0]++')
96+printf '%s\n' "${pids[@]}" > "$out_dir/pids.txt"
97+
98+{
99+ echo "timestamp=$(date --iso-8601=seconds)"
100+ echo "root_pid=$pid"
101+ echo "samples=$samples"
102+ echo "interval_ms=$interval_ms"
103+ echo "uname=$(uname -a)"
104+ echo "wayland_display=${WAYLAND_DISPLAY:-}"
105+ echo "display=${DISPLAY:-}"
106+ command -v amdgpu_top >/dev/null && amdgpu_top --version 2>/dev/null || true
107+} > "$out_dir/metadata.txt"
108+
109+log "writing process command lines and thread CPU"
110+: > "$out_dir/cmdlines.txt"
111+: > "$out_dir/thread_cpu.txt"
112+for p in "${pids[@]}"; do
113+ [[ -d "/proc/$p" ]] || continue
114+ {
115+ echo "--- pid=$p comm=$(cat "/proc/$p/comm" 2>/dev/null || true)"
116+ tr '\0' ' ' < "/proc/$p/cmdline" || true
117+ echo
118+ } >> "$out_dir/cmdlines.txt"
119+ ps -L -p "$p" -o pid,tid,ppid,pcpu,pmem,stat,comm,args --sort=-pcpu >> "$out_dir/thread_cpu.txt" 2>/dev/null || true
120+done
121+
122+log "capturing DRM fdinfo"
123+mkdir -p "$out_dir/fdinfo"
124+for p in "${pids[@]}"; do
125+ [[ -d "/proc/$p/fdinfo" ]] || continue
126+ out="$out_dir/fdinfo/pid_${p}.txt"
127+ : > "$out"
128+ for fd in "/proc/${p}/fdinfo"/*; do
129+ [[ -r "$fd" ]] || continue
130+ if grep -Eq 'drm-|amdgpu|engine|vram|gtt|cycles' "$fd" 2>/dev/null; then
131+ echo "--- $fd" >> "$out"
132+ cat "$fd" >> "$out" 2>/dev/null || true
133+ fi
134+ done
135+ [[ -s "$out" ]] || rm -f "$out"
136+done
137+
138+log "capturing DRM device metadata"
139+mkdir -p "$out_dir/dri"
140+for card in /sys/class/drm/card*; do
141+ [[ -e "$card" && ! "$card" =~ - ]] || continue
142+ name="$(basename "$card")"
143+ {
144+ echo "--- $card"
145+ readlink -f "$card/device" || true
146+ for f in vendor device revision subsystem_vendor subsystem_device; do
147+ [[ -r "$card/device/$f" ]] && printf '%s=' "$f" && cat "$card/device/$f"
148+ done
149+ } > "$out_dir/dri/${name}.txt"
150+done
151+
152+if command -v amdgpu_top >/dev/null; then
153+ log "capturing amdgpu_top JSON (${samples} samples @ ${interval_ms}ms)"
154+ amdgpu_top -J -s "$interval_ms" -n "$samples" -p -gm > "$out_dir/amdgpu_top.json" 2> "$out_dir/amdgpu_top.stderr" || true
155+ if command -v jq >/dev/null; then
156+ jq '{period, devices: [.devices[]? | {name: .Info.DeviceName, render: .Info.DevicePath.render, activity: .gpu_activity, total_fdinfo: .["Total fdinfo"], processes: (.fdinfo // {})}]}' \
157+ "$out_dir/amdgpu_top.json" > "$out_dir/amdgpu_top.summary.json" 2>/dev/null || true
158+ fi
159+else
160+ log "amdgpu_top not found; skipping GPU samples"
161+fi
162+
163+if [[ -n "$session_log" && -r "$session_log" ]]; then
164+ cp "$session_log" "$out_dir/$(basename "$session_log")"
165+fi
166+if [[ -n "$codec_stats_file" && -r "$codec_stats_file" ]]; then
167+ cp "$codec_stats_file" "$out_dir/youtube_stats_for_nerds.txt"
168+elif [[ -n "$codec_stats" ]]; then
169+ printf '%s\n' "$codec_stats" > "$out_dir/youtube_stats_for_nerds.txt"
170+else
171+ cat > "$out_dir/youtube_stats_for_nerds.README.txt" <<'EOF'
172+No YouTube codec stats were supplied. Re-run with --codec-stats or
173+--codec-stats-file after copying the YouTube "Stats for nerds" panel,
174+especially the Codecs line (e.g. vp09/av01/h264) and viewport/resolution.
175+EOF
176+fi
177+
178+if [[ -n "$devtools_port" ]]; then
179+ log "capturing DevTools metadata from port $devtools_port"
180+ curl --connect-timeout 1 --max-time 3 -fsS "http://127.0.0.1:${devtools_port}/json/version" > "$out_dir/devtools_version.json" 2> "$out_dir/devtools.stderr" || true
181+ curl --connect-timeout 1 --max-time 3 -fsS "http://127.0.0.1:${devtools_port}/json" > "$out_dir/devtools_tabs.json" 2>> "$out_dir/devtools.stderr" || true
182+fi
183+
184+log "bundle complete: $out_dir"
185+printf '%s\n' "$out_dir"
A scripts/generate-types.js +291 -0
@@ -0,0 +1,291 @@
1+#!/usr/bin/env node
2+
3+import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
4+import { join, dirname } from 'path';
5+
6+/**
7+ * Generate TypeScript types from Go DTOs by parsing Go struct definitions
8+ * Watches for changes in Go files and regenerates automatically
9+ */
10+
11+const GO_MODELS_PATH = 'internal/db/';
12+const GO_PARSER_PATH = 'internal/parser/';
13+const TS_OUTPUT_PATH = 'gui/src/types/generated.ts';
14+
15+// Helper function to convert Go types to TypeScript
16+function convertGoTypeToTS(goType) {
17+ const cleanType = goType.trim().replace(/^\*/, ''); // Remove pointer indicator
18+
19+ // Handle Go slices: []Type -> Type[]
20+ if (cleanType.startsWith('[]')) {
21+ const elementType = cleanType.slice(2); // Remove []
22+ return convertGoTypeToTS(elementType) + '[]';
23+ }
24+
25+ // Handle sql.Null types
26+ if (cleanType.includes('sql.NullString')) return 'string | null';
27+ if (cleanType.includes('sql.NullInt64')) return 'number | null';
28+ if (cleanType.includes('sql.NullTime')) return 'string | null';
29+ if (cleanType.includes('sql.NullBool')) return 'boolean | null';
30+ if (cleanType.includes('sql.NullFloat64')) return 'number | null';
31+
32+ // Handle Go package prefixes
33+ if (cleanType.includes('*db.History')) return 'History | null';
34+ if (cleanType.includes('db.History')) return 'History';
35+
36+ if (cleanType.includes('time.Time')) return 'string'; // ISO date strings
37+ if (cleanType.includes('time.Duration')) return 'number'; // Nanoseconds
38+ if (cleanType.includes('map[string]interface{}')) return 'Record<string, unknown>';
39+ if (cleanType.includes('map[string]string')) return 'Record<string, string>';
40+ if (cleanType.includes('map[string]any')) return 'Record<string, unknown>';
41+ if (cleanType.includes('string')) return 'string';
42+ if (cleanType.includes('int') || cleanType.includes('float')) return 'number';
43+ if (cleanType.includes('bool')) return 'boolean';
44+ if (cleanType.match(/^[A-Z][a-zA-Z0-9]*$/)) return cleanType; // Custom struct types
45+
46+ return 'unknown'; // Fallback
47+}
48+
49+// Parse Go struct to extract JSON field names and types
50+function parseGoStruct(goCode, structName) {
51+ // More sophisticated regex to handle nested braces in type definitions
52+ const structStart = new RegExp(`type\\s+${structName}\\s+struct\\s*{`, 's');
53+ const startMatch = goCode.match(structStart);
54+
55+ if (!startMatch) return null;
56+
57+ // Find the matching closing brace by counting braces
58+ const startIndex = startMatch.index + startMatch[0].length;
59+ let braceCount = 1;
60+ let endIndex = startIndex;
61+
62+ for (let i = startIndex; i < goCode.length && braceCount > 0; i++) {
63+ if (goCode[i] === '{') braceCount++;
64+ if (goCode[i] === '}') braceCount--;
65+ endIndex = i;
66+ }
67+
68+ if (braceCount !== 0) return null; // Unmatched braces
69+
70+ const structContent = goCode.substring(startIndex, endIndex);
71+ const match = [null, structContent]; // Simulate regex match format
72+
73+ if (!match) return null;
74+
75+ // First, reconstruct the struct content by merging lines that belong together
76+ const rawContent = match[1];
77+ const lines = rawContent.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('//'));
78+
79+ // Merge multi-line field definitions
80+ const mergedFields = [];
81+ let currentField = '';
82+
83+ for (const line of lines) {
84+ // If line starts with a field name (capital letter), it's a new field
85+ if (/^\w+\s+/.test(line)) {
86+ // Save previous field if exists
87+ if (currentField) {
88+ mergedFields.push(currentField);
89+ }
90+ currentField = line;
91+ } else {
92+ // This line continues the previous field
93+ currentField += ' ' + line;
94+ }
95+ }
96+
97+ // Don't forget the last field
98+ if (currentField) {
99+ mergedFields.push(currentField);
100+ }
101+
102+ const fields = mergedFields.map(fieldLine => {
103+ // Check for embedded struct (no field name, just type)
104+ const embeddedMatch = fieldLine.match(/^([A-Z][a-zA-Z0-9]*)\s*$/);
105+ if (embeddedMatch) {
106+ const embeddedType = embeddedMatch[1];
107+ // Return a special marker for embedded structs
108+ return {
109+ embedded: true,
110+ type: embeddedType
111+ };
112+ }
113+
114+ // Parse: FieldName type `json:"field_name,omitempty"`
115+ const fieldMatch = fieldLine.match(/^(\w+)\s+([^`]+?)\s*`json:"([^"]+)".*?`/);
116+ if (!fieldMatch) return null;
117+
118+ const [, , goType, jsonTag] = fieldMatch;
119+ const jsonField = jsonTag.split(',')[0]; // Remove omitempty, etc.
120+ const isOptional = jsonTag.includes('omitempty') || jsonTag.includes(',omitempty') || goType.trim().startsWith('*');
121+
122+ // Convert Go types to TypeScript using helper function
123+ const tsType = convertGoTypeToTS(goType.trim());
124+
125+ return {
126+ name: jsonField,
127+ type: tsType,
128+ optional: isOptional
129+ };
130+ })
131+ .filter(Boolean);
132+
133+ // Handle embedded structs by resolving their fields
134+ const resolvedFields = [];
135+ for (const field of fields) {
136+ if (field.embedded) {
137+ // Find the embedded struct and get its fields
138+ const embeddedFields = parseGoStruct(goCode, field.type);
139+ if (embeddedFields) {
140+ resolvedFields.push(...embeddedFields);
141+ }
142+ } else {
143+ resolvedFields.push(field);
144+ }
145+ }
146+
147+ return resolvedFields;
148+}
149+
150+function generateTSInterface(structName, fields) {
151+ const interfaceFields = fields.map(field =>
152+ ` ${field.name}${field.optional ? '?' : ''}: ${field.type};`
153+ ).join('\n');
154+
155+ return `export interface ${structName} {\n${interfaceFields}\n}`;
156+}
157+
158+// Function to discover all struct names in Go code
159+function findAllStructs(goCode) {
160+ const structRegex = /type\s+([A-Z][a-zA-Z0-9]*)\s+struct\s*{/g;
161+ const structs = [];
162+ let match;
163+
164+ while ((match = structRegex.exec(goCode)) !== null) {
165+ structs.push(match[1]);
166+ }
167+
168+ return structs;
169+}
170+
171+// Function to find Go enums (const declarations)
172+function findGoEnums(goCode) {
173+ const enums = [];
174+
175+ // Look for InputType specifically
176+ if (goCode.includes('type InputType int')) {
177+ // Find the const block that contains InputType values
178+ const constRegex = /const\s*\(\s*([\s\S]*?)\s*\)/;
179+ const matches = goCode.match(constRegex);
180+
181+ if (matches) {
182+ const constBody = matches[1];
183+ // Look for any identifier that starts with InputType
184+ const lines = constBody.split('\n');
185+ const values = [];
186+
187+ for (const line of lines) {
188+ const trimmedLine = line.trim();
189+
190+ // Skip comment lines and empty lines
191+ if (trimmedLine.startsWith('//') || trimmedLine === '') continue;
192+
193+ // Check if this line contains an InputType identifier
194+ const identifierMatch = trimmedLine.match(/^\s*(InputType\w+)/);
195+ if (identifierMatch) {
196+ values.push(identifierMatch[1]);
197+ }
198+ }
199+
200+ if (values.length > 0) {
201+ enums.push({ name: 'InputType', values });
202+ }
203+ }
204+ }
205+
206+ return enums;
207+}
208+
209+function generateTSEnum(enumName, values) {
210+ const enumValues = values.map((value, index) => ` ${value} = ${index}`).join(',\n');
211+ return `export enum ${enumName} {\n${enumValues}\n}`;
212+}
213+
214+function generateTypes() {
215+ console.log('🔧 Parsing Go structs and generating TypeScript types...');
216+
217+ const sourceFiles = [
218+ { path: GO_MODELS_PATH, files: ['models.go'] },
219+ { path: GO_PARSER_PATH, files: ['types.go'] }
220+ ];
221+
222+ let combinedTypes = `// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
223+// Auto-generated TypeScript types from Go structs
224+// Generated on: ${new Date().toISOString()}
225+// Generator script: scripts/generate-types.js
226+// Source files: internal/db/models.go, internal/parser/types.go
227+// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
228+
229+`;
230+
231+ const generatedEnums = new Set();
232+
233+ for (const { path, files } of sourceFiles) {
234+ for (const filename of files) {
235+ const filePath = join(path, filename);
236+ if (!existsSync(filePath)) continue;
237+
238+ const goCode = readFileSync(filePath, 'utf-8');
239+ console.log(` 📄 Processing ${filename}...`);
240+
241+ // Automatically discover all structs in this file
242+ const structsInFile = findAllStructs(goCode);
243+ const enumsInFile = findGoEnums(goCode);
244+
245+ // Generate enums first
246+ console.log(` Found ${enumsInFile.length} enums in ${filename}`);
247+ for (const enumDef of enumsInFile) {
248+ if (generatedEnums.has(enumDef.name)) {
249+ continue;
250+ }
251+ generatedEnums.add(enumDef.name);
252+ const tsEnum = generateTSEnum(enumDef.name, enumDef.values);
253+ combinedTypes += tsEnum + '\n\n';
254+ console.log(` ✓ Generated enum ${enumDef.name} with values: ${enumDef.values.join(', ')}`);
255+ }
256+
257+ for (const structName of structsInFile) {
258+ const fields = parseGoStruct(goCode, structName);
259+ if (fields && fields.length > 0) {
260+ const tsInterface = generateTSInterface(structName, fields);
261+ combinedTypes += tsInterface + '\n\n';
262+ console.log(` ✓ Generated ${structName}`);
263+ }
264+ }
265+ }
266+ }
267+
268+ // Add manual types that are hard to auto-generate
269+ combinedTypes += `// Manual types and aliases
270+${generatedEnums.has('InputType') ? '' : `export enum InputType {
271+ InputTypeDirectURL = 0,
272+ InputTypeSearchShortcut = 1,
273+ InputTypeHistorySearch = 2,
274+ InputTypeFallbackSearch = 3,
275+}
276+
277+`}// Type aliases for frontend usage
278+export type HistoryEntry = History;
279+export type SearchShortcut = Shortcut;
280+export type ZoomEntry = ZoomSetting;
281+`;
282+
283+ // Ensure output directory exists
284+ mkdirSync(dirname(TS_OUTPUT_PATH), { recursive: true });
285+
286+ writeFileSync(TS_OUTPUT_PATH, combinedTypes);
287+ console.log(`✅ TypeScript types generated at: ${TS_OUTPUT_PATH}`);
288+}
289+
290+// One-time generation
291+generateTypes();⏎̸
@@ -0,0 +1,291 @@
1+#!/usr/bin/env node
2+
3+import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
4+import { join, dirname } from 'path';
5+
6+/**
7+ * Generate TypeScript types from Go DTOs by parsing Go struct definitions
8+ * Watches for changes in Go files and regenerates automatically
9+ */
10+
11+const GO_MODELS_PATH = 'internal/db/';
12+const GO_PARSER_PATH = 'internal/parser/';
13+const TS_OUTPUT_PATH = 'gui/src/types/generated.ts';
14+
15+// Helper function to convert Go types to TypeScript
16+function convertGoTypeToTS(goType) {
17+ const cleanType = goType.trim().replace(/^\*/, ''); // Remove pointer indicator
18+
19+ // Handle Go slices: []Type -> Type[]
20+ if (cleanType.startsWith('[]')) {
21+ const elementType = cleanType.slice(2); // Remove []
22+ return convertGoTypeToTS(elementType) + '[]';
23+ }
24+
25+ // Handle sql.Null types
26+ if (cleanType.includes('sql.NullString')) return 'string | null';
27+ if (cleanType.includes('sql.NullInt64')) return 'number | null';
28+ if (cleanType.includes('sql.NullTime')) return 'string | null';
29+ if (cleanType.includes('sql.NullBool')) return 'boolean | null';
30+ if (cleanType.includes('sql.NullFloat64')) return 'number | null';
31+
32+ // Handle Go package prefixes
33+ if (cleanType.includes('*db.History')) return 'History | null';
34+ if (cleanType.includes('db.History')) return 'History';
35+
36+ if (cleanType.includes('time.Time')) return 'string'; // ISO date strings
37+ if (cleanType.includes('time.Duration')) return 'number'; // Nanoseconds
38+ if (cleanType.includes('map[string]interface{}')) return 'Record<string, unknown>';
39+ if (cleanType.includes('map[string]string')) return 'Record<string, string>';
40+ if (cleanType.includes('map[string]any')) return 'Record<string, unknown>';
41+ if (cleanType.includes('string')) return 'string';
42+ if (cleanType.includes('int') || cleanType.includes('float')) return 'number';
43+ if (cleanType.includes('bool')) return 'boolean';
44+ if (cleanType.match(/^[A-Z][a-zA-Z0-9]*$/)) return cleanType; // Custom struct types
45+
46+ return 'unknown'; // Fallback
47+}
48+
49+// Parse Go struct to extract JSON field names and types
50+function parseGoStruct(goCode, structName) {
51+ // More sophisticated regex to handle nested braces in type definitions
52+ const structStart = new RegExp(`type\\s+${structName}\\s+struct\\s*{`, 's');
53+ const startMatch = goCode.match(structStart);
54+
55+ if (!startMatch) return null;
56+
57+ // Find the matching closing brace by counting braces
58+ const startIndex = startMatch.index + startMatch[0].length;
59+ let braceCount = 1;
60+ let endIndex = startIndex;
61+
62+ for (let i = startIndex; i < goCode.length && braceCount > 0; i++) {
63+ if (goCode[i] === '{') braceCount++;
64+ if (goCode[i] === '}') braceCount--;
65+ endIndex = i;
66+ }
67+
68+ if (braceCount !== 0) return null; // Unmatched braces
69+
70+ const structContent = goCode.substring(startIndex, endIndex);
71+ const match = [null, structContent]; // Simulate regex match format
72+
73+ if (!match) return null;
74+
75+ // First, reconstruct the struct content by merging lines that belong together
76+ const rawContent = match[1];
77+ const lines = rawContent.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('//'));
78+
79+ // Merge multi-line field definitions
80+ const mergedFields = [];
81+ let currentField = '';
82+
83+ for (const line of lines) {
84+ // If line starts with a field name (capital letter), it's a new field
85+ if (/^\w+\s+/.test(line)) {
86+ // Save previous field if exists
87+ if (currentField) {
88+ mergedFields.push(currentField);
89+ }
90+ currentField = line;
91+ } else {
92+ // This line continues the previous field
93+ currentField += ' ' + line;
94+ }
95+ }
96+
97+ // Don't forget the last field
98+ if (currentField) {
99+ mergedFields.push(currentField);
100+ }
101+
102+ const fields = mergedFields.map(fieldLine => {
103+ // Check for embedded struct (no field name, just type)
104+ const embeddedMatch = fieldLine.match(/^([A-Z][a-zA-Z0-9]*)\s*$/);
105+ if (embeddedMatch) {
106+ const embeddedType = embeddedMatch[1];
107+ // Return a special marker for embedded structs
108+ return {
109+ embedded: true,
110+ type: embeddedType
111+ };
112+ }
113+
114+ // Parse: FieldName type `json:"field_name,omitempty"`
115+ const fieldMatch = fieldLine.match(/^(\w+)\s+([^`]+?)\s*`json:"([^"]+)".*?`/);
116+ if (!fieldMatch) return null;
117+
118+ const [, , goType, jsonTag] = fieldMatch;
119+ const jsonField = jsonTag.split(',')[0]; // Remove omitempty, etc.
120+ const isOptional = jsonTag.includes('omitempty') || jsonTag.includes(',omitempty') || goType.trim().startsWith('*');
121+
122+ // Convert Go types to TypeScript using helper function
123+ const tsType = convertGoTypeToTS(goType.trim());
124+
125+ return {
126+ name: jsonField,
127+ type: tsType,
128+ optional: isOptional
129+ };
130+ })
131+ .filter(Boolean);
132+
133+ // Handle embedded structs by resolving their fields
134+ const resolvedFields = [];
135+ for (const field of fields) {
136+ if (field.embedded) {
137+ // Find the embedded struct and get its fields
138+ const embeddedFields = parseGoStruct(goCode, field.type);
139+ if (embeddedFields) {
140+ resolvedFields.push(...embeddedFields);
141+ }
142+ } else {
143+ resolvedFields.push(field);
144+ }
145+ }
146+
147+ return resolvedFields;
148+}
149+
150+function generateTSInterface(structName, fields) {
151+ const interfaceFields = fields.map(field =>
152+ ` ${field.name}${field.optional ? '?' : ''}: ${field.type};`
153+ ).join('\n');
154+
155+ return `export interface ${structName} {\n${interfaceFields}\n}`;
156+}
157+
158+// Function to discover all struct names in Go code
159+function findAllStructs(goCode) {
160+ const structRegex = /type\s+([A-Z][a-zA-Z0-9]*)\s+struct\s*{/g;
161+ const structs = [];
162+ let match;
163+
164+ while ((match = structRegex.exec(goCode)) !== null) {
165+ structs.push(match[1]);
166+ }
167+
168+ return structs;
169+}
170+
171+// Function to find Go enums (const declarations)
172+function findGoEnums(goCode) {
173+ const enums = [];
174+
175+ // Look for InputType specifically
176+ if (goCode.includes('type InputType int')) {
177+ // Find the const block that contains InputType values
178+ const constRegex = /const\s*\(\s*([\s\S]*?)\s*\)/;
179+ const matches = goCode.match(constRegex);
180+
181+ if (matches) {
182+ const constBody = matches[1];
183+ // Look for any identifier that starts with InputType
184+ const lines = constBody.split('\n');
185+ const values = [];
186+
187+ for (const line of lines) {
188+ const trimmedLine = line.trim();
189+
190+ // Skip comment lines and empty lines
191+ if (trimmedLine.startsWith('//') || trimmedLine === '') continue;
192+
193+ // Check if this line contains an InputType identifier
194+ const identifierMatch = trimmedLine.match(/^\s*(InputType\w+)/);
195+ if (identifierMatch) {
196+ values.push(identifierMatch[1]);
197+ }
198+ }
199+
200+ if (values.length > 0) {
201+ enums.push({ name: 'InputType', values });
202+ }
203+ }
204+ }
205+
206+ return enums;
207+}
208+
209+function generateTSEnum(enumName, values) {
210+ const enumValues = values.map((value, index) => ` ${value} = ${index}`).join(',\n');
211+ return `export enum ${enumName} {\n${enumValues}\n}`;
212+}
213+
214+function generateTypes() {
215+ console.log('🔧 Parsing Go structs and generating TypeScript types...');
216+
217+ const sourceFiles = [
218+ { path: GO_MODELS_PATH, files: ['models.go'] },
219+ { path: GO_PARSER_PATH, files: ['types.go'] }
220+ ];
221+
222+ let combinedTypes = `// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
223+// Auto-generated TypeScript types from Go structs
224+// Generated on: ${new Date().toISOString()}
225+// Generator script: scripts/generate-types.js
226+// Source files: internal/db/models.go, internal/parser/types.go
227+// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
228+
229+`;
230+
231+ const generatedEnums = new Set();
232+
233+ for (const { path, files } of sourceFiles) {
234+ for (const filename of files) {
235+ const filePath = join(path, filename);
236+ if (!existsSync(filePath)) continue;
237+
238+ const goCode = readFileSync(filePath, 'utf-8');
239+ console.log(` 📄 Processing ${filename}...`);
240+
241+ // Automatically discover all structs in this file
242+ const structsInFile = findAllStructs(goCode);
243+ const enumsInFile = findGoEnums(goCode);
244+
245+ // Generate enums first
246+ console.log(` Found ${enumsInFile.length} enums in ${filename}`);
247+ for (const enumDef of enumsInFile) {
248+ if (generatedEnums.has(enumDef.name)) {
249+ continue;
250+ }
251+ generatedEnums.add(enumDef.name);
252+ const tsEnum = generateTSEnum(enumDef.name, enumDef.values);
253+ combinedTypes += tsEnum + '\n\n';
254+ console.log(` ✓ Generated enum ${enumDef.name} with values: ${enumDef.values.join(', ')}`);
255+ }
256+
257+ for (const structName of structsInFile) {
258+ const fields = parseGoStruct(goCode, structName);
259+ if (fields && fields.length > 0) {
260+ const tsInterface = generateTSInterface(structName, fields);
261+ combinedTypes += tsInterface + '\n\n';
262+ console.log(` ✓ Generated ${structName}`);
263+ }
264+ }
265+ }
266+ }
267+
268+ // Add manual types that are hard to auto-generate
269+ combinedTypes += `// Manual types and aliases
270+${generatedEnums.has('InputType') ? '' : `export enum InputType {
271+ InputTypeDirectURL = 0,
272+ InputTypeSearchShortcut = 1,
273+ InputTypeHistorySearch = 2,
274+ InputTypeFallbackSearch = 3,
275+}
276+
277+`}// Type aliases for frontend usage
278+export type HistoryEntry = History;
279+export type SearchShortcut = Shortcut;
280+export type ZoomEntry = ZoomSetting;
281+`;
282+
283+ // Ensure output directory exists
284+ mkdirSync(dirname(TS_OUTPUT_PATH), { recursive: true });
285+
286+ writeFileSync(TS_OUTPUT_PATH, combinedTypes);
287+ console.log(`✅ TypeScript types generated at: ${TS_OUTPUT_PATH}`);
288+}
289+
290+// One-time generation
291+generateTypes();⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close