detached d8b3f7c

cef: clean architecture review findings

commit
d8b3f7c0500c0419ec6d56a6905132f351ec9c45
author
brice · · 2026-05-01 14:51 UTC
parent
952e013
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +311 -180
M cmd/dumber/main.go +6 -1
@@ -24,6 +24,7 @@ import (
2424 "github.com/bnema/dumber/internal/infrastructure/config"
2525 "github.com/bnema/dumber/internal/infrastructure/deps"
2626 "github.com/bnema/dumber/internal/infrastructure/desktop"
27+ renderenv "github.com/bnema/dumber/internal/infrastructure/env"
2728 "github.com/bnema/dumber/internal/infrastructure/favicon"
2829 "github.com/bnema/dumber/internal/infrastructure/filesystem"
2930 "github.com/bnema/dumber/internal/infrastructure/idle"
@@ -327,7 +328,11 @@ func applyCEFRenderStackDefault(ctx context.Context, cfg *config.Config) {
327328 return
328329 }
329330 infracef.ApplyDefaultRenderStackEnvironment(ctx)
330- infracef.ApplyDefaultHardwareDecodeEnvironment(ctx, cfg)
331+ infracef.ApplyDefaultHardwareDecodeEnvironment(ctx, infracef.HardwareDecodeEnvironmentOptions{
332+ EngineType: cfg.Engine.ResolveEngineType(),
333+ HardwareDecodingDisabled: cfg.Media.HardwareDecodingMode == config.HardwareDecodingDisable,
334+ RenderingEnvManager: renderenv.NewManager(),
335+ })
331336 }
332337
333338 func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) {
@@ -24,6 +24,7 @@ import (
24 "github.com/bnema/dumber/internal/infrastructure/config"24 "github.com/bnema/dumber/internal/infrastructure/config"
25 "github.com/bnema/dumber/internal/infrastructure/deps"25 "github.com/bnema/dumber/internal/infrastructure/deps"
26 "github.com/bnema/dumber/internal/infrastructure/desktop"26 "github.com/bnema/dumber/internal/infrastructure/desktop"
27+ renderenv "github.com/bnema/dumber/internal/infrastructure/env"
27 "github.com/bnema/dumber/internal/infrastructure/favicon"28 "github.com/bnema/dumber/internal/infrastructure/favicon"
28 "github.com/bnema/dumber/internal/infrastructure/filesystem"29 "github.com/bnema/dumber/internal/infrastructure/filesystem"
29 "github.com/bnema/dumber/internal/infrastructure/idle"30 "github.com/bnema/dumber/internal/infrastructure/idle"
@@ -327,7 +328,11 @@ func applyCEFRenderStackDefault(ctx context.Context, cfg *config.Config) {
327 return328 return
328 }329 }
329 infracef.ApplyDefaultRenderStackEnvironment(ctx)330 infracef.ApplyDefaultRenderStackEnvironment(ctx)
330- infracef.ApplyDefaultHardwareDecodeEnvironment(ctx, cfg)331+ infracef.ApplyDefaultHardwareDecodeEnvironment(ctx, infracef.HardwareDecodeEnvironmentOptions{
332+ EngineType: cfg.Engine.ResolveEngineType(),
333+ HardwareDecodingDisabled: cfg.Media.HardwareDecodingMode == config.HardwareDecodingDisable,
334+ RenderingEnvManager: renderenv.NewManager(),
335+ })
331 }336 }
332 337
333 func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) {338 func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) {
M internal/bootstrap/engine.go +2 -0
@@ -13,6 +13,7 @@ import (
1313 clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard"
1414 "github.com/bnema/dumber/internal/infrastructure/config"
1515 "github.com/bnema/dumber/internal/infrastructure/env"
16+ "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
1617 "github.com/bnema/dumber/internal/infrastructure/handlers"
1718 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1819 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -90,6 +91,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
9091 DefaultConfigPayload: defaultConfigPayload,
9192 ContextMenuBuilder: contextMenuBuilder,
9293 ContextMenuExecutorFactory: contextMenuExecutorFactory,
94+ ContextMenuRenderer: gtkmenu.NewRenderer(nil),
9395 Clipboard: clipboardinfra.New(),
9496 ImageDataResolver: webkit.NewContextMenuResolver(),
9597 }
@@ -13,6 +13,7 @@ import (
13 clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard"13 clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard"
14 "github.com/bnema/dumber/internal/infrastructure/config"14 "github.com/bnema/dumber/internal/infrastructure/config"
15 "github.com/bnema/dumber/internal/infrastructure/env"15 "github.com/bnema/dumber/internal/infrastructure/env"
16+ "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
16 "github.com/bnema/dumber/internal/infrastructure/handlers"17 "github.com/bnema/dumber/internal/infrastructure/handlers"
17 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"18 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
18 "github.com/bnema/dumber/internal/infrastructure/webkit"19 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -90,6 +91,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
90 DefaultConfigPayload: defaultConfigPayload,91 DefaultConfigPayload: defaultConfigPayload,
91 ContextMenuBuilder: contextMenuBuilder,92 ContextMenuBuilder: contextMenuBuilder,
92 ContextMenuExecutorFactory: contextMenuExecutorFactory,93 ContextMenuExecutorFactory: contextMenuExecutorFactory,
94+ ContextMenuRenderer: gtkmenu.NewRenderer(nil),
93 Clipboard: clipboardinfra.New(),95 Clipboard: clipboardinfra.New(),
94 ImageDataResolver: webkit.NewContextMenuResolver(),96 ImageDataResolver: webkit.NewContextMenuResolver(),
95 }97 }
M internal/infrastructure/cef/cef2gtk_adapter.go +0 -7
@@ -275,10 +275,3 @@ func (a *Cef2gtkAdapter) Destroy() error {
275275 func (a *Cef2gtkAdapter) IsDestroyed() bool {
276276 return a == nil || a.destroyed.Load()
277277 }
278-
279-func (a *Cef2gtkAdapter) destroyCount() uint64 {
280- if a == nil {
281- return 0
282- }
283- return a.destroyCnt.Load()
284-}
@@ -275,10 +275,3 @@ func (a *Cef2gtkAdapter) Destroy() error {
275 func (a *Cef2gtkAdapter) IsDestroyed() bool {275 func (a *Cef2gtkAdapter) IsDestroyed() bool {
276 return a == nil || a.destroyed.Load()276 return a == nil || a.destroyed.Load()
277 }277 }
278-
279-func (a *Cef2gtkAdapter) destroyCount() uint64 {
280- if a == nil {
281- return 0
282- }
283- return a.destroyCnt.Load()
284-}
D internal/infrastructure/cef/cef2gtk_migration_audit.md +0 -81
@@ -1,81 +0,0 @@
1-# purego-cef2gtk migration audit
2-
3-Date: 2026-04-29
4-Branch: `feature/use-purego-cef2gtk`
5-
6-This audit records the old Dumber CEF/GTK rendering/input stack references that must be replaced before the old files are deleted.
7-
8-## Old-stack references
9-
10-### `renderPipeline`
11-
12-- Defined in `internal/infrastructure/cef/render_pipeline.go`.
13-- Owned by `WebView.pipeline` in `webview.go`.
14-- Created in `factory.go`.
15-- Used for view rect/screen info, popup paint, CPU `OnPaint`, diagnostics, cursor updates, begin-frame `GtkGLArea`, native widget, and resize callbacks.
16-
17-Classification:
18-
19-- Rendering/GL upload: replace with `purego-cef2gtk.View.RenderHandler` and accelerated DMABUF path.
20-- Widget/GLArea access: replace with the thin Dumber `Cef2gtkAdapter`.
21-- Size/resize: use `purego-cef2gtk` size observer/size APIs plus small Dumber browser lifecycle callbacks.
22-- Software paint/PBO tests: delete or move responsibility to `purego-cef2gtk` tests.
23-
24-### `inputBridge`
25-
26-- Defined in `internal/infrastructure/cef/input_bridge.go`.
27-- Created and attached in `factory.go`.
28-- Host updated/cleared in `webview_handlers.go`.
29-- Focus queried in loading/focus paths.
30-- Middle-click link behavior wired in `factory.go` via `OnLinkMiddleClick` and cached hover URI.
31-
32-Classification:
33-
34-- Mouse/key/scroll/IME/paste/focus forwarding: replace with `purego-cef2gtk.View.AttachInput`, `SetInputHost`, and `DetachInput`.
35-- Host attach/update/detach: call through Dumber GTK scheduling path.
36-- Focus state: use bridge focus helper on GTK thread.
37-- Middle-click link open: preserve through `purego-cef2gtk.InputOptions.OnMiddleClick`.
38-- Old input translation tests: delete from Dumber or replace with lifecycle/callback wiring tests.
39-
40-### `glLoader`
41-
42-- Defined in `internal/infrastructure/cef/gl_loader.go`.
43-- Owned by `Engine.gl` and created in `engine_init.go`.
44-- Passed to factory and consumed by `renderPipeline`.
45-
46-Classification: delete from Dumber. GL/EGL loading belongs to `purego-cef2gtk` internals.
47-
48-### `resizeReconciler`
49-
50-- Defined in `internal/infrastructure/cef/resize_reconciler.go`.
51-- Owned by `WebView.resizeReconciler`.
52-- Started from resize callbacks and paint completion callbacks.
53-
54-Classification:
55-
56-- Paint-based retry/diagnostics: obsolete after deleting Dumber software paint pipeline.
57-- Browser resize notification: keep only a small Dumber lifecycle path that calls CEF host `WasResized`/`Invalidate` from bridge size events.
58-
59-## Non-obvious callouts
60-
61-- Load watchdog diagnostics in `webview.go` currently log `renderPipelineSnapshot`; replace with bridge diagnostics, accepting less Dumber-specific render detail unless a real product need appears.
62-- Cursor updates in `webview_handlers.go` currently call `wv.pipeline.glArea.SetCursorFromName`; replace with `Cef2gtkAdapter.SetCursorFromName` on GTK thread.
63-- Loading/focus checks currently use `wv.input.hasGTKFocus`; replace with bridge focus helper on GTK thread.
64-- Browser close currently clears old input host directly; replace with GTK-thread `SetInputHost(nil)` or `DetachInput`.
65-- Begin-frame loop currently uses `pipeline.glArea`; replace with adapter `GLArea` if external begin frame remains enabled.
66-- `NativeWidget` currently returns old GLArea pointer; replace with adapter native widget pointer.
67-- Popup/select handling needs runtime verification. `purego-cef2gtk` currently treats CPU `OnPaint` as diagnostic-only and popup callbacks as no-op.
68-- Text selection updates Dumber state and should be preserved through render-handler hook/composition while paint handling delegates to the bridge.
69-
70-## Upstream bridge gaps addressed during Phase 2
71-
72-`purego-cef2gtk` now exposes or implements:
73-
74-- real size access and size observers;
75-- device scale factor access;
76-- `GetScreenInfo` based on bridge size/scale;
77-- text-selection render hook;
78-- middle-click input hook;
79-- focus and cursor helpers.
80-
81-Remaining runtime risk: popup/select behavior should be smoke-tested after Dumber is fully migrated.
@@ -1,81 +0,0 @@
1-# purego-cef2gtk migration audit
2-
3-Date: 2026-04-29
4-Branch: `feature/use-purego-cef2gtk`
5-
6-This audit records the old Dumber CEF/GTK rendering/input stack references that must be replaced before the old files are deleted.
7-
8-## Old-stack references
9-
10-### `renderPipeline`
11-
12-- Defined in `internal/infrastructure/cef/render_pipeline.go`.
13-- Owned by `WebView.pipeline` in `webview.go`.
14-- Created in `factory.go`.
15-- Used for view rect/screen info, popup paint, CPU `OnPaint`, diagnostics, cursor updates, begin-frame `GtkGLArea`, native widget, and resize callbacks.
16-
17-Classification:
18-
19-- Rendering/GL upload: replace with `purego-cef2gtk.View.RenderHandler` and accelerated DMABUF path.
20-- Widget/GLArea access: replace with the thin Dumber `Cef2gtkAdapter`.
21-- Size/resize: use `purego-cef2gtk` size observer/size APIs plus small Dumber browser lifecycle callbacks.
22-- Software paint/PBO tests: delete or move responsibility to `purego-cef2gtk` tests.
23-
24-### `inputBridge`
25-
26-- Defined in `internal/infrastructure/cef/input_bridge.go`.
27-- Created and attached in `factory.go`.
28-- Host updated/cleared in `webview_handlers.go`.
29-- Focus queried in loading/focus paths.
30-- Middle-click link behavior wired in `factory.go` via `OnLinkMiddleClick` and cached hover URI.
31-
32-Classification:
33-
34-- Mouse/key/scroll/IME/paste/focus forwarding: replace with `purego-cef2gtk.View.AttachInput`, `SetInputHost`, and `DetachInput`.
35-- Host attach/update/detach: call through Dumber GTK scheduling path.
36-- Focus state: use bridge focus helper on GTK thread.
37-- Middle-click link open: preserve through `purego-cef2gtk.InputOptions.OnMiddleClick`.
38-- Old input translation tests: delete from Dumber or replace with lifecycle/callback wiring tests.
39-
40-### `glLoader`
41-
42-- Defined in `internal/infrastructure/cef/gl_loader.go`.
43-- Owned by `Engine.gl` and created in `engine_init.go`.
44-- Passed to factory and consumed by `renderPipeline`.
45-
46-Classification: delete from Dumber. GL/EGL loading belongs to `purego-cef2gtk` internals.
47-
48-### `resizeReconciler`
49-
50-- Defined in `internal/infrastructure/cef/resize_reconciler.go`.
51-- Owned by `WebView.resizeReconciler`.
52-- Started from resize callbacks and paint completion callbacks.
53-
54-Classification:
55-
56-- Paint-based retry/diagnostics: obsolete after deleting Dumber software paint pipeline.
57-- Browser resize notification: keep only a small Dumber lifecycle path that calls CEF host `WasResized`/`Invalidate` from bridge size events.
58-
59-## Non-obvious callouts
60-
61-- Load watchdog diagnostics in `webview.go` currently log `renderPipelineSnapshot`; replace with bridge diagnostics, accepting less Dumber-specific render detail unless a real product need appears.
62-- Cursor updates in `webview_handlers.go` currently call `wv.pipeline.glArea.SetCursorFromName`; replace with `Cef2gtkAdapter.SetCursorFromName` on GTK thread.
63-- Loading/focus checks currently use `wv.input.hasGTKFocus`; replace with bridge focus helper on GTK thread.
64-- Browser close currently clears old input host directly; replace with GTK-thread `SetInputHost(nil)` or `DetachInput`.
65-- Begin-frame loop currently uses `pipeline.glArea`; replace with adapter `GLArea` if external begin frame remains enabled.
66-- `NativeWidget` currently returns old GLArea pointer; replace with adapter native widget pointer.
67-- Popup/select handling needs runtime verification. `purego-cef2gtk` currently treats CPU `OnPaint` as diagnostic-only and popup callbacks as no-op.
68-- Text selection updates Dumber state and should be preserved through render-handler hook/composition while paint handling delegates to the bridge.
69-
70-## Upstream bridge gaps addressed during Phase 2
71-
72-`purego-cef2gtk` now exposes or implements:
73-
74-- real size access and size observers;
75-- device scale factor access;
76-- `GetScreenInfo` based on bridge size/scale;
77-- text-selection render hook;
78-- middle-click input hook;
79-- focus and cursor helpers.
80-
81-Remaining runtime risk: popup/select behavior should be smoke-tested after Dumber is fully migrated.
M internal/infrastructure/cef/context_menu.go +14 -2
@@ -8,11 +8,17 @@ import (
88 purecef "github.com/bnema/purego-cef/cef"
99
1010 "github.com/bnema/dumber/internal/application/port"
11- "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
1211 "github.com/bnema/dumber/internal/logging"
1312 "github.com/bnema/puregotk/v4/gtk"
1413 )
1514
15+// ContextMenuRenderer renders normalized context menu items for the CEF adapter.
16+// It is implemented by the GTK menu adapter and injected by bootstrap so this
17+// adapter does not depend on a sibling infrastructure adapter.
18+type ContextMenuRenderer interface {
19+ Show(items []port.MenuItem, anchor *gtk.Widget, x, y int32, onSelect func(port.MenuItem), onClose func())
20+}
21+
1622 // ===========================================================================
1723 // ContextMenuHandler (implemented on handlerSet)
1824 // ===========================================================================
@@ -20,6 +26,7 @@ import (
2026 func (h *handlerSet) OnBeforeContextMenu(_ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams, _ purecef.MenuModel) {
2127 }
2228
29+//nolint:gocyclo // CEF callback coordinates menu extraction, rendering, and native fallback in one synchronous call.
2330 func (h *handlerSet) RunContextMenu(
2431 _ purecef.Browser, _ purecef.Frame,
2532 params purecef.ContextMenuParams, model purecef.MenuModel,
@@ -82,7 +89,12 @@ func (h *handlerSet) RunContextMenu(
8289 }
8390 widget := wv.viewBridge.Widget()
8491 logContextMenuPopupRequest(h, widget, params, x, y)
85- gtkmenu.NewRenderer(nil).Show(
92+ renderer := wv.engine.contextMenuRenderer()
93+ if renderer == nil {
94+ callback.Cancel()
95+ return
96+ }
97+ renderer.Show(
8698 items,
8799 widget,
88100 x,
@@ -8,11 +8,17 @@ import (
8 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11- "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
12 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/puregotk/v4/gtk"12 "github.com/bnema/puregotk/v4/gtk"
14 )13 )
15 14
15+// ContextMenuRenderer renders normalized context menu items for the CEF adapter.
16+// It is implemented by the GTK menu adapter and injected by bootstrap so this
17+// adapter does not depend on a sibling infrastructure adapter.
18+type ContextMenuRenderer interface {
19+ Show(items []port.MenuItem, anchor *gtk.Widget, x, y int32, onSelect func(port.MenuItem), onClose func())
20+}
21+
16 // ===========================================================================22 // ===========================================================================
17 // ContextMenuHandler (implemented on handlerSet)23 // ContextMenuHandler (implemented on handlerSet)
18 // ===========================================================================24 // ===========================================================================
@@ -20,6 +26,7 @@ import (
20 func (h *handlerSet) OnBeforeContextMenu(_ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams, _ purecef.MenuModel) {26 func (h *handlerSet) OnBeforeContextMenu(_ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams, _ purecef.MenuModel) {
21 }27 }
22 28
29+//nolint:gocyclo // CEF callback coordinates menu extraction, rendering, and native fallback in one synchronous call.
23 func (h *handlerSet) RunContextMenu(30 func (h *handlerSet) RunContextMenu(
24 _ purecef.Browser, _ purecef.Frame,31 _ purecef.Browser, _ purecef.Frame,
25 params purecef.ContextMenuParams, model purecef.MenuModel,32 params purecef.ContextMenuParams, model purecef.MenuModel,
@@ -82,7 +89,12 @@ func (h *handlerSet) RunContextMenu(
82 }89 }
83 widget := wv.viewBridge.Widget()90 widget := wv.viewBridge.Widget()
84 logContextMenuPopupRequest(h, widget, params, x, y)91 logContextMenuPopupRequest(h, widget, params, x, y)
85- gtkmenu.NewRenderer(nil).Show(92+ renderer := wv.engine.contextMenuRenderer()
93+ if renderer == nil {
94+ callback.Cancel()
95+ return
96+ }
97+ renderer.Show(
86 items,98 items,
87 widget,99 widget,
88 x,100 x,
M internal/infrastructure/cef/engine.go +8 -0
@@ -40,6 +40,7 @@ type Engine struct {
4040 defaultConfigPayload func() ([]byte, error)
4141 ctxMenuBuilder port.ContextMenuBuilder
4242 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
43+ ctxMenuRenderer ContextMenuRenderer
4344 clipboard port.Clipboard
4445 clipboardTextOrchestrator port.ClipboardTextOrchestrator
4546 onClipboardCopied func(textLen int)
@@ -83,6 +84,13 @@ func (e *Engine) Factory() port.WebViewFactory {
8384 return &webViewFactoryAdapter{factory: e.factory}
8485 }
8586
87+func (e *Engine) contextMenuRenderer() ContextMenuRenderer {
88+ if e == nil {
89+ return nil
90+ }
91+ return e.ctxMenuRenderer
92+}
93+
8694 func (e *Engine) Pool() port.WebViewPool {
8795 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
8896 }
@@ -40,6 +40,7 @@ type Engine struct {
40 defaultConfigPayload func() ([]byte, error)40 defaultConfigPayload func() ([]byte, error)
41 ctxMenuBuilder port.ContextMenuBuilder41 ctxMenuBuilder port.ContextMenuBuilder
42 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory42 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
43+ ctxMenuRenderer ContextMenuRenderer
43 clipboard port.Clipboard44 clipboard port.Clipboard
44 clipboardTextOrchestrator port.ClipboardTextOrchestrator45 clipboardTextOrchestrator port.ClipboardTextOrchestrator
45 onClipboardCopied func(textLen int)46 onClipboardCopied func(textLen int)
@@ -83,6 +84,13 @@ func (e *Engine) Factory() port.WebViewFactory {
83 return &webViewFactoryAdapter{factory: e.factory}84 return &webViewFactoryAdapter{factory: e.factory}
84 }85 }
85 86
87+func (e *Engine) contextMenuRenderer() ContextMenuRenderer {
88+ if e == nil {
89+ return nil
90+ }
91+ return e.ctxMenuRenderer
92+}
93+
86 func (e *Engine) Pool() port.WebViewPool {94 func (e *Engine) Pool() port.WebViewPool {
87 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}95 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
88 }96 }
M internal/infrastructure/cef/engine_init.go +10 -2
@@ -17,12 +17,12 @@ import (
1717
1818 "github.com/bnema/dumber/assets"
1919 "github.com/bnema/dumber/internal/application/port"
20- "github.com/bnema/dumber/internal/infrastructure/config"
2120 "github.com/bnema/dumber/internal/logging"
2221 )
2322
2423 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
2524 const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
25+const defaultCEFWindowlessFrameRate = 60
2626
2727 // RuntimePaths contains the concrete filesystem paths the CEF adapter needs.
2828 type RuntimePaths struct {
@@ -43,7 +43,7 @@ func NewEngine(
4343 logger := logging.FromContext(ctx)
4444 stateRoot := resolvedStateRoot(paths.StateRoot, opts)
4545 cleanStaleSingletonLocks(logger, stateRoot)
46- windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
46+ windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate)
4747
4848 settings, err := prepareCEFSettings(opts, paths, cfg, logger)
4949 if err != nil {
@@ -65,6 +65,7 @@ func NewEngine(
6565 defaultConfigPayload: deps.DefaultConfigPayload,
6666 ctxMenuBuilder: deps.ContextMenuBuilder,
6767 ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,
68+ ctxMenuRenderer: deps.ContextMenuRenderer,
6869 clipboard: deps.Clipboard,
6970 resolver: deps.ImageDataResolver,
7071 }
@@ -93,6 +94,13 @@ func NewEngine(
9394 )
9495 }
9596
97+func normalizedWindowlessFrameRate(frameRate int32) int32 {
98+ if frameRate > 0 {
99+ return frameRate
100+ }
101+ return defaultCEFWindowlessFrameRate
102+}
103+
96104 func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {
97105 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
98106 return root
@@ -17,12 +17,12 @@ import (
17 17
18 "github.com/bnema/dumber/assets"18 "github.com/bnema/dumber/assets"
19 "github.com/bnema/dumber/internal/application/port"19 "github.com/bnema/dumber/internal/application/port"
20- "github.com/bnema/dumber/internal/infrastructure/config"
21 "github.com/bnema/dumber/internal/logging"20 "github.com/bnema/dumber/internal/logging"
22 )21 )
23 22
24 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"23 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
25 const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"24 const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
25+const defaultCEFWindowlessFrameRate = 60
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 {
@@ -43,7 +43,7 @@ func NewEngine(
43 logger := logging.FromContext(ctx)43 logger := logging.FromContext(ctx)
44 stateRoot := resolvedStateRoot(paths.StateRoot, opts)44 stateRoot := resolvedStateRoot(paths.StateRoot, opts)
45 cleanStaleSingletonLocks(logger, stateRoot)45 cleanStaleSingletonLocks(logger, stateRoot)
46- windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()46+ windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate)
47 47
48 settings, err := prepareCEFSettings(opts, paths, cfg, logger)48 settings, err := prepareCEFSettings(opts, paths, cfg, logger)
49 if err != nil {49 if err != nil {
@@ -65,6 +65,7 @@ func NewEngine(
65 defaultConfigPayload: deps.DefaultConfigPayload,65 defaultConfigPayload: deps.DefaultConfigPayload,
66 ctxMenuBuilder: deps.ContextMenuBuilder,66 ctxMenuBuilder: deps.ContextMenuBuilder,
67 ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,67 ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,
68+ ctxMenuRenderer: deps.ContextMenuRenderer,
68 clipboard: deps.Clipboard,69 clipboard: deps.Clipboard,
69 resolver: deps.ImageDataResolver,70 resolver: deps.ImageDataResolver,
70 }71 }
@@ -93,6 +94,13 @@ func NewEngine(
93 )94 )
94 }95 }
95 96
97+func normalizedWindowlessFrameRate(frameRate int32) int32 {
98+ if frameRate > 0 {
99+ return frameRate
100+ }
101+ return defaultCEFWindowlessFrameRate
102+}
103+
96 func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {104 func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {
97 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {105 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
98 return root106 return root
M internal/infrastructure/cef/process_diagnostics.go +88 -27
@@ -17,18 +17,29 @@ import (
1717 )
1818
1919 const (
20- maxLoggedThreadsPerProcess = 8
21- maxDetailedThreadsPerProcess = 48
22- renderStallBacktraceCooldown = 30 * time.Second
23- renderStallBacktraceCommandLimit = 64 * 1024
20+ procRoot = "/proc"
21+ maxLoggedThreadsPerProcess = 8
22+ maxDetailedThreadsPerProcess = 48
23+ threadStatUTimeIndex = 11
24+ threadStatSTimeIndex = 12
25+ maxKernelStackLines = 6
26+ renderStallProcessDiagnosticsCooldown = 30 * time.Second
27+ renderStallBacktraceCooldown = 30 * time.Second
28+ renderStallBacktraceCommandLimit = 64 * 1024
2429 )
2530
26-var renderStallBacktraceLastUnixNS atomic.Int64
31+var (
32+ renderStallProcessDiagnosticsLastUnixNS atomic.Int64
33+ renderStallBacktraceLastUnixNS atomic.Int64
34+)
2735
2836 func (wv *WebView) logCEFProcessDiagnostics(reason string, classification renderStallClassification) {
2937 if wv == nil || wv.ctx == nil {
3038 return
3139 }
40+ if !reserveRenderStallProcessDiagnostics(time.Now()) {
41+ return
42+ }
3243 pids := append([]int{os.Getpid()}, childPIDs(os.Getpid())...)
3344 for _, pid := range pids {
3445 cmdline := procCmdline(pid)
@@ -45,7 +56,7 @@ func (wv *WebView) logCEFProcessDiagnostics(reason string, classification render
4556 Bool("cef_io_alive", classification.CEFIOAlive).
4657 Int("pid", pid).
4758 Str("process_type", processType).
48- Str("cmdline", logging.TruncateURL(cmdline, 1024)).
59+ Strs("cmdline_flags", safeChromiumCmdlineFlags(cmdline)).
4960 Int("thread_count", procThreadCount(pid)).
5061 Strs("hot_threads", threads).
5162 Strs("detailed_threads", detailedThreads).
@@ -57,11 +68,61 @@ func (wv *WebView) logCEFProcessDiagnostics(reason string, classification render
5768 }
5869 }
5970
71+func reserveRenderStallProcessDiagnostics(now time.Time) bool {
72+ nowNS := now.UnixNano()
73+ for {
74+ lastNS := renderStallProcessDiagnosticsLastUnixNS.Load()
75+ last := unixNSTime(lastNS)
76+ if !last.IsZero() && now.Sub(last) < renderStallProcessDiagnosticsCooldown {
77+ return false
78+ }
79+ if renderStallProcessDiagnosticsLastUnixNS.CompareAndSwap(lastNS, nowNS) {
80+ return true
81+ }
82+ }
83+}
84+
85+func safeChromiumCmdlineFlags(cmdline string) []string {
86+ fields := strings.Fields(cmdline)
87+ flags := make([]string, 0, len(fields))
88+ seen := make(map[string]struct{})
89+ for _, field := range fields {
90+ if !strings.HasPrefix(field, "--") {
91+ continue
92+ }
93+ name, value, hasValue := strings.Cut(field, "=")
94+ var safe string
95+ switch name {
96+ case "--type", "--lang", "--enable-features", "--disable-features", "--ozone-platform",
97+ "--use-gl", "--use-angle", "--enable-logging", "--log-severity":
98+ if hasValue && value != "" {
99+ safe = name + "=" + value
100+ } else {
101+ safe = name
102+ }
103+ case "--enable-gpu-rasterization", "--disable-gpu", "--disable-software-rasterizer", "--in-process-gpu", "--no-sandbox", "--no-zygote":
104+ safe = name
105+ default:
106+ continue
107+ }
108+ if _, ok := seen[safe]; ok {
109+ continue
110+ }
111+ seen[safe] = struct{}{}
112+ flags = append(flags, safe)
113+ }
114+ return flags
115+}
116+
117+func procPath(parts ...string) string {
118+ return filepath.Join(append([]string{procRoot}, parts...)...)
119+}
120+
60121 func childPIDs(parent int) []int {
61122 if out := childPIDsFromProcChildren(parent); len(out) > 0 {
62123 return out
63124 }
64- entries, err := os.ReadDir("/proc")
125+ entries, err := os.ReadDir(procRoot)
65126 if err != nil {
66127 return nil
67128 }
@@ -83,7 +144,7 @@ func childPIDs(parent int) []int {
83144 }
84145
85146 func childPIDsFromProcChildren(parent int) []int {
86- path := filepath.Join("/proc", strconv.Itoa(parent), "task", strconv.Itoa(parent), "children")
147+ path := procPath(strconv.Itoa(parent), "task", strconv.Itoa(parent), "children")
87148 b, err := os.ReadFile(path)
88149 if err != nil {
89150 return nil
@@ -102,7 +163,7 @@ func childPIDsFromProcChildren(parent int) []int {
102163 }
103164
104165 func procPPID(pid int) int {
105- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))
166+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "status"))
106167 if err != nil {
107168 return 0
108169 }
@@ -120,7 +181,7 @@ func procPPID(pid int) int {
120181 }
121182
122183 func procCmdline(pid int) string {
123- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
184+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "cmdline"))
124185 if err != nil {
125186 return ""
126187 }
@@ -137,7 +198,7 @@ func chromiumProcessType(cmdline string) string {
137198 }
138199
139200 func procThreadCount(pid int) int {
140- entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "task"))
201+ entries, err := os.ReadDir(procPath(strconv.Itoa(pid), "task"))
141202 if err != nil {
142203 return 0
143204 }
@@ -158,7 +219,7 @@ func procDetailedThreadSummaries(pid, limit int) []string {
158219 }
159220
160221 func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string {
161- entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "task"))
222+ entries, err := os.ReadDir(procPath(strconv.Itoa(pid), "task"))
162223 if err != nil {
163224 return nil
164225 }
@@ -168,7 +229,7 @@ func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string {
168229 if err != nil {
169230 continue
170231 }
171- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "stat"))
232+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "task", strconv.Itoa(tid), "stat"))
172233 if err != nil {
173234 continue
174235 }
@@ -194,27 +255,27 @@ func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string {
194255
195256 func parseThreadStat(tid int, stat string) (string, uint64) {
196257 open := strings.IndexByte(stat, '(')
197- close := strings.LastIndexByte(stat, ')')
198- if open < 0 || close <= open || close+2 >= len(stat) {
258+ closeIdx := strings.LastIndexByte(stat, ')')
259+ if open < 0 || closeIdx <= open || closeIdx+2 >= len(stat) {
199260 return strconv.Itoa(tid), 0
200261 }
201- comm := stat[open+1 : close]
202- rest := strings.Fields(stat[close+2:])
262+ comm := stat[open+1 : closeIdx]
263+ rest := strings.Fields(stat[closeIdx+2:])
203264 state := "?"
204265 if len(rest) > 0 {
205266 state = rest[0]
206267 }
207268 var ticks uint64
208- if len(rest) > 12 {
209- utime, _ := strconv.ParseUint(rest[11], 10, 64)
210- stime, _ := strconv.ParseUint(rest[12], 10, 64)
269+ if len(rest) > threadStatSTimeIndex {
270+ utime, _ := strconv.ParseUint(rest[threadStatUTimeIndex], 10, 64)
271+ stime, _ := strconv.ParseUint(rest[threadStatSTimeIndex], 10, 64)
211272 ticks = utime + stime
212273 }
213274 return "tid=" + strconv.Itoa(tid) + " state=" + state + " ticks=" + strconv.FormatUint(ticks, 10) + " comm=" + comm, ticks
214275 }
215276
216277 func procThreadWchan(pid, tid int) string {
217- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "wchan"))
278+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "task", strconv.Itoa(tid), "wchan"))
218279 if err != nil {
219280 return ""
220281 }
@@ -222,13 +283,13 @@ func procThreadWchan(pid, tid int) string {
222283 }
223284
224285 func procThreadKernelStack(pid, tid int) string {
225- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "stack"))
286+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "task", strconv.Itoa(tid), "stack"))
226287 if err != nil {
227288 return ""
228289 }
229290 lines := strings.Split(strings.TrimSpace(string(b)), "\n")
230- if len(lines) > 6 {
231- lines = lines[:6]
291+ if len(lines) > maxKernelStackLines {
292+ lines = lines[:maxKernelStackLines]
232293 }
233294 return strings.Join(lines, " | ")
234295 }
@@ -260,7 +321,7 @@ func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classificatio
260321 Msg("cef: render stall native backtrace skipped; gdb not available")
261322 return
262323 }
263- if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() {
324+ if wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() {
264325 return
265326 }
266327 logCtx := wv.ctx
@@ -290,13 +351,13 @@ func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classificatio
290351 }
291352
292353 func gpuFDInfoSummaries(pid int) []string {
293- entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "fdinfo"))
354+ entries, err := os.ReadDir(procPath(strconv.Itoa(pid), "fdinfo"))
294355 if err != nil {
295356 return nil
296357 }
297358 var out []string
298359 for _, entry := range entries {
299- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "fdinfo", entry.Name()))
360+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "fdinfo", entry.Name()))
300361 if err != nil {
301362 continue
302363 }
@@ -17,18 +17,29 @@ import (
17 )17 )
18 18
19 const (19 const (
20- maxLoggedThreadsPerProcess = 820+ procRoot = "/proc"
21- maxDetailedThreadsPerProcess = 4821+ maxLoggedThreadsPerProcess = 8
22- renderStallBacktraceCooldown = 30 * time.Second22+ maxDetailedThreadsPerProcess = 48
23- renderStallBacktraceCommandLimit = 64 * 102423+ threadStatUTimeIndex = 11
24+ threadStatSTimeIndex = 12
25+ maxKernelStackLines = 6
26+ renderStallProcessDiagnosticsCooldown = 30 * time.Second
27+ renderStallBacktraceCooldown = 30 * time.Second
28+ renderStallBacktraceCommandLimit = 64 * 1024
24 )29 )
25 30
26-var renderStallBacktraceLastUnixNS atomic.Int6431+var (
32+ renderStallProcessDiagnosticsLastUnixNS atomic.Int64
33+ renderStallBacktraceLastUnixNS atomic.Int64
34+)
27 35
28 func (wv *WebView) logCEFProcessDiagnostics(reason string, classification renderStallClassification) {36 func (wv *WebView) logCEFProcessDiagnostics(reason string, classification renderStallClassification) {
29 if wv == nil || wv.ctx == nil {37 if wv == nil || wv.ctx == nil {
30 return38 return
31 }39 }
40+ if !reserveRenderStallProcessDiagnostics(time.Now()) {
41+ return
42+ }
32 pids := append([]int{os.Getpid()}, childPIDs(os.Getpid())...)43 pids := append([]int{os.Getpid()}, childPIDs(os.Getpid())...)
33 for _, pid := range pids {44 for _, pid := range pids {
34 cmdline := procCmdline(pid)45 cmdline := procCmdline(pid)
@@ -45,7 +56,7 @@ func (wv *WebView) logCEFProcessDiagnostics(reason string, classification render
45 Bool("cef_io_alive", classification.CEFIOAlive).56 Bool("cef_io_alive", classification.CEFIOAlive).
46 Int("pid", pid).57 Int("pid", pid).
47 Str("process_type", processType).58 Str("process_type", processType).
48- Str("cmdline", logging.TruncateURL(cmdline, 1024)).59+ Strs("cmdline_flags", safeChromiumCmdlineFlags(cmdline)).
49 Int("thread_count", procThreadCount(pid)).60 Int("thread_count", procThreadCount(pid)).
50 Strs("hot_threads", threads).61 Strs("hot_threads", threads).
51 Strs("detailed_threads", detailedThreads).62 Strs("detailed_threads", detailedThreads).
@@ -57,11 +68,61 @@ func (wv *WebView) logCEFProcessDiagnostics(reason string, classification render
57 }68 }
58 }69 }
59 70
71+func reserveRenderStallProcessDiagnostics(now time.Time) bool {
72+ nowNS := now.UnixNano()
73+ for {
74+ lastNS := renderStallProcessDiagnosticsLastUnixNS.Load()
75+ last := unixNSTime(lastNS)
76+ if !last.IsZero() && now.Sub(last) < renderStallProcessDiagnosticsCooldown {
77+ return false
78+ }
79+ if renderStallProcessDiagnosticsLastUnixNS.CompareAndSwap(lastNS, nowNS) {
80+ return true
81+ }
82+ }
83+}
84+
85+func safeChromiumCmdlineFlags(cmdline string) []string {
86+ fields := strings.Fields(cmdline)
87+ flags := make([]string, 0, len(fields))
88+ seen := make(map[string]struct{})
89+ for _, field := range fields {
90+ if !strings.HasPrefix(field, "--") {
91+ continue
92+ }
93+ name, value, hasValue := strings.Cut(field, "=")
94+ var safe string
95+ switch name {
96+ case "--type", "--lang", "--enable-features", "--disable-features", "--ozone-platform",
97+ "--use-gl", "--use-angle", "--enable-logging", "--log-severity":
98+ if hasValue && value != "" {
99+ safe = name + "=" + value
100+ } else {
101+ safe = name
102+ }
103+ case "--enable-gpu-rasterization", "--disable-gpu", "--disable-software-rasterizer", "--in-process-gpu", "--no-sandbox", "--no-zygote":
104+ safe = name
105+ default:
106+ continue
107+ }
108+ if _, ok := seen[safe]; ok {
109+ continue
110+ }
111+ seen[safe] = struct{}{}
112+ flags = append(flags, safe)
113+ }
114+ return flags
115+}
116+
117+func procPath(parts ...string) string {
118+ return filepath.Join(append([]string{procRoot}, parts...)...)
119+}
120+
60 func childPIDs(parent int) []int {121 func childPIDs(parent int) []int {
61 if out := childPIDsFromProcChildren(parent); len(out) > 0 {122 if out := childPIDsFromProcChildren(parent); len(out) > 0 {
62 return out123 return out
63 }124 }
64- entries, err := os.ReadDir("/proc")125+ entries, err := os.ReadDir(procRoot)
65 if err != nil {126 if err != nil {
66 return nil127 return nil
67 }128 }
@@ -83,7 +144,7 @@ func childPIDs(parent int) []int {
83 }144 }
84 145
85 func childPIDsFromProcChildren(parent int) []int {146 func childPIDsFromProcChildren(parent int) []int {
86- path := filepath.Join("/proc", strconv.Itoa(parent), "task", strconv.Itoa(parent), "children")147+ path := procPath(strconv.Itoa(parent), "task", strconv.Itoa(parent), "children")
87 b, err := os.ReadFile(path)148 b, err := os.ReadFile(path)
88 if err != nil {149 if err != nil {
89 return nil150 return nil
@@ -102,7 +163,7 @@ func childPIDsFromProcChildren(parent int) []int {
102 }163 }
103 164
104 func procPPID(pid int) int {165 func procPPID(pid int) int {
105- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))166+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "status"))
106 if err != nil {167 if err != nil {
107 return 0168 return 0
108 }169 }
@@ -120,7 +181,7 @@ func procPPID(pid int) int {
120 }181 }
121 182
122 func procCmdline(pid int) string {183 func procCmdline(pid int) string {
123- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))184+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "cmdline"))
124 if err != nil {185 if err != nil {
125 return ""186 return ""
126 }187 }
@@ -137,7 +198,7 @@ func chromiumProcessType(cmdline string) string {
137 }198 }
138 199
139 func procThreadCount(pid int) int {200 func procThreadCount(pid int) int {
140- entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "task"))201+ entries, err := os.ReadDir(procPath(strconv.Itoa(pid), "task"))
141 if err != nil {202 if err != nil {
142 return 0203 return 0
143 }204 }
@@ -158,7 +219,7 @@ func procDetailedThreadSummaries(pid, limit int) []string {
158 }219 }
159 220
160 func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string {221 func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string {
161- entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "task"))222+ entries, err := os.ReadDir(procPath(strconv.Itoa(pid), "task"))
162 if err != nil {223 if err != nil {
163 return nil224 return nil
164 }225 }
@@ -168,7 +229,7 @@ func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string {
168 if err != nil {229 if err != nil {
169 continue230 continue
170 }231 }
171- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "stat"))232+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "task", strconv.Itoa(tid), "stat"))
172 if err != nil {233 if err != nil {
173 continue234 continue
174 }235 }
@@ -194,27 +255,27 @@ func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string {
194 255
195 func parseThreadStat(tid int, stat string) (string, uint64) {256 func parseThreadStat(tid int, stat string) (string, uint64) {
196 open := strings.IndexByte(stat, '(')257 open := strings.IndexByte(stat, '(')
197- close := strings.LastIndexByte(stat, ')')258+ closeIdx := strings.LastIndexByte(stat, ')')
198- if open < 0 || close <= open || close+2 >= len(stat) {259+ if open < 0 || closeIdx <= open || closeIdx+2 >= len(stat) {
199 return strconv.Itoa(tid), 0260 return strconv.Itoa(tid), 0
200 }261 }
201- comm := stat[open+1 : close]262+ comm := stat[open+1 : closeIdx]
202- rest := strings.Fields(stat[close+2:])263+ rest := strings.Fields(stat[closeIdx+2:])
203 state := "?"264 state := "?"
204 if len(rest) > 0 {265 if len(rest) > 0 {
205 state = rest[0]266 state = rest[0]
206 }267 }
207 var ticks uint64268 var ticks uint64
208- if len(rest) > 12 {269+ if len(rest) > threadStatSTimeIndex {
209- utime, _ := strconv.ParseUint(rest[11], 10, 64)270+ utime, _ := strconv.ParseUint(rest[threadStatUTimeIndex], 10, 64)
210- stime, _ := strconv.ParseUint(rest[12], 10, 64)271+ stime, _ := strconv.ParseUint(rest[threadStatSTimeIndex], 10, 64)
211 ticks = utime + stime272 ticks = utime + stime
212 }273 }
213 return "tid=" + strconv.Itoa(tid) + " state=" + state + " ticks=" + strconv.FormatUint(ticks, 10) + " comm=" + comm, ticks274 return "tid=" + strconv.Itoa(tid) + " state=" + state + " ticks=" + strconv.FormatUint(ticks, 10) + " comm=" + comm, ticks
214 }275 }
215 276
216 func procThreadWchan(pid, tid int) string {277 func procThreadWchan(pid, tid int) string {
217- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "wchan"))278+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "task", strconv.Itoa(tid), "wchan"))
218 if err != nil {279 if err != nil {
219 return ""280 return ""
220 }281 }
@@ -222,13 +283,13 @@ func procThreadWchan(pid, tid int) string {
222 }283 }
223 284
224 func procThreadKernelStack(pid, tid int) string {285 func procThreadKernelStack(pid, tid int) string {
225- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "task", strconv.Itoa(tid), "stack"))286+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "task", strconv.Itoa(tid), "stack"))
226 if err != nil {287 if err != nil {
227 return ""288 return ""
228 }289 }
229 lines := strings.Split(strings.TrimSpace(string(b)), "\n")290 lines := strings.Split(strings.TrimSpace(string(b)), "\n")
230- if len(lines) > 6 {291+ if len(lines) > maxKernelStackLines {
231- lines = lines[:6]292+ lines = lines[:maxKernelStackLines]
232 }293 }
233 return strings.Join(lines, " | ")294 return strings.Join(lines, " | ")
234 }295 }
@@ -260,7 +321,7 @@ func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classificatio
260 Msg("cef: render stall native backtrace skipped; gdb not available")321 Msg("cef: render stall native backtrace skipped; gdb not available")
261 return322 return
262 }323 }
263- if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() {324+ if wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() {
264 return325 return
265 }326 }
266 logCtx := wv.ctx327 logCtx := wv.ctx
@@ -290,13 +351,13 @@ func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classificatio
290 }351 }
291 352
292 func gpuFDInfoSummaries(pid int) []string {353 func gpuFDInfoSummaries(pid int) []string {
293- entries, err := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "fdinfo"))354+ entries, err := os.ReadDir(procPath(strconv.Itoa(pid), "fdinfo"))
294 if err != nil {355 if err != nil {
295 return nil356 return nil
296 }357 }
297 var out []string358 var out []string
298 for _, entry := range entries {359 for _, entry := range entries {
299- b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "fdinfo", entry.Name()))360+ b, err := os.ReadFile(procPath(strconv.Itoa(pid), "fdinfo", entry.Name()))
300 if err != nil {361 if err != nil {
301 continue362 continue
302 }363 }
A internal/infrastructure/cef/process_diagnostics_test.go +33 -0
@@ -0,0 +1,33 @@
1+package cef
2+
3+import (
4+ "reflect"
5+ "testing"
6+ "time"
7+)
8+
9+func TestSafeChromiumCmdlineFlagsOmitsPathBearingAndUnknownFlags(t *testing.T) {
10+ cmdline := "/tmp/dumber --type=gpu-process --user-data-dir=/home/user/.config/dumber --use-gl=angle --password=secret --no-sandbox"
11+
12+ got := safeChromiumCmdlineFlags(cmdline)
13+ want := []string{"--type=gpu-process", "--use-gl=angle", "--no-sandbox"}
14+
15+ if !reflect.DeepEqual(got, want) {
16+ t.Fatalf("safe flags = %#v, want %#v", got, want)
17+ }
18+}
19+
20+func TestReserveRenderStallProcessDiagnosticsAppliesCooldown(t *testing.T) {
21+ renderStallProcessDiagnosticsLastUnixNS.Store(0)
22+ now := time.Unix(100, 0)
23+
24+ if !reserveRenderStallProcessDiagnostics(now) {
25+ t.Fatal("first process diagnostic reservation failed")
26+ }
27+ if reserveRenderStallProcessDiagnostics(now.Add(renderStallProcessDiagnosticsCooldown / 2)) {
28+ t.Fatal("process diagnostic reservation inside cooldown succeeded")
29+ }
30+ if !reserveRenderStallProcessDiagnostics(now.Add(renderStallProcessDiagnosticsCooldown)) {
31+ t.Fatal("process diagnostic reservation at cooldown boundary failed")
32+ }
33+}
@@ -0,0 +1,33 @@
1+package cef
2+
3+import (
4+ "reflect"
5+ "testing"
6+ "time"
7+)
8+
9+func TestSafeChromiumCmdlineFlagsOmitsPathBearingAndUnknownFlags(t *testing.T) {
10+ cmdline := "/tmp/dumber --type=gpu-process --user-data-dir=/home/user/.config/dumber --use-gl=angle --password=secret --no-sandbox"
11+
12+ got := safeChromiumCmdlineFlags(cmdline)
13+ want := []string{"--type=gpu-process", "--use-gl=angle", "--no-sandbox"}
14+
15+ if !reflect.DeepEqual(got, want) {
16+ t.Fatalf("safe flags = %#v, want %#v", got, want)
17+ }
18+}
19+
20+func TestReserveRenderStallProcessDiagnosticsAppliesCooldown(t *testing.T) {
21+ renderStallProcessDiagnosticsLastUnixNS.Store(0)
22+ now := time.Unix(100, 0)
23+
24+ if !reserveRenderStallProcessDiagnostics(now) {
25+ t.Fatal("first process diagnostic reservation failed")
26+ }
27+ if reserveRenderStallProcessDiagnostics(now.Add(renderStallProcessDiagnosticsCooldown / 2)) {
28+ t.Fatal("process diagnostic reservation inside cooldown succeeded")
29+ }
30+ if !reserveRenderStallProcessDiagnostics(now.Add(renderStallProcessDiagnosticsCooldown)) {
31+ t.Fatal("process diagnostic reservation at cooldown boundary failed")
32+ }
33+}
M internal/infrastructure/cef/profile.go +7 -2
@@ -88,6 +88,11 @@ type cef2gtkProfileRecord struct {
8888 Snapshot cef2gtk.ProfileSnapshot `json:"snapshot"`
8989 }
9090
91+const (
92+ profileLogDirPerm = 0o755
93+ profileLogFilePerm = 0o644
94+)
95+
9196 func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions {
9297 if e == nil || wv == nil || !cef2gtkProfileEnabled() {
9398 return cef2gtk.ProfileOptions{}
@@ -96,11 +101,11 @@ func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions {
96101 if path == "" {
97102 return cef2gtk.ProfileOptions{}
98103 }
99- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
104+ if err := os.MkdirAll(filepath.Dir(path), profileLogDirPerm); err != nil {
100105 logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to create profile log directory")
101106 return cef2gtk.ProfileOptions{}
102107 }
103- file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
108+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, profileLogFilePerm)
104109 if err != nil {
105110 logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to open profile log")
106111 return cef2gtk.ProfileOptions{}
@@ -88,6 +88,11 @@ type cef2gtkProfileRecord struct {
88 Snapshot cef2gtk.ProfileSnapshot `json:"snapshot"`88 Snapshot cef2gtk.ProfileSnapshot `json:"snapshot"`
89 }89 }
90 90
91+const (
92+ profileLogDirPerm = 0o755
93+ profileLogFilePerm = 0o644
94+)
95+
91 func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions {96 func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions {
92 if e == nil || wv == nil || !cef2gtkProfileEnabled() {97 if e == nil || wv == nil || !cef2gtkProfileEnabled() {
93 return cef2gtk.ProfileOptions{}98 return cef2gtk.ProfileOptions{}
@@ -96,11 +101,11 @@ func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions {
96 if path == "" {101 if path == "" {
97 return cef2gtk.ProfileOptions{}102 return cef2gtk.ProfileOptions{}
98 }103 }
99- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {104+ if err := os.MkdirAll(filepath.Dir(path), profileLogDirPerm); err != nil {
100 logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to create profile log directory")105 logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to create profile log directory")
101 return cef2gtk.ProfileOptions{}106 return cef2gtk.ProfileOptions{}
102 }107 }
103- file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)108+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, profileLogFilePerm)
104 if err != nil {109 if err != nil {
105 logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to open profile log")110 logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to open profile log")
106 return cef2gtk.ProfileOptions{}111 return cef2gtk.ProfileOptions{}
M internal/infrastructure/cef/render_diagnostics.go +6 -3
@@ -12,7 +12,7 @@ import (
1212
1313 const renderStallRecoveryCooldown = 10 * time.Second
1414
15-func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, stalledID port.WebViewID, reason string, classification renderStallClassification) {
15+func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, _ port.WebViewID, reason string, classification renderStallClassification) {
1616 if e == nil {
1717 return
1818 }
@@ -33,7 +33,10 @@ func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, stalledID port.WebV
3333 isLoading := wv.isLoading
3434 pendingURI := wv.pendingURI
3535 wv.mu.RUnlock()
36- wv.logRenderDiagnosticSnapshot(now, reason, classification, wv.viewBridge, diag, lastPaintAge, browser, uri, title, isLoading, pendingURI, wv.audioPlaying.Load())
36+ wv.logRenderDiagnosticSnapshot(
37+ now, reason, classification, wv.viewBridge, diag, lastPaintAge,
38+ browser, uri, title, isLoading, pendingURI, wv.audioPlaying.Load(),
39+ )
3740 return true
3841 })
3942 }
@@ -122,7 +125,7 @@ func (wv *WebView) logRenderDiagnosticSnapshot(
122125 Msg("cef: render diagnostic snapshot")
123126 }
124127
125-func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAdapter, paintsBefore int) {
128+func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, _ *Cef2gtkAdapter, paintsBefore int) {
126129 if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallRecoveryEnabled() {
127130 return
128131 }
@@ -12,7 +12,7 @@ import (
12 12
13 const renderStallRecoveryCooldown = 10 * time.Second13 const renderStallRecoveryCooldown = 10 * time.Second
14 14
15-func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, stalledID port.WebViewID, reason string, classification renderStallClassification) {15+func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, _ port.WebViewID, reason string, classification renderStallClassification) {
16 if e == nil {16 if e == nil {
17 return17 return
18 }18 }
@@ -33,7 +33,10 @@ func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, stalledID port.WebV
33 isLoading := wv.isLoading33 isLoading := wv.isLoading
34 pendingURI := wv.pendingURI34 pendingURI := wv.pendingURI
35 wv.mu.RUnlock()35 wv.mu.RUnlock()
36- wv.logRenderDiagnosticSnapshot(now, reason, classification, wv.viewBridge, diag, lastPaintAge, browser, uri, title, isLoading, pendingURI, wv.audioPlaying.Load())36+ wv.logRenderDiagnosticSnapshot(
37+ now, reason, classification, wv.viewBridge, diag, lastPaintAge,
38+ browser, uri, title, isLoading, pendingURI, wv.audioPlaying.Load(),
39+ )
37 return true40 return true
38 })41 })
39 }42 }
@@ -122,7 +125,7 @@ func (wv *WebView) logRenderDiagnosticSnapshot(
122 Msg("cef: render diagnostic snapshot")125 Msg("cef: render diagnostic snapshot")
123 }126 }
124 127
125-func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAdapter, paintsBefore int) {128+func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, _ *Cef2gtkAdapter, paintsBefore int) {
126 if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallRecoveryEnabled() {129 if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallRecoveryEnabled() {
127 return130 return
128 }131 }
M internal/infrastructure/cef/render_stack_env.go +27 -12
@@ -6,8 +6,6 @@ import (
66 "strings"
77
88 "github.com/bnema/dumber/internal/application/port"
9- "github.com/bnema/dumber/internal/infrastructure/config"
10- renderenv "github.com/bnema/dumber/internal/infrastructure/env"
119 "github.com/bnema/dumber/internal/logging"
1210 )
1311
@@ -15,6 +13,7 @@ const (
1513 dumberRenderStackEnvVar = "DUMBER_RENDER_STACK"
1614 dumberRenderStackAllowSplitEnvVar = "DUMBER_RENDER_STACK_ALLOW_SPLIT"
1715
16+ cefEngineType = "cef"
1817 renderStackAuto = "auto"
1918 renderStackVulkanDMABUF = "vulkan-dmabuf"
2019 renderStackLegacyGL = "legacy-gl"
@@ -45,7 +44,7 @@ func ApplyDefaultRenderStackEnvironment(ctx context.Context) string {
4544 }
4645
4746 func applyDefaultRenderStackEnvironment(ctx context.Context) string {
48- stack := normalizeRenderStack(os.Getenv(dumberRenderStackEnvVar))
47+ stack := normalizeRenderStack(ctx, os.Getenv(dumberRenderStackEnvVar))
4948 switch stack {
5049 case renderStackLegacyGL:
5150 setRenderStackEnv(ctx, gskRendererEnvVar, gskRendererOpenGL)
@@ -74,26 +73,34 @@ func logRenderStackEnvironment(ctx context.Context, stack string) {
7473 Msg("cef: render stack environment configured")
7574 }
7675
76+// HardwareDecodeEnvironmentOptions carries config-derived CEF environment
77+// decisions from the composition root without making the CEF adapter import
78+// sibling configuration or environment adapters directly.
79+type HardwareDecodeEnvironmentOptions struct {
80+ EngineType string
81+ HardwareDecodingDisabled bool
82+ RenderingEnvManager port.RenderingEnvManager
83+}
84+
7785 // ApplyDefaultHardwareDecodeEnvironment maps Dumber's media config to CEF's
7886 // developer-facing VAAPI switch env var before CEF command-line callbacks run.
7987 // Existing explicit env values are preserved as low-level escape hatches.
80-func ApplyDefaultHardwareDecodeEnvironment(ctx context.Context, cfg *config.Config) {
81- if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF {
88+func ApplyDefaultHardwareDecodeEnvironment(ctx context.Context, opts HardwareDecodeEnvironmentOptions) {
89+ if opts.EngineType != cefEngineType {
8290 return
8391 }
84- if cfg.Media.HardwareDecodingMode == config.HardwareDecodingDisable {
92+ if opts.HardwareDecodingDisabled {
8593 setEnvDefault(ctx, cefEnableVAAPIEnvVar, "0")
8694 return
8795 }
8896 setEnvDefault(ctx, cefEnableVAAPIEnvVar, "1")
89- applyDefaultLIBVADriverEnvironment(ctx)
97+ applyDefaultLIBVADriverEnvironment(ctx, opts.RenderingEnvManager)
9098 }
9199
92-func applyDefaultLIBVADriverEnvironment(ctx context.Context) {
93- if strings.TrimSpace(os.Getenv("LIBVA_DRIVER_NAME")) != "" {
100+func applyDefaultLIBVADriverEnvironment(ctx context.Context, manager port.RenderingEnvManager) {
101+ if strings.TrimSpace(os.Getenv("LIBVA_DRIVER_NAME")) != "" || manager == nil {
94102 return
95103 }
96- manager := renderenv.NewManager()
97104 switch manager.DetectGPUVendor(ctx) {
98105 case port.GPUVendorAMD:
99106 setEnvDefault(ctx, "LIBVA_DRIVER_NAME", "radeonsi")
@@ -108,13 +115,21 @@ func applyDefaultLIBVADriverEnvironment(ctx context.Context) {
108115 }
109116 }
110117
111-func normalizeRenderStack(value string) string {
112- switch strings.ToLower(strings.TrimSpace(value)) {
118+func normalizeRenderStack(ctx context.Context, value string) string {
119+ normalized := strings.ToLower(strings.TrimSpace(value))
120+ switch normalized {
113121 case "", renderStackAuto, renderStackVulkanDMABUF:
114122 return renderStackVulkanDMABUF
115123 case renderStackLegacyGL:
116124 return renderStackLegacyGL
117125 default:
126+ if ctx == nil {
127+ ctx = context.Background()
128+ }
129+ logging.FromContext(ctx).Warn().
130+ Str("render_stack", value).
131+ Str("fallback", renderStackVulkanDMABUF).
132+ Msg("cef: unknown render stack, falling back to default")
118133 return renderStackVulkanDMABUF
119134 }
120135 }
@@ -6,8 +6,6 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9- "github.com/bnema/dumber/internal/infrastructure/config"
10- renderenv "github.com/bnema/dumber/internal/infrastructure/env"
11 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
12 )10 )
13 11
@@ -15,6 +13,7 @@ const (
15 dumberRenderStackEnvVar = "DUMBER_RENDER_STACK"13 dumberRenderStackEnvVar = "DUMBER_RENDER_STACK"
16 dumberRenderStackAllowSplitEnvVar = "DUMBER_RENDER_STACK_ALLOW_SPLIT"14 dumberRenderStackAllowSplitEnvVar = "DUMBER_RENDER_STACK_ALLOW_SPLIT"
17 15
16+ cefEngineType = "cef"
18 renderStackAuto = "auto"17 renderStackAuto = "auto"
19 renderStackVulkanDMABUF = "vulkan-dmabuf"18 renderStackVulkanDMABUF = "vulkan-dmabuf"
20 renderStackLegacyGL = "legacy-gl"19 renderStackLegacyGL = "legacy-gl"
@@ -45,7 +44,7 @@ func ApplyDefaultRenderStackEnvironment(ctx context.Context) string {
45 }44 }
46 45
47 func applyDefaultRenderStackEnvironment(ctx context.Context) string {46 func applyDefaultRenderStackEnvironment(ctx context.Context) string {
48- stack := normalizeRenderStack(os.Getenv(dumberRenderStackEnvVar))47+ stack := normalizeRenderStack(ctx, os.Getenv(dumberRenderStackEnvVar))
49 switch stack {48 switch stack {
50 case renderStackLegacyGL:49 case renderStackLegacyGL:
51 setRenderStackEnv(ctx, gskRendererEnvVar, gskRendererOpenGL)50 setRenderStackEnv(ctx, gskRendererEnvVar, gskRendererOpenGL)
@@ -74,26 +73,34 @@ func logRenderStackEnvironment(ctx context.Context, stack string) {
74 Msg("cef: render stack environment configured")73 Msg("cef: render stack environment configured")
75 }74 }
76 75
76+// HardwareDecodeEnvironmentOptions carries config-derived CEF environment
77+// decisions from the composition root without making the CEF adapter import
78+// sibling configuration or environment adapters directly.
79+type HardwareDecodeEnvironmentOptions struct {
80+ EngineType string
81+ HardwareDecodingDisabled bool
82+ RenderingEnvManager port.RenderingEnvManager
83+}
84+
77 // ApplyDefaultHardwareDecodeEnvironment maps Dumber's media config to CEF's85 // ApplyDefaultHardwareDecodeEnvironment maps Dumber's media config to CEF's
78 // developer-facing VAAPI switch env var before CEF command-line callbacks run.86 // developer-facing VAAPI switch env var before CEF command-line callbacks run.
79 // Existing explicit env values are preserved as low-level escape hatches.87 // Existing explicit env values are preserved as low-level escape hatches.
80-func ApplyDefaultHardwareDecodeEnvironment(ctx context.Context, cfg *config.Config) {88+func ApplyDefaultHardwareDecodeEnvironment(ctx context.Context, opts HardwareDecodeEnvironmentOptions) {
81- if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF {89+ if opts.EngineType != cefEngineType {
82 return90 return
83 }91 }
84- if cfg.Media.HardwareDecodingMode == config.HardwareDecodingDisable {92+ if opts.HardwareDecodingDisabled {
85 setEnvDefault(ctx, cefEnableVAAPIEnvVar, "0")93 setEnvDefault(ctx, cefEnableVAAPIEnvVar, "0")
86 return94 return
87 }95 }
88 setEnvDefault(ctx, cefEnableVAAPIEnvVar, "1")96 setEnvDefault(ctx, cefEnableVAAPIEnvVar, "1")
89- applyDefaultLIBVADriverEnvironment(ctx)97+ applyDefaultLIBVADriverEnvironment(ctx, opts.RenderingEnvManager)
90 }98 }
91 99
92-func applyDefaultLIBVADriverEnvironment(ctx context.Context) {100+func applyDefaultLIBVADriverEnvironment(ctx context.Context, manager port.RenderingEnvManager) {
93- if strings.TrimSpace(os.Getenv("LIBVA_DRIVER_NAME")) != "" {101+ if strings.TrimSpace(os.Getenv("LIBVA_DRIVER_NAME")) != "" || manager == nil {
94 return102 return
95 }103 }
96- manager := renderenv.NewManager()
97 switch manager.DetectGPUVendor(ctx) {104 switch manager.DetectGPUVendor(ctx) {
98 case port.GPUVendorAMD:105 case port.GPUVendorAMD:
99 setEnvDefault(ctx, "LIBVA_DRIVER_NAME", "radeonsi")106 setEnvDefault(ctx, "LIBVA_DRIVER_NAME", "radeonsi")
@@ -108,13 +115,21 @@ func applyDefaultLIBVADriverEnvironment(ctx context.Context) {
108 }115 }
109 }116 }
110 117
111-func normalizeRenderStack(value string) string {118+func normalizeRenderStack(ctx context.Context, value string) string {
112- switch strings.ToLower(strings.TrimSpace(value)) {119+ normalized := strings.ToLower(strings.TrimSpace(value))
120+ switch normalized {
113 case "", renderStackAuto, renderStackVulkanDMABUF:121 case "", renderStackAuto, renderStackVulkanDMABUF:
114 return renderStackVulkanDMABUF122 return renderStackVulkanDMABUF
115 case renderStackLegacyGL:123 case renderStackLegacyGL:
116 return renderStackLegacyGL124 return renderStackLegacyGL
117 default:125 default:
126+ if ctx == nil {
127+ ctx = context.Background()
128+ }
129+ logging.FromContext(ctx).Warn().
130+ Str("render_stack", value).
131+ Str("fallback", renderStackVulkanDMABUF).
132+ Msg("cef: unknown render stack, falling back to default")
118 return renderStackVulkanDMABUF133 return renderStackVulkanDMABUF
119 }134 }
120 }135 }
M internal/infrastructure/cef/render_stack_env_test.go +24 -15
@@ -5,7 +5,7 @@ import (
55 "os"
66 "testing"
77
8- "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/application/port"
99 )
1010
1111 func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEVulkan(t *testing.T) {
@@ -95,13 +95,18 @@ func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) {
9595 }
9696 }
9797
98+func TestNormalizeRenderStackUnknownFallsBackToGDKDMABUF(t *testing.T) {
99+ got := normalizeRenderStack(context.Background(), "vulkan-dmabuff")
100+
101+ if got != renderStackVulkanDMABUF {
102+ t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF)
103+ }
104+}
105+
98106 func TestApplyDefaultHardwareDecodeEnvironment_DefaultsCEFToVAAPIForAuto(t *testing.T) {
99107 t.Setenv(cefEnableVAAPIEnvVar, "")
100108
101- ApplyDefaultHardwareDecodeEnvironment(context.Background(), &config.Config{
102- Engine: config.EngineConfig{Type: config.EngineTypeCEF},
103- Media: config.MediaConfig{HardwareDecodingMode: config.HardwareDecodingAuto},
104- })
109+ ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{EngineType: cefEngineType})
105110
106111 if got := os.Getenv(cefEnableVAAPIEnvVar); got != "1" {
107112 t.Fatalf("%s = %q, want 1", cefEnableVAAPIEnvVar, got)
@@ -112,9 +117,9 @@ func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitLIBVADriver(t *t
112117 t.Setenv(cefEnableVAAPIEnvVar, "")
113118 t.Setenv("LIBVA_DRIVER_NAME", "custom-driver")
114119
115- ApplyDefaultHardwareDecodeEnvironment(context.Background(), &config.Config{
116- Engine: config.EngineConfig{Type: config.EngineTypeCEF},
117- Media: config.MediaConfig{HardwareDecodingMode: config.HardwareDecodingAuto},
120+ ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{
121+ EngineType: cefEngineType,
122+ RenderingEnvManager: stubRenderingEnvManager{},
118123 })
119124
120125 if got := os.Getenv("LIBVA_DRIVER_NAME"); got != "custom-driver" {
@@ -125,9 +130,9 @@ func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitLIBVADriver(t *t
125130 func TestApplyDefaultHardwareDecodeEnvironment_DisablesCEFVAAPIWhenMediaDisabled(t *testing.T) {
126131 t.Setenv(cefEnableVAAPIEnvVar, "")
127132
128- ApplyDefaultHardwareDecodeEnvironment(context.Background(), &config.Config{
129- Engine: config.EngineConfig{Type: config.EngineTypeCEF},
130- Media: config.MediaConfig{HardwareDecodingMode: config.HardwareDecodingDisable},
133+ ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{
134+ EngineType: cefEngineType,
135+ HardwareDecodingDisabled: true,
131136 })
132137
133138 if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" {
@@ -138,12 +143,16 @@ func TestApplyDefaultHardwareDecodeEnvironment_DisablesCEFVAAPIWhenMediaDisabled
138143 func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitCEFVAAPIOverride(t *testing.T) {
139144 t.Setenv(cefEnableVAAPIEnvVar, "0")
140145
141- ApplyDefaultHardwareDecodeEnvironment(context.Background(), &config.Config{
142- Engine: config.EngineConfig{Type: config.EngineTypeCEF},
143- Media: config.MediaConfig{HardwareDecodingMode: config.HardwareDecodingAuto},
144- })
146+ ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{EngineType: cefEngineType})
145147
146148 if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" {
147149 t.Fatalf("%s = %q, want explicit 0", cefEnableVAAPIEnvVar, got)
148150 }
149151 }
152+
153+type stubRenderingEnvManager struct{}
154+
155+func (stubRenderingEnvManager) DetectGPUVendor(context.Context) port.GPUVendor {
156+ return port.GPUVendorAMD
157+}
158+func (stubRenderingEnvManager) GetAppliedVars() map[string]string { return nil }
@@ -5,7 +5,7 @@ import (
5 "os"5 "os"
6 "testing"6 "testing"
7 7
8- "github.com/bnema/dumber/internal/infrastructure/config"8+ "github.com/bnema/dumber/internal/application/port"
9 )9 )
10 10
11 func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEVulkan(t *testing.T) {11 func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEVulkan(t *testing.T) {
@@ -95,13 +95,18 @@ func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) {
95 }95 }
96 }96 }
97 97
98+func TestNormalizeRenderStackUnknownFallsBackToGDKDMABUF(t *testing.T) {
99+ got := normalizeRenderStack(context.Background(), "vulkan-dmabuff")
100+
101+ if got != renderStackVulkanDMABUF {
102+ t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF)
103+ }
104+}
105+
98 func TestApplyDefaultHardwareDecodeEnvironment_DefaultsCEFToVAAPIForAuto(t *testing.T) {106 func TestApplyDefaultHardwareDecodeEnvironment_DefaultsCEFToVAAPIForAuto(t *testing.T) {
99 t.Setenv(cefEnableVAAPIEnvVar, "")107 t.Setenv(cefEnableVAAPIEnvVar, "")
100 108
101- ApplyDefaultHardwareDecodeEnvironment(context.Background(), &config.Config{109+ ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{EngineType: cefEngineType})
102- Engine: config.EngineConfig{Type: config.EngineTypeCEF},
103- Media: config.MediaConfig{HardwareDecodingMode: config.HardwareDecodingAuto},
104- })
105 110
106 if got := os.Getenv(cefEnableVAAPIEnvVar); got != "1" {111 if got := os.Getenv(cefEnableVAAPIEnvVar); got != "1" {
107 t.Fatalf("%s = %q, want 1", cefEnableVAAPIEnvVar, got)112 t.Fatalf("%s = %q, want 1", cefEnableVAAPIEnvVar, got)
@@ -112,9 +117,9 @@ func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitLIBVADriver(t *t
112 t.Setenv(cefEnableVAAPIEnvVar, "")117 t.Setenv(cefEnableVAAPIEnvVar, "")
113 t.Setenv("LIBVA_DRIVER_NAME", "custom-driver")118 t.Setenv("LIBVA_DRIVER_NAME", "custom-driver")
114 119
115- ApplyDefaultHardwareDecodeEnvironment(context.Background(), &config.Config{120+ ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{
116- Engine: config.EngineConfig{Type: config.EngineTypeCEF},121+ EngineType: cefEngineType,
117- Media: config.MediaConfig{HardwareDecodingMode: config.HardwareDecodingAuto},122+ RenderingEnvManager: stubRenderingEnvManager{},
118 })123 })
119 124
120 if got := os.Getenv("LIBVA_DRIVER_NAME"); got != "custom-driver" {125 if got := os.Getenv("LIBVA_DRIVER_NAME"); got != "custom-driver" {
@@ -125,9 +130,9 @@ func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitLIBVADriver(t *t
125 func TestApplyDefaultHardwareDecodeEnvironment_DisablesCEFVAAPIWhenMediaDisabled(t *testing.T) {130 func TestApplyDefaultHardwareDecodeEnvironment_DisablesCEFVAAPIWhenMediaDisabled(t *testing.T) {
126 t.Setenv(cefEnableVAAPIEnvVar, "")131 t.Setenv(cefEnableVAAPIEnvVar, "")
127 132
128- ApplyDefaultHardwareDecodeEnvironment(context.Background(), &config.Config{133+ ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{
129- Engine: config.EngineConfig{Type: config.EngineTypeCEF},134+ EngineType: cefEngineType,
130- Media: config.MediaConfig{HardwareDecodingMode: config.HardwareDecodingDisable},135+ HardwareDecodingDisabled: true,
131 })136 })
132 137
133 if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" {138 if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" {
@@ -138,12 +143,16 @@ func TestApplyDefaultHardwareDecodeEnvironment_DisablesCEFVAAPIWhenMediaDisabled
138 func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitCEFVAAPIOverride(t *testing.T) {143 func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitCEFVAAPIOverride(t *testing.T) {
139 t.Setenv(cefEnableVAAPIEnvVar, "0")144 t.Setenv(cefEnableVAAPIEnvVar, "0")
140 145
141- ApplyDefaultHardwareDecodeEnvironment(context.Background(), &config.Config{146+ ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{EngineType: cefEngineType})
142- Engine: config.EngineConfig{Type: config.EngineTypeCEF},
143- Media: config.MediaConfig{HardwareDecodingMode: config.HardwareDecodingAuto},
144- })
145 147
146 if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" {148 if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" {
147 t.Fatalf("%s = %q, want explicit 0", cefEnableVAAPIEnvVar, got)149 t.Fatalf("%s = %q, want explicit 0", cefEnableVAAPIEnvVar, got)
148 }150 }
149 }151 }
152+
153+type stubRenderingEnvManager struct{}
154+
155+func (stubRenderingEnvManager) DetectGPUVendor(context.Context) port.GPUVendor {
156+ return port.GPUVendorAMD
157+}
158+func (stubRenderingEnvManager) GetAppliedVars() map[string]string { return nil }
M internal/infrastructure/cef/render_stall_watchdog.go +8 -2
@@ -36,7 +36,10 @@ type renderStallDiagnostics struct {
3636
3737 func classifyRenderStall(uiHeartbeat, ioHeartbeat cefHeartbeatSnapshot) renderStallClassification {
3838 uiBlocked := uiHeartbeat.PostResult == 1 && uiHeartbeat.InFlight && uiHeartbeat.LastAckAge >= renderStallCEFUIBlockedAfter
39- ioAlive := ioHeartbeat.PostResult == 1 && !ioHeartbeat.InFlight && ioHeartbeat.LastAckAge > 0 && ioHeartbeat.LastAckAge < renderStallCEFUIBlockedAfter
39+ ioAlive := ioHeartbeat.PostResult == 1 &&
40+ !ioHeartbeat.InFlight &&
41+ ioHeartbeat.LastAckAge > 0 &&
42+ ioHeartbeat.LastAckAge < renderStallCEFUIBlockedAfter
4043 category := "osr-paint-stalled"
4144 if uiBlocked {
4245 category = "cef-ui-task-runner-blocked"
@@ -173,7 +176,10 @@ func (wv *WebView) collectStallDiagnostics(bridge *Cef2gtkAdapter, now time.Time
173176 func (wv *WebView) logRenderStall(bridge *Cef2gtkAdapter, now time.Time, diag renderStallDiagnostics) {
174177 uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now)
175178 classification := classifyRenderStall(uiHeartbeat, ioHeartbeat)
176- wv.logRenderDiagnosticSnapshot(now, "stall", classification, bridge, diag.diag, diag.lastPaintAge, diag.browser, diag.uri, diag.title, diag.isLoading, diag.pendingURI, diag.audioPlaying)
179+ wv.logRenderDiagnosticSnapshot(
180+ now, "stall", classification, bridge, diag.diag, diag.lastPaintAge,
181+ diag.browser, diag.uri, diag.title, diag.isLoading, diag.pendingURI, diag.audioPlaying,
182+ )
177183 wv.logCEFProcessDiagnostics("stall", classification)
178184 if wv.engine != nil {
179185 wv.engine.logAllWebViewRenderSnapshots(now, wv.id, "stall-global", classification)
@@ -36,7 +36,10 @@ type renderStallDiagnostics struct {
36 36
37 func classifyRenderStall(uiHeartbeat, ioHeartbeat cefHeartbeatSnapshot) renderStallClassification {37 func classifyRenderStall(uiHeartbeat, ioHeartbeat cefHeartbeatSnapshot) renderStallClassification {
38 uiBlocked := uiHeartbeat.PostResult == 1 && uiHeartbeat.InFlight && uiHeartbeat.LastAckAge >= renderStallCEFUIBlockedAfter38 uiBlocked := uiHeartbeat.PostResult == 1 && uiHeartbeat.InFlight && uiHeartbeat.LastAckAge >= renderStallCEFUIBlockedAfter
39- ioAlive := ioHeartbeat.PostResult == 1 && !ioHeartbeat.InFlight && ioHeartbeat.LastAckAge > 0 && ioHeartbeat.LastAckAge < renderStallCEFUIBlockedAfter39+ ioAlive := ioHeartbeat.PostResult == 1 &&
40+ !ioHeartbeat.InFlight &&
41+ ioHeartbeat.LastAckAge > 0 &&
42+ ioHeartbeat.LastAckAge < renderStallCEFUIBlockedAfter
40 category := "osr-paint-stalled"43 category := "osr-paint-stalled"
41 if uiBlocked {44 if uiBlocked {
42 category = "cef-ui-task-runner-blocked"45 category = "cef-ui-task-runner-blocked"
@@ -173,7 +176,10 @@ func (wv *WebView) collectStallDiagnostics(bridge *Cef2gtkAdapter, now time.Time
173 func (wv *WebView) logRenderStall(bridge *Cef2gtkAdapter, now time.Time, diag renderStallDiagnostics) {176 func (wv *WebView) logRenderStall(bridge *Cef2gtkAdapter, now time.Time, diag renderStallDiagnostics) {
174 uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now)177 uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now)
175 classification := classifyRenderStall(uiHeartbeat, ioHeartbeat)178 classification := classifyRenderStall(uiHeartbeat, ioHeartbeat)
176- wv.logRenderDiagnosticSnapshot(now, "stall", classification, bridge, diag.diag, diag.lastPaintAge, diag.browser, diag.uri, diag.title, diag.isLoading, diag.pendingURI, diag.audioPlaying)179+ wv.logRenderDiagnosticSnapshot(
180+ now, "stall", classification, bridge, diag.diag, diag.lastPaintAge,
181+ diag.browser, diag.uri, diag.title, diag.isLoading, diag.pendingURI, diag.audioPlaying,
182+ )
177 wv.logCEFProcessDiagnostics("stall", classification)183 wv.logCEFProcessDiagnostics("stall", classification)
178 if wv.engine != nil {184 if wv.engine != nil {
179 wv.engine.logAllWebViewRenderSnapshots(now, wv.id, "stall-global", classification)185 wv.engine.logAllWebViewRenderSnapshots(now, wv.id, "stall-global", classification)
D internal/infrastructure/cef/renderer.go +0 -9
@@ -1,9 +0,0 @@
1-package cef
2-
3-import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
4-
5-type Renderer = gtkmenu.Renderer
6-
7-func NewRenderer(dispatch func(func())) *Renderer {
8- return gtkmenu.NewRenderer(dispatch)
9-}
@@ -1,9 +0,0 @@
1-package cef
2-
3-import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
4-
5-type Renderer = gtkmenu.Renderer
6-
7-func NewRenderer(dispatch func(func())) *Renderer {
8- return gtkmenu.NewRenderer(dispatch)
9-}
M internal/infrastructure/cef/runtime_config.go +1 -0
@@ -34,6 +34,7 @@ type EngineDependencies struct {
3434 DefaultConfigPayload func() ([]byte, error)
3535 ContextMenuBuilder port.ContextMenuBuilder
3636 ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory
37+ ContextMenuRenderer ContextMenuRenderer
3738 Clipboard port.Clipboard
3839 ImageDataResolver port.ImageDataResolver
3940 }
@@ -34,6 +34,7 @@ type EngineDependencies struct {
34 DefaultConfigPayload func() ([]byte, error)34 DefaultConfigPayload func() ([]byte, error)
35 ContextMenuBuilder port.ContextMenuBuilder35 ContextMenuBuilder port.ContextMenuBuilder
36 ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory36 ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory
37+ ContextMenuRenderer ContextMenuRenderer
37 Clipboard port.Clipboard38 Clipboard port.Clipboard
38 ImageDataResolver port.ImageDataResolver39 ImageDataResolver port.ImageDataResolver
39 }40 }
M internal/infrastructure/cef/webview_handlers.go +4 -7
@@ -2,7 +2,6 @@ package cef
22
33 import (
44 "context"
5- "errors"
65 "strings"
76 "sync"
87
@@ -596,13 +595,11 @@ func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost)
596595 if wv.ctx != nil {
597596 logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge")
598597 }
599- if errors.Is(err, ErrAdapterDestroyed) {
600- if host != nil && !wv.destroyed.Load() {
601- host.CloseBrowser(1)
602- return
603- }
604- wv.runCloseCallbacks()
598+ if host != nil && !wv.destroyed.Load() {
599+ host.CloseBrowser(1)
600+ return
605601 }
602+ wv.runCloseCallbacks()
606603 }
607604
608605 func (h *handlerSet) finishAfterCreated(
@@ -2,7 +2,6 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "strings"5 "strings"
7 "sync"6 "sync"
8 7
@@ -596,13 +595,11 @@ func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost)
596 if wv.ctx != nil {595 if wv.ctx != nil {
597 logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge")596 logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge")
598 }597 }
599- if errors.Is(err, ErrAdapterDestroyed) {598+ if host != nil && !wv.destroyed.Load() {
600- if host != nil && !wv.destroyed.Load() {599+ host.CloseBrowser(1)
601- host.CloseBrowser(1)600+ return
602- return
603- }
604- wv.runCloseCallbacks()
605 }601 }
602+ wv.runCloseCallbacks()
606 }603 }
607 604
608 func (h *handlerSet) finishAfterCreated(605 func (h *handlerSet) finishAfterCreated(
M internal/infrastructure/cef/webview_lifecycle_test.go +13 -0
@@ -35,6 +35,19 @@ func TestDestroy_WithoutHostDestroysBridgeImmediately(t *testing.T) {
3535 require.True(t, bridge.IsDestroyed())
3636 }
3737
38+func TestHandleInputAttachFailureClosesBrowserForAnyAttachError(t *testing.T) {
39+ host := &stubBrowserHost{}
40+ wv := &WebView{}
41+
42+ wv.handleInputAttachFailure(assertAnError{}, host)
43+
44+ require.True(t, host.closeBrowserCalled)
45+}
46+
47+type assertAnError struct{}
48+
49+func (assertAnError) Error() string { return "attach failed" }
50+
3851 // stubBrowserHost intentionally only stubs CloseBrowser. The embedded
3952 // purecef.BrowserHost may be nil; calling any other BrowserHost method will
4053 // panic and should be avoided in these lifecycle tests.
@@ -35,6 +35,19 @@ func TestDestroy_WithoutHostDestroysBridgeImmediately(t *testing.T) {
35 require.True(t, bridge.IsDestroyed())35 require.True(t, bridge.IsDestroyed())
36 }36 }
37 37
38+func TestHandleInputAttachFailureClosesBrowserForAnyAttachError(t *testing.T) {
39+ host := &stubBrowserHost{}
40+ wv := &WebView{}
41+
42+ wv.handleInputAttachFailure(assertAnError{}, host)
43+
44+ require.True(t, host.closeBrowserCalled)
45+}
46+
47+type assertAnError struct{}
48+
49+func (assertAnError) Error() string { return "attach failed" }
50+
38 // stubBrowserHost intentionally only stubs CloseBrowser. The embedded51 // stubBrowserHost intentionally only stubs CloseBrowser. The embedded
39 // purecef.BrowserHost may be nil; calling any other BrowserHost method will52 // purecef.BrowserHost may be nil; calling any other BrowserHost method will
40 // panic and should be avoided in these lifecycle tests.53 // panic and should be avoided in these lifecycle tests.
M internal/ui/input/global_shortcuts.go +21 -2
@@ -25,6 +25,8 @@ type GlobalShortcutHandler struct {
2525 ctx context.Context
2626 registered map[KeyBinding]Action
2727 lastDispatchAt map[Action]time.Time
28+ // generation is mutated only from the GTK main thread alongside controller replacement.
29+ generation uint64
2830
2931 // Keep references to callbacks to prevent GC from collecting them
3032 callbacks []gtk.ShortcutFunc
@@ -184,11 +186,21 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
184186 return false
185187 }
186188
187- // Create callback action
188- // We need to capture the action in the closure
189+ // Create callback action.
190+ // Capture the generation so callbacks from removed/reloaded controllers cannot
191+ // dispatch one-shot actions after a shortcut reload.
189192 actionToDispatch := action
193+ generation := h.generation
190194 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
191195 log := logging.FromContext(h.ctx)
196+ if h.isStaleGeneration(generation) {
197+ log.Trace().
198+ Str("action", string(actionToDispatch)).
199+ Uint64("callback_generation", generation).
200+ Uint64("current_generation", h.generation).
201+ Msg("stale global shortcut callback ignored")
202+ return true
203+ }
192204 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
193205 log.Trace().
194206 Str("action", string(actionToDispatch)).
@@ -275,6 +287,8 @@ func globalShortcutActionMap() map[string]Action {
275287 // are removed.
276288 func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
277289 log := logging.FromContext(ctx)
290+ h.ctx = ctx
291+ h.generation++
278292
279293 if h.window == nil || h.controller == nil {
280294 return
@@ -358,6 +372,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
358372 // Note: GTK handles cleanup when the widget is destroyed,
359373 // but we clear our references here.
360374 func (h *GlobalShortcutHandler) Detach() {
375+ h.generation++
361376 h.controller = nil
362377 h.callbacks = nil
363378 h.registered = nil
@@ -366,6 +381,10 @@ func (h *GlobalShortcutHandler) Detach() {
366381
367382 const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
368383
384+func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
385+ return h == nil || generation != h.generation
386+}
387+
369388 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
370389 if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {
371390 return false
@@ -25,6 +25,8 @@ type GlobalShortcutHandler struct {
25 ctx context.Context25 ctx context.Context
26 registered map[KeyBinding]Action26 registered map[KeyBinding]Action
27 lastDispatchAt map[Action]time.Time27 lastDispatchAt map[Action]time.Time
28+ // generation is mutated only from the GTK main thread alongside controller replacement.
29+ generation uint64
28 30
29 // Keep references to callbacks to prevent GC from collecting them31 // Keep references to callbacks to prevent GC from collecting them
30 callbacks []gtk.ShortcutFunc32 callbacks []gtk.ShortcutFunc
@@ -184,11 +186,21 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
184 return false186 return false
185 }187 }
186 188
187- // Create callback action189+ // Create callback action.
188- // We need to capture the action in the closure190+ // Capture the generation so callbacks from removed/reloaded controllers cannot
191+ // dispatch one-shot actions after a shortcut reload.
189 actionToDispatch := action192 actionToDispatch := action
193+ generation := h.generation
190 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {194 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
191 log := logging.FromContext(h.ctx)195 log := logging.FromContext(h.ctx)
196+ if h.isStaleGeneration(generation) {
197+ log.Trace().
198+ Str("action", string(actionToDispatch)).
199+ Uint64("callback_generation", generation).
200+ Uint64("current_generation", h.generation).
201+ Msg("stale global shortcut callback ignored")
202+ return true
203+ }
192 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {204 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
193 log.Trace().205 log.Trace().
194 Str("action", string(actionToDispatch)).206 Str("action", string(actionToDispatch)).
@@ -275,6 +287,8 @@ func globalShortcutActionMap() map[string]Action {
275 // are removed.287 // are removed.
276 func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {288 func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
277 log := logging.FromContext(ctx)289 log := logging.FromContext(ctx)
290+ h.ctx = ctx
291+ h.generation++
278 292
279 if h.window == nil || h.controller == nil {293 if h.window == nil || h.controller == nil {
280 return294 return
@@ -358,6 +372,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
358 // Note: GTK handles cleanup when the widget is destroyed,372 // Note: GTK handles cleanup when the widget is destroyed,
359 // but we clear our references here.373 // but we clear our references here.
360 func (h *GlobalShortcutHandler) Detach() {374 func (h *GlobalShortcutHandler) Detach() {
375+ h.generation++
361 h.controller = nil376 h.controller = nil
362 h.callbacks = nil377 h.callbacks = nil
363 h.registered = nil378 h.registered = nil
@@ -366,6 +381,10 @@ func (h *GlobalShortcutHandler) Detach() {
366 381
367 const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond382 const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
368 383
384+func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
385+ return h == nil || generation != h.generation
386+}
387+
369 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {388 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
370 if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {389 if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {
371 return false390 return false
M internal/ui/input/global_shortcuts_test.go +13 -0
@@ -46,3 +46,16 @@ func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T)
4646 t.Fatal("one-shot shortcut was not suppressed with detached handler state")
4747 }
4848 }
49+
50+func TestGlobalShortcutHandlerGenerationMarksOldCallbacksStale(t *testing.T) {
51+ h := &GlobalShortcutHandler{}
52+ generation := h.generation
53+
54+ if h.isStaleGeneration(generation) {
55+ t.Fatal("current generation was marked stale")
56+ }
57+ h.generation++
58+ if !h.isStaleGeneration(generation) {
59+ t.Fatal("old generation was not marked stale")
60+ }
61+}
@@ -46,3 +46,16 @@ func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T)
46 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")
47 }47 }
48 }48 }
49+
50+func TestGlobalShortcutHandlerGenerationMarksOldCallbacksStale(t *testing.T) {
51+ h := &GlobalShortcutHandler{}
52+ generation := h.generation
53+
54+ if h.isStaleGeneration(generation) {
55+ t.Fatal("current generation was marked stale")
56+ }
57+ h.generation++
58+ if !h.isStaleGeneration(generation) {
59+ t.Fatal("old generation was not marked stale")
60+ }
61+}
M scripts/cef-gpu-diagnostics.sh +26 -8
@@ -9,7 +9,7 @@ Capture a short, repeatable Dumber/CEF GPU-video diagnostic bundle while a
99 YouTube video is playing.
1010
1111 Options:
12- -p, --pid PID Dumber browser/root PID. Defaults to newest dumber process.
12+ -p, --pid PID Dumber browser/root PID. Defaults to oldest/root dumber process.
1313 -o, --out DIR Output directory. Default: .dev/dumber/gpu-diagnostics/<timestamp>
1414 -n, --samples N amdgpu_top JSON samples. Default: 10
1515 -i, --interval-ms MS amdgpu_top sample interval. Default: 1000
@@ -65,7 +65,11 @@ while [[ $# -gt 0 ]]; do
6565 done
6666
6767 if [[ -z "$pid" ]]; then
68- pid="$(pgrep -n -x dumber || true)"
68+ pid="$(pgrep -o -x dumber || true)"
69+fi
70+if [[ -n "$pid" && ! "$pid" =~ ^[1-9][0-9]*$ ]]; then
71+ echo "--pid must be a positive integer" >&2
72+ exit 2
6973 fi
7074 if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then
7175 echo "could not find a live dumber PID; pass --pid" >&2
@@ -160,11 +164,19 @@ else
160164 log "amdgpu_top not found; skipping GPU samples"
161165 fi
162166
163-if [[ -n "$session_log" && -r "$session_log" ]]; then
164- cp "$session_log" "$out_dir/$(basename "$session_log")"
167+if [[ -n "$session_log" ]]; then
168+ if [[ ! -r "$session_log" ]]; then
169+ log "--session-log is not readable: $session_log"
170+ exit 2
171+ fi
172+ cp -- "$session_log" "$out_dir/$(basename -- "$session_log")"
165173 fi
166-if [[ -n "$codec_stats_file" && -r "$codec_stats_file" ]]; then
167- cp "$codec_stats_file" "$out_dir/youtube_stats_for_nerds.txt"
174+if [[ -n "$codec_stats_file" ]]; then
175+ if [[ ! -r "$codec_stats_file" ]]; then
176+ log "--codec-stats-file is not readable: $codec_stats_file"
177+ exit 2
178+ fi
179+ cp -- "$codec_stats_file" "$out_dir/youtube_stats_for_nerds.txt"
168180 elif [[ -n "$codec_stats" ]]; then
169181 printf '%s\n' "$codec_stats" > "$out_dir/youtube_stats_for_nerds.txt"
170182 else
@@ -177,8 +189,14 @@ fi
177189
178190 if [[ -n "$devtools_port" ]]; then
179191 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
192+ if ! 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"; then
193+ log "failed to fetch DevTools version metadata from port $devtools_port"
194+ exit 1
195+ fi
196+ if ! 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"; then
197+ log "failed to fetch DevTools tab metadata from port $devtools_port"
198+ exit 1
199+ fi
182200 fi
183201
184202 log "bundle complete: $out_dir"
@@ -9,7 +9,7 @@ Capture a short, repeatable Dumber/CEF GPU-video diagnostic bundle while a
9 YouTube video is playing.9 YouTube video is playing.
10 10
11 Options:11 Options:
12- -p, --pid PID Dumber browser/root PID. Defaults to newest dumber process.12+ -p, --pid PID Dumber browser/root PID. Defaults to oldest/root dumber process.
13 -o, --out DIR Output directory. Default: .dev/dumber/gpu-diagnostics/<timestamp>13 -o, --out DIR Output directory. Default: .dev/dumber/gpu-diagnostics/<timestamp>
14 -n, --samples N amdgpu_top JSON samples. Default: 1014 -n, --samples N amdgpu_top JSON samples. Default: 10
15 -i, --interval-ms MS amdgpu_top sample interval. Default: 100015 -i, --interval-ms MS amdgpu_top sample interval. Default: 1000
@@ -65,7 +65,11 @@ while [[ $# -gt 0 ]]; do
65 done65 done
66 66
67 if [[ -z "$pid" ]]; then67 if [[ -z "$pid" ]]; then
68- pid="$(pgrep -n -x dumber || true)"68+ pid="$(pgrep -o -x dumber || true)"
69+fi
70+if [[ -n "$pid" && ! "$pid" =~ ^[1-9][0-9]*$ ]]; then
71+ echo "--pid must be a positive integer" >&2
72+ exit 2
69 fi73 fi
70 if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then74 if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then
71 echo "could not find a live dumber PID; pass --pid" >&275 echo "could not find a live dumber PID; pass --pid" >&2
@@ -160,11 +164,19 @@ else
160 log "amdgpu_top not found; skipping GPU samples"164 log "amdgpu_top not found; skipping GPU samples"
161 fi165 fi
162 166
163-if [[ -n "$session_log" && -r "$session_log" ]]; then167+if [[ -n "$session_log" ]]; then
164- cp "$session_log" "$out_dir/$(basename "$session_log")"168+ if [[ ! -r "$session_log" ]]; then
169+ log "--session-log is not readable: $session_log"
170+ exit 2
171+ fi
172+ cp -- "$session_log" "$out_dir/$(basename -- "$session_log")"
165 fi173 fi
166-if [[ -n "$codec_stats_file" && -r "$codec_stats_file" ]]; then174+if [[ -n "$codec_stats_file" ]]; then
167- cp "$codec_stats_file" "$out_dir/youtube_stats_for_nerds.txt"175+ if [[ ! -r "$codec_stats_file" ]]; then
176+ log "--codec-stats-file is not readable: $codec_stats_file"
177+ exit 2
178+ fi
179+ cp -- "$codec_stats_file" "$out_dir/youtube_stats_for_nerds.txt"
168 elif [[ -n "$codec_stats" ]]; then180 elif [[ -n "$codec_stats" ]]; then
169 printf '%s\n' "$codec_stats" > "$out_dir/youtube_stats_for_nerds.txt"181 printf '%s\n' "$codec_stats" > "$out_dir/youtube_stats_for_nerds.txt"
170 else182 else
@@ -177,8 +189,14 @@ fi
177 189
178 if [[ -n "$devtools_port" ]]; then190 if [[ -n "$devtools_port" ]]; then
179 log "capturing DevTools metadata from port $devtools_port"191 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" || true192+ if ! 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"; then
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" || true193+ log "failed to fetch DevTools version metadata from port $devtools_port"
194+ exit 1
195+ fi
196+ if ! 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"; then
197+ log "failed to fetch DevTools tab metadata from port $devtools_port"
198+ exit 1
199+ fi
182 fi200 fi
183 201
184 log "bundle complete: $out_dir"202 log "bundle complete: $out_dir"

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close