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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
34 files · +2211 -69
M .gitignore +2 -1
| @@ -84,10 +84,11 @@ CLAUDE.local.md | |||
| 84 | 84 | memory/ | |
| 85 | 85 | notes/ | |
| 86 | 86 | docs/plans/ | |
| 87 | - | scripts/ | |
| 87 | + | scripts/* | |
| 88 | 88 | !scripts/generate-types.js | |
| 89 | 89 | !scripts/verify_purego_only.sh | |
| 90 | 90 | !scripts/stress_omnibox_callbacks.go | |
| 91 | + | !scripts/cef-gpu-diagnostics.sh | |
| 91 | 92 | specs/ | |
| 92 | 93 | templates/ | |
| 93 | 94 | .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.js | 88 | !scripts/generate-types.js | ||
| 89 | !scripts/verify_purego_only.sh | 89 | !scripts/verify_purego_only.sh | ||
| 90 | !scripts/stress_omnibox_callbacks.go | 90 | !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 | |||
| 101 | 101 | ||
| 102 | 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 | 106 | ## Keyboard-Driven | |
| 105 | 107 | ||
| 106 | 108 | 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-Driven | 106 | ## 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 { | |||
| 199 | 199 | timer.Mark("config") | |
| 200 | 200 | ||
| 201 | 201 | ctx := initStartupContextWithTrace(cfg) | |
| 202 | + | applyCEFRenderStackDefault(ctx, cfg) | |
| 202 | 203 | timer.Mark("logger") | |
| 203 | 204 | bootstrapLog := logging.FromContext(ctx) | |
| 204 | 205 | ||
| @@ -273,6 +274,7 @@ func runStandaloneOmnibox() int { | |||
| 273 | 274 | cfg := initConfig() | |
| 274 | 275 | configureBrowserLaunchRelay(cfg) | |
| 275 | 276 | ctx := initStartupContextWithTrace(cfg) | |
| 277 | + | applyCEFRenderStackDefault(ctx, cfg) | |
| 276 | 278 | ||
| 277 | 279 | initResult, err := runParallelInitPhase(ctx, cfg) | |
| 278 | 280 | if err != nil { | |
| @@ -320,6 +322,13 @@ func runStandaloneOmnibox() int { | |||
| 320 | 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 | 332 | func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) { | |
| 324 | 333 | if cfg == nil || initResult == nil || initAdwaita == nil { | |
| 325 | 334 | 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 | return | 334 | 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 | |||
| 690 | 690 | DUMBER_DATABASE_PATH=/custom/path/db.sqlite | |
| 691 | 691 | ||
| 692 | 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 | 696 | DUMBER_RENDERING_MODE=cpu | |
| 697 | + | # WebKit-specific rendering overrides: | |
| 695 | 698 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | |
| 696 | 699 | # DUMBER_RENDERING_GSK_RENDERER=opengl | |
| 697 | 700 | 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.sqlite | 690 | 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=cpu | 696 | DUMBER_RENDERING_MODE=cpu | ||
| 697 | + | # WebKit-specific rendering overrides: | |||
| 695 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | 698 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | ||
| 696 | # DUMBER_RENDERING_GSK_RENDERER=opengl | 699 | # DUMBER_RENDERING_GSK_RENDERER=opengl | ||
| 697 | DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5 | 700 | DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5 | ||
M docs/reference/configuration.md +1 -0
| @@ -59,6 +59,7 @@ | |||
| 59 | 59 | | `rendering.show_fps` | bool | `false` | | | |
| 60 | 60 | | `rendering.sample_memory` | bool | `false` | | | |
| 61 | 61 | | `rendering.debug_frames` | bool | `false` | | | |
| 62 | + | | `DUMBER_RENDER_STACK` env | string | `auto` | `auto`, `vulkan-dmabuf`, `legacy-gl` | | |
| 62 | 63 | | `default_ui_scale` | float | `1.0` | > 0 | | |
| 63 | 64 | | `default_webpage_zoom` | float | `1.2` | > 0 | | |
| 64 | 65 | | `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 | |||
| 71 | 71 | 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 | 78 | ## Debian/Ubuntu | |
| 75 | 79 | ||
| 76 | 80 | ### 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 vulkaninfo | 71 | 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/Ubuntu | 78 | ## Debian/Ubuntu | ||
| 75 | 79 | ||||
| 76 | ### Core GTK4 and WebKit | 80 | ### Core GTK4 and WebKit | ||
M go.mod +2 -0
| @@ -81,3 +81,5 @@ require ( | |||
| 81 | 81 | replace github.com/bnema/purego-cef2gtk => ../purego-cef2gtk | |
| 82 | 82 | ||
| 83 | 83 | 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-cef2gtk | 81 | replace github.com/bnema/purego-cef2gtk => ../purego-cef2gtk | ||
| 82 | 82 | ||||
| 83 | tool github.com/a-h/templ/cmd/templ | 83 | 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) { | |||
| 95 | 95 | } | |
| 96 | 96 | audioFactory := audiofactory.NewAudioOutputFactory() | |
| 97 | 97 | 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, | |
| 100 | 101 | }, cefCfg, audioFactory, deps) | |
| 101 | 102 | default: | |
| 102 | 103 | 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( | |||
| 118 | 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 | 127 | bs := &BrowserSession{ | |
| 124 | 128 | 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 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 6 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -11,6 +12,7 @@ import ( | |||
| 11 | 12 | ||
| 12 | 13 | const ( | |
| 13 | 14 | dumbSchemeName = "dumb" | |
| 15 | + | chromiumEnableFeaturesSwitch = "enable-features" | |
| 14 | 16 | chromiumDisableFeaturesSwitch = "disable-features" | |
| 15 | 17 | chromiumDisableBlinkFeaturesSwitch = "disable-blink-features" | |
| 16 | 18 | ) | |
| @@ -56,9 +58,96 @@ func configureCommandLine(commandLine purecef.CommandLine) { | |||
| 56 | 58 | // Chromium blocks them, showing an infinite spinner. | |
| 57 | 59 | commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required") | |
| 58 | 60 | ||
| 61 | + | configureHardwareVideoDecode(commandLine) | |
| 62 | + | configureEnvChromiumFlags(commandLine) | |
| 59 | 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 | 151 | func configureWebAuthnFeaturePolicy(commandLine purecef.CommandLine) { | |
| 63 | 152 | if commandLine == nil { | |
| 64 | 153 | return | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package cef | 1 | 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 | return | 153 | return | ||
M internal/infrastructure/cef/app_test.go +71 -0
| @@ -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 | 371 | func TestAppendUniqueCommaSeparatedSwitchValues(t *testing.T) { | |
| 301 | 372 | t.Run("trims whitespace and skips empty existing values", func(t *testing.T) { | |
| 302 | 373 | 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 { | |||
| 28 | 28 | destroyed atomic.Bool | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 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. | |
| 33 | 33 | func NewCef2gtkAdapter() *Cef2gtkAdapter { | |
| 34 | 34 | v := cef2gtk.NewView() | |
| 35 | 35 | if v == nil { | |
| @@ -47,8 +47,8 @@ func (a *Cef2gtkAdapter) Widget() *gtk.Widget { | |||
| 47 | 47 | return a.view.Widget() | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 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. | |
| 52 | 52 | func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea { | |
| 53 | 53 | if a == nil || a.destroyed.Load() || a.view == nil { | |
| 54 | 54 | return nil | |
| @@ -56,14 +56,14 @@ func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea { | |||
| 56 | 56 | return a.view.GLArea() | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | - | // NativeWidget returns the uintptr of the GLArea for embedding via | |
| 59 | + | // NativeWidget returns the uintptr of the active GTK widget for embedding via | |
| 60 | 60 | // port.NativeWidgetProvider. Call on the GTK main thread. | |
| 61 | 61 | func (a *Cef2gtkAdapter) NativeWidget() uintptr { | |
| 62 | - | area := a.GLArea() | |
| 63 | - | if area == nil { | |
| 62 | + | widget := a.Widget() | |
| 63 | + | if widget == nil { | |
| 64 | 64 | return 0 | |
| 65 | 65 | } | |
| 66 | - | return area.GoPointer() | |
| 66 | + | return widget.GoPointer() | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | 69 | // Size returns the bridge's last observed positive widget size, or 1x1 before | |
| @@ -112,7 +112,7 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) { | |||
| 112 | 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 | 116 | // Must be called on the GTK main thread. | |
| 117 | 117 | func (a *Cef2gtkAdapter) PrepareOnGTKThread() error { | |
| 118 | 118 | if a == nil || a.destroyed.Load() || a.view == nil { | |
| @@ -123,7 +123,7 @@ func (a *Cef2gtkAdapter) PrepareOnGTKThread() error { | |||
| 123 | 123 | ||
| 124 | 124 | // RenderHandler returns a CEF render handler that delegates accelerated paint | |
| 125 | 125 | // 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 | 128 | // hooks may be a zero-value cef2gtk.Hooks if no custom callbacks are needed. | |
| 129 | 129 | func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler { | |
| @@ -133,6 +133,14 @@ func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandle | |||
| 133 | 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 | 144 | // AttachInput attaches GTK event controllers to the view and forwards input | |
| 137 | 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 | 180 | return a.view.Diagnostics() | |
| 173 | 181 | } | |
| 174 | 182 | ||
| 175 | - | // Destroy releases GL resources owned by purego-cef2gtk and disconnects GTK | |
| 183 | + | // Destroy releases renderer resources owned by purego-cef2gtk and disconnects GTK | |
| 176 | 184 | // signal handlers. Must be called on the GTK main thread. The atomic destroyed | |
| 177 | 185 | // flag only communicates lifecycle state; accesses to view are not otherwise | |
| 178 | 186 | // synchronized, so callers must ensure no concurrent adapter method is using the | |
| @@ -28,8 +28,8 @@ type Cef2gtkAdapter struct { | |||||
| 28 | destroyed atomic.Bool | 28 | destroyed atomic.Bool | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | - | // NewCef2gtkAdapter creates a GtkGLArea-backed accelerated CEF view and wraps | 31 | + | // 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 nil | 54 | 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 via | 59 | + | // 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 0 | 64 | 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 before | 69 | // 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 paint | 124 | // RenderHandler returns a CEF render handler that delegates accelerated paint | ||
| 125 | // handling to purego-cef2gtk. The returned handler satisfies cef.RenderHandler | 125 | // 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 input | 144 | // 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 GTK | 183 | + | // Destroy releases renderer resources owned by purego-cef2gtk and disconnects GTK |
| 176 | // signal handlers. Must be called on the GTK main thread. The atomic destroyed | 184 | // signal handlers. Must be called on the GTK main thread. The atomic destroyed | ||
| 177 | // flag only communicates lifecycle state; accesses to view are not otherwise | 185 | // flag only communicates lifecycle state; accesses to view are not otherwise | ||
| 178 | // synchronized, so callers must ensure no concurrent adapter method is using the | 186 | // 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( | |||
| 76 | 76 | executor := h.contextMenuExecutor() | |
| 77 | 77 | wv := h.wv | |
| 78 | 78 | wv.runOnGTK(func() { | |
| 79 | - | if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil { | |
| 79 | + | if wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |
| 80 | 80 | callback.Cancel() | |
| 81 | 81 | return | |
| 82 | 82 | } | |
| 83 | - | glArea := wv.viewBridge.GLArea() | |
| 84 | - | logContextMenuPopupRequest(h, glArea, params, x, y) | |
| 83 | + | widget := wv.viewBridge.Widget() | |
| 84 | + | logContextMenuPopupRequest(h, widget, params, x, y) | |
| 85 | 85 | gtkmenu.NewRenderer(nil).Show( | |
| 86 | 86 | items, | |
| 87 | - | &glArea.Widget, | |
| 87 | + | widget, | |
| 88 | 88 | x, | |
| 89 | 89 | y, | |
| 90 | 90 | func(item port.MenuItem) { | |
| @@ -126,23 +126,23 @@ func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (i | |||
| 126 | 126 | ||
| 127 | 127 | func logContextMenuPopupRequest( | |
| 128 | 128 | h *handlerSet, | |
| 129 | - | glArea *gtk.GLArea, | |
| 129 | + | widget *gtk.Widget, | |
| 130 | 130 | params purecef.ContextMenuParams, | |
| 131 | 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 | 134 | return | |
| 135 | 135 | } | |
| 136 | 136 | rawX, rawY := contextMenuRawPosition(params) | |
| 137 | - | parent := glArea.GetParent() | |
| 137 | + | parent := widget.GetParent() | |
| 138 | 138 | logging.FromContext(h.wv.ctx).Debug(). | |
| 139 | 139 | Int32("raw_x", rawX). | |
| 140 | 140 | Int32("raw_y", rawY). | |
| 141 | 141 | Int32("popup_x", x). | |
| 142 | 142 | Int32("popup_y", y). | |
| 143 | 143 | 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()). | |
| 146 | 146 | Int("parent_width", cefWidgetAllocatedWidth(parent)). | |
| 147 | 147 | Int("parent_height", cefWidgetAllocatedHeight(parent)). | |
| 148 | 148 | 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.wv | 77 | 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 | return | 81 | 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 | return | 134 | 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) | |||
| 23 | 23 | // Engine implements port.Engine for the CEF browser backend. | |
| 24 | 24 | // It manages the CEF lifecycle and provides access to all engine subsystems. | |
| 25 | 25 | 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 | |
| 29 | 30 | ||
| 30 | 31 | messageRouter *MessageRouter | |
| 31 | 32 | schemeHandler *dumbSchemeHandler | |
| @@ -55,6 +56,12 @@ type Engine struct { | |||
| 55 | 56 | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | |
| 56 | 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 | 65 | // Diagnostic counters. | |
| 59 | 66 | contextInitializedCount atomic.Uint64 | |
| 60 | 67 | childLaunchRenderer atomic.Uint64 | |
| @@ -68,7 +75,6 @@ type Engine struct { | |||
| 68 | 75 | browserCreateLastWidth atomic.Int32 | |
| 69 | 76 | browserCreateLastHeight atomic.Int32 | |
| 70 | 77 | browserCreateComplete atomic.Bool | |
| 71 | - | ||
| 72 | 78 | } | |
| 73 | 79 | ||
| 74 | 80 | func (e *Engine) Factory() port.WebViewFactory { | |
| @@ -196,6 +202,7 @@ func (e *Engine) Close() error { | |||
| 196 | 202 | ||
| 197 | 203 | e.closeActiveWebViews() | |
| 198 | 204 | e.pool.Close() | |
| 205 | + | e.stopCEFHeartbeat() | |
| 199 | 206 | ||
| 200 | 207 | if activeAfter := e.activeWebViewCount(); activeAfter > 0 { | |
| 201 | 208 | log.Warn(). | |
| @@ -474,4 +481,3 @@ func (e *Engine) currentDownloadHandler() *downloadHandler { | |||
| 474 | 481 | defer e.downloadMu.RUnlock() | |
| 475 | 482 | return e.downloadHandler | |
| 476 | 483 | } | |
| 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.Context | 26 | + | ctx context.Context |
| 27 | - | factory *WebViewFactory | 27 | + | factory *WebViewFactory |
| 28 | - | pool *WebViewPool | 28 | + | pool *WebViewPool |
| 29 | + | profileLogDir string | |||
| 29 | 30 | ||||
| 30 | messageRouter *MessageRouter | 31 | messageRouter *MessageRouter | ||
| 31 | schemeHandler *dumbSchemeHandler | 32 | 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.Uint64 | 66 | contextInitializedCount atomic.Uint64 | ||
| 60 | childLaunchRenderer atomic.Uint64 | 67 | childLaunchRenderer atomic.Uint64 | ||
| @@ -68,7 +75,6 @@ type Engine struct { | |||||
| 68 | browserCreateLastWidth atomic.Int32 | 75 | browserCreateLastWidth atomic.Int32 | ||
| 69 | browserCreateLastHeight atomic.Int32 | 76 | browserCreateLastHeight atomic.Int32 | ||
| 70 | browserCreateComplete atomic.Bool | 77 | 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.downloadHandler | 482 | 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" | |||
| 26 | 26 | ||
| 27 | 27 | // RuntimePaths contains the concrete filesystem paths the CEF adapter needs. | |
| 28 | 28 | type RuntimePaths struct { | |
| 29 | - | StateRoot string | |
| 30 | - | LogFile string | |
| 29 | + | StateRoot string | |
| 30 | + | LogFile string | |
| 31 | + | ProfileLogDir string | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 34 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| @@ -56,6 +57,7 @@ func NewEngine( | |||
| 56 | 57 | ||
| 57 | 58 | eng := &Engine{ | |
| 58 | 59 | ctx: ctx, | |
| 60 | + | profileLogDir: paths.ProfileLogDir, | |
| 59 | 61 | registerHandlers: deps.RegisterHandlers, | |
| 60 | 62 | registerAccentHandlers: deps.RegisterAccentHandlers, | |
| 61 | 63 | currentConfigPayload: deps.CurrentConfigPayload, | |
| @@ -186,6 +188,7 @@ func wireEngine( | |||
| 186 | 188 | } | |
| 187 | 189 | eng.messageRouter = messageRouter | |
| 188 | 190 | eng.schemeHandler = schemeHandler | |
| 191 | + | eng.startCEFHeartbeat() | |
| 189 | 192 | ||
| 190 | 193 | registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler)) | |
| 191 | 194 | 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 string | 29 | + | StateRoot string |
| 30 | - | LogFile string | 30 | + | 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 = messageRouter | 189 | eng.messageRouter = messageRouter | ||
| 188 | eng.schemeHandler = schemeHandler | 190 | eng.schemeHandler = schemeHandler | ||
| 191 | + | eng.startCEFHeartbeat() | |||
| 189 | 192 | ||||
| 190 | registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler)) | 193 | registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler)) | ||
| 191 | return eng, nil | 194 | return eng, nil | ||
M internal/infrastructure/cef/env.go +12 -0
| @@ -8,6 +8,10 @@ import ( | |||
| 8 | 8 | const ( | |
| 9 | 9 | cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME" | |
| 10 | 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 | 17 | // envBoolEnabled returns true when the given environment variable is set | |
| @@ -28,3 +32,11 @@ func externalBeginFrameEnabled() bool { | |||
| 28 | 32 | func cefWebAuthnUnsafeEnabled() bool { | |
| 29 | 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 | + | } | |
| @@ -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 set | 17 | // 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) { | |||
| 126 | 126 | // Build a CEF client backed by our handlerSet. | |
| 127 | 127 | // Store on WebView to prevent GC collection before CEF AddRef's it. | |
| 128 | 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 | 135 | return wv, nil | |
| 130 | 136 | } | |
| 131 | 137 | ||
| @@ -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, nil | 135 | 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 ( | |||
| 14 | 14 | "time" | |
| 15 | 15 | ||
| 16 | 16 | purecef "github.com/bnema/purego-cef/cef" | |
| 17 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 17 | 18 | "github.com/bnema/puregotk/v4/glib" | |
| 18 | 19 | "github.com/bnema/puregotk/v4/gtk" | |
| 19 | 20 | ||
| @@ -68,15 +69,24 @@ var ( | |||
| 68 | 69 | // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the | |
| 69 | 70 | // bridge owns GTK rendering and input forwarding. | |
| 70 | 71 | 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 | |
| 80 | 90 | ||
| 81 | 91 | removeSizeObserver func() | |
| 82 | 92 | ||
| @@ -137,6 +147,7 @@ type WebView struct { | |||
| 137 | 147 | isLoading bool | |
| 138 | 148 | selectedText string | |
| 139 | 149 | focusedEditable bool | |
| 150 | + | inputAttached bool | |
| 140 | 151 | bridgeNonce string | |
| 141 | 152 | selectionDebounceTimer stoppableTimer | |
| 142 | 153 | selectionDebounceSeq uint64 | |
| @@ -513,9 +524,9 @@ func (wv *WebView) SetOnReadyToShow(fn func()) { | |||
| 513 | 524 | } | |
| 514 | 525 | wv.mu.Lock() | |
| 515 | 526 | wv.popupReadyToShow = fn | |
| 516 | - | alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil | |
| 527 | + | shouldTryReady := wv.browser != nil && fn != nil | |
| 517 | 528 | wv.mu.Unlock() | |
| 518 | - | if alreadyReady { | |
| 529 | + | if shouldTryReady { | |
| 519 | 530 | wv.fireReadyToShow() | |
| 520 | 531 | } | |
| 521 | 532 | } | |
| @@ -596,7 +607,7 @@ func (wv *WebView) fireReadyToShow() { | |||
| 596 | 607 | return | |
| 597 | 608 | } | |
| 598 | 609 | wv.mu.Lock() | |
| 599 | - | if wv.popupReadyShown || wv.popupReadyToShow == nil { | |
| 610 | + | if wv.popupReadyShown || wv.popupReadyToShow == nil || !wv.inputAttached { | |
| 600 | 611 | wv.mu.Unlock() | |
| 601 | 612 | return | |
| 602 | 613 | } | |
| @@ -606,6 +617,16 @@ func (wv *WebView) fireReadyToShow() { | |||
| 606 | 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 | 630 | func (wv *WebView) markNativePopupCandidate(parent *WebView) { | |
| 610 | 631 | if wv == nil { | |
| 611 | 632 | return | |
| @@ -1000,6 +1021,7 @@ func (wv *WebView) Destroy() { | |||
| 1000 | 1021 | wv.openerMessageCallbacks = nil | |
| 1001 | 1022 | wv.openerNavigationCallbacks = nil | |
| 1002 | 1023 | wv.popupReadyToShow = nil | |
| 1024 | + | wv.inputAttached = false | |
| 1003 | 1025 | wv.pendingNativePopups = nil | |
| 1004 | 1026 | wv.nativePopupParent = nil | |
| 1005 | 1027 | wv.nativePopupCandidate = false | |
| @@ -1009,6 +1031,7 @@ func (wv *WebView) Destroy() { | |||
| 1009 | 1031 | wv.popupOpenerBridgeParentURI = "" | |
| 1010 | 1032 | wv.mu.Unlock() | |
| 1011 | 1033 | wv.stopNativePopupFallbackTimer() | |
| 1034 | + | wv.stopRenderStallWatchdog() | |
| 1012 | 1035 | wv.cancelSelectionDebounce() | |
| 1013 | 1036 | wv.closeAudioStream() | |
| 1014 | 1037 | wv.scheduleStopBeginFrameLoop() | |
| @@ -1030,13 +1053,17 @@ func (wv *WebView) Destroy() { | |||
| 1030 | 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 | 1063 | // 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 | 1067 | func (wv *WebView) NativeWidget() uintptr { | |
| 1041 | 1068 | if wv.viewBridge == nil { | |
| 1042 | 1069 | return 0 | |
| @@ -1563,7 +1590,7 @@ func (wv *WebView) scheduleStopBeginFrameLoop() { | |||
| 1563 | 1590 | } | |
| 1564 | 1591 | ||
| 1565 | 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 | 1594 | return | |
| 1568 | 1595 | } | |
| 1569 | 1596 | ||
| @@ -1573,7 +1600,7 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1573 | 1600 | return | |
| 1574 | 1601 | } | |
| 1575 | 1602 | ||
| 1576 | - | glArea := wv.viewBridge.GLArea() | |
| 1603 | + | widget := wv.viewBridge.Widget() | |
| 1577 | 1604 | cb := new(gtk.TickCallback) | |
| 1578 | 1605 | *cb = func(_, _, _ uintptr) bool { | |
| 1579 | 1606 | if wv.destroyed.Load() { | |
| @@ -1589,7 +1616,7 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1589 | 1616 | return true | |
| 1590 | 1617 | } | |
| 1591 | 1618 | wv.beginFrameTick = cb | |
| 1592 | - | wv.beginFrameTickID = glArea.AddTickCallback(cb, 0, nil) | |
| 1619 | + | wv.beginFrameTickID = widget.AddTickCallback(cb, 0, nil) | |
| 1593 | 1620 | host := wv.host | |
| 1594 | 1621 | wv.mu.Unlock() | |
| 1595 | 1622 | ||
| @@ -1599,7 +1626,7 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1599 | 1626 | } | |
| 1600 | 1627 | ||
| 1601 | 1628 | func (wv *WebView) stopBeginFrameLoop() { | |
| 1602 | - | if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil { | |
| 1629 | + | if wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |
| 1603 | 1630 | return | |
| 1604 | 1631 | } | |
| 1605 | 1632 | ||
| @@ -1607,11 +1634,11 @@ func (wv *WebView) stopBeginFrameLoop() { | |||
| 1607 | 1634 | tickID := wv.beginFrameTickID | |
| 1608 | 1635 | wv.beginFrameTickID = 0 | |
| 1609 | 1636 | wv.beginFrameTick = nil | |
| 1610 | - | glArea := wv.viewBridge.GLArea() | |
| 1637 | + | widget := wv.viewBridge.Widget() | |
| 1611 | 1638 | wv.mu.Unlock() | |
| 1612 | 1639 | ||
| 1613 | 1640 | if tickID != 0 { | |
| 1614 | - | glArea.RemoveTickCallback(tickID) | |
| 1641 | + | widget.RemoveTickCallback(tickID) | |
| 1615 | 1642 | } | |
| 1616 | 1643 | } | |
| 1617 | 1644 | ||
| @@ -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; the | 69 | // 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.WebViewID | 72 | + | id port.WebViewID |
| 72 | - | ctx context.Context | 73 | + | ctx context.Context |
| 73 | - | engine *Engine | 74 | + | engine *Engine |
| 74 | - | browser purecef.Browser | 75 | + | browser purecef.Browser |
| 75 | - | host purecef.BrowserHost | 76 | + | host purecef.BrowserHost |
| 76 | - | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | 77 | + | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it |
| 77 | - | viewBridge *Cef2gtkAdapter | 78 | + | viewBridge *Cef2gtkAdapter |
| 78 | - | handlers *handlerSet | 79 | + | handlers *handlerSet |
| 79 | - | findCtrl *cefFindController | 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 | |||
| 80 | 90 | ||||
| 81 | removeSizeObserver func() | 91 | removeSizeObserver func() | ||
| 82 | 92 | ||||
| @@ -137,6 +147,7 @@ type WebView struct { | |||||
| 137 | isLoading bool | 147 | isLoading bool | ||
| 138 | selectedText string | 148 | selectedText string | ||
| 139 | focusedEditable bool | 149 | focusedEditable bool | ||
| 150 | + | inputAttached bool | |||
| 140 | bridgeNonce string | 151 | bridgeNonce string | ||
| 141 | selectionDebounceTimer stoppableTimer | 152 | selectionDebounceTimer stoppableTimer | ||
| 142 | selectionDebounceSeq uint64 | 153 | 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 = fn | 526 | wv.popupReadyToShow = fn | ||
| 516 | - | alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil | 527 | + | 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 | return | 607 | 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 | return | 612 | 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 | return | 632 | return | ||
| @@ -1000,6 +1021,7 @@ func (wv *WebView) Destroy() { | |||||
| 1000 | wv.openerMessageCallbacks = nil | 1021 | wv.openerMessageCallbacks = nil | ||
| 1001 | wv.openerNavigationCallbacks = nil | 1022 | wv.openerNavigationCallbacks = nil | ||
| 1002 | wv.popupReadyToShow = nil | 1023 | wv.popupReadyToShow = nil | ||
| 1024 | + | wv.inputAttached = false | |||
| 1003 | wv.pendingNativePopups = nil | 1025 | wv.pendingNativePopups = nil | ||
| 1004 | wv.nativePopupParent = nil | 1026 | wv.nativePopupParent = nil | ||
| 1005 | wv.nativePopupCandidate = false | 1027 | 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 | // NativeWidgetProvider | 1063 | // 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 0 | 1069 | 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 | return | 1594 | return | ||
| 1568 | } | 1595 | } | ||
| 1569 | 1596 | ||||
| @@ -1573,7 +1600,7 @@ func (wv *WebView) startBeginFrameLoop() { | |||||
| 1573 | return | 1600 | 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 true | 1616 | return true | ||
| 1590 | } | 1617 | } | ||
| 1591 | wv.beginFrameTick = cb | 1618 | wv.beginFrameTick = cb | ||
| 1592 | - | wv.beginFrameTickID = glArea.AddTickCallback(cb, 0, nil) | 1619 | + | wv.beginFrameTickID = widget.AddTickCallback(cb, 0, nil) |
| 1593 | host := wv.host | 1620 | 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 | return | 1630 | return | ||
| 1604 | } | 1631 | } | ||
| 1605 | 1632 | ||||
| @@ -1607,11 +1634,11 @@ func (wv *WebView) stopBeginFrameLoop() { | |||||
| 1607 | tickID := wv.beginFrameTickID | 1634 | tickID := wv.beginFrameTickID | ||
| 1608 | wv.beginFrameTickID = 0 | 1635 | wv.beginFrameTickID = 0 | ||
| 1609 | wv.beginFrameTick = nil | 1636 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| 7 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -531,6 +532,9 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 531 | 532 | h.wv.browser = browser | |
| 532 | 533 | h.wv.host = host | |
| 533 | 534 | h.wv.pendingCreate = nil | |
| 535 | + | bridge := h.wv.viewBridge | |
| 536 | + | h.wv.inputAttached = bridge == nil | |
| 537 | + | state.inputAttached = h.wv.inputAttached | |
| 534 | 538 | if h.wv.findCtrl != nil { | |
| 535 | 539 | h.wv.findCtrl.setHost(host) | |
| 536 | 540 | } | |
| @@ -542,10 +546,11 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 542 | 546 | state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != "" | |
| 543 | 547 | h.wv.mu.Unlock() | |
| 544 | 548 | ||
| 545 | - | if h.wv.viewBridge != nil { | |
| 549 | + | if bridge != nil { | |
| 546 | 550 | wv := h.wv | |
| 547 | 551 | wv.runOnGTK(func() { | |
| 548 | - | if wv.viewBridge == nil { | |
| 552 | + | if wv.destroyed.Load() || wv.viewBridge == nil { | |
| 553 | + | wv.handleInputAttachFailure(ErrAdapterDestroyed, host) | |
| 549 | 554 | return | |
| 550 | 555 | } | |
| 551 | 556 | if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{ | |
| @@ -559,28 +564,39 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 559 | 564 | wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |
| 560 | 565 | } | |
| 561 | 566 | }, | |
| 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 | |
| 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 | 580 | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | |
| 569 | 581 | // painting/caret updates until explicitly told the browser is shown. | |
| 570 | 582 | host.WasHidden(0) | |
| 571 | - | state.shouldSyncFocus = h.afterCreatedShouldSyncFocus() | |
| 572 | 583 | return state | |
| 573 | 584 | } | |
| 574 | 585 | ||
| 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() | |
| 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 | 602 | func (h *handlerSet) finishAfterCreated( | |
| @@ -594,9 +610,7 @@ func (h *handlerSet) finishAfterCreated( | |||
| 594 | 610 | h.wv.engine.bindBrowserWebView(browser, h.wv) | |
| 595 | 611 | } | |
| 596 | 612 | h.wv.stopNativePopupFallbackTimer() | |
| 597 | - | if state.shouldSyncFocus { | |
| 598 | - | syncWindowlessBrowserFocus(host) | |
| 599 | - | } else { | |
| 613 | + | if state.inputAttached { | |
| 600 | 614 | // Request an initial OSR frame even before the first real navigation | |
| 601 | 615 | // commits. This restores the about:blank warm-up paint that the stable | |
| 602 | 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 | 629 | type afterCreatedState struct { | |
| 616 | - | shouldSyncFocus bool | |
| 630 | + | inputAttached bool | |
| 617 | 631 | hasPendingNavigation bool | |
| 618 | 632 | nativePopupParent *WebView | |
| 619 | 633 | nativePopupID int32 | |
| @@ -638,6 +652,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||
| 638 | 652 | h.wv.mu.Lock() | |
| 639 | 653 | h.wv.browser = nil | |
| 640 | 654 | h.wv.host = nil | |
| 655 | + | h.wv.inputAttached = false | |
| 641 | 656 | if h.wv.findCtrl != nil { | |
| 642 | 657 | h.wv.findCtrl.setHost(nil) | |
| 643 | 658 | } | |
| @@ -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 = browser | 532 | h.wv.browser = browser | ||
| 532 | h.wv.host = host | 533 | h.wv.host = host | ||
| 533 | h.wv.pendingCreate = nil | 534 | 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.wv | 550 | 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 | return | 554 | 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 suppresses | 580 | // 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 state | 583 | 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 false | 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() | |||
| 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 navigation | 614 | // Request an initial OSR frame even before the first real navigation | ||
| 601 | // commits. This restores the about:blank warm-up paint that the stable | 615 | // 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 bool | 630 | + | inputAttached bool |
| 617 | hasPendingNavigation bool | 631 | hasPendingNavigation bool | ||
| 618 | nativePopupParent *WebView | 632 | nativePopupParent *WebView | ||
| 619 | nativePopupID int32 | 633 | 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 = nil | 653 | h.wv.browser = nil | ||
| 640 | h.wv.host = nil | 654 | 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 @@ | |||
| 1 | 1 | package logging | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "crypto/rand" | |
| 5 | 6 | "encoding/hex" | |
| 6 | 7 | "errors" | |
| @@ -55,6 +56,31 @@ func SessionFilename(sessionID string) string { | |||
| 55 | 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 | 84 | type sessionLogFile struct { | |
| 59 | 85 | path string | |
| 60 | 86 | filename string | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package logging | 1 | 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 string | 85 | path string | ||
| 60 | filename string | 86 | filename string | ||
M internal/ui/input/global_shortcuts.go +2 -0
| @@ -371,6 +371,8 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time | |||
| 371 | 371 | return false | |
| 372 | 372 | } | |
| 373 | 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 | 376 | return true | |
| 375 | 377 | } | |
| 376 | 378 | 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 false | 371 | 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 true | 376 | 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) { | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 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 | 45 | if !h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) { | |
| 38 | 46 | t.Fatal("one-shot shortcut was not suppressed with detached handler state") | |
| 39 | 47 | } | |
| @@ -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
| @@ -0,0 +1,6 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "dumber", | |
| 3 | + | "lockfileVersion": 3, | |
| 4 | + | "requires": true, | |
| 5 | + | "packages": {} | |
| 6 | + | } | |
| @@ -0,0 +1,6 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "dumber", | |||
| 3 | + | "lockfileVersion": 3, | |||
| 4 | + | "requires": true, | |||
| 5 | + | "packages": {} | |||
| 6 | + | } | |||
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();⏎̸ | |||