cef: clean architecture review findings
- commit
- d8b3f7c0500c0419ec6d56a6905132f351ec9c45
- author
- brice · · 2026-05-01 14:51 UTC
- parent
- 952e013
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +311 -180
M cmd/dumber/main.go +6 -1
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 25 | 25 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 26 | 26 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 27 | + | renderenv "github.com/bnema/dumber/internal/infrastructure/env" | |
| 27 | 28 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 28 | 29 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 29 | 30 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| @@ -327,7 +328,11 @@ func applyCEFRenderStackDefault(ctx context.Context, cfg *config.Config) { | |||
| 327 | 328 | return | |
| 328 | 329 | } | |
| 329 | 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 | 338 | 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 | return | 328 | 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 ( | |||
| 13 | 13 | clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/gtkmenu" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 18 | 19 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| @@ -90,6 +91,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 90 | 91 | DefaultConfigPayload: defaultConfigPayload, | |
| 91 | 92 | ContextMenuBuilder: contextMenuBuilder, | |
| 92 | 93 | ContextMenuExecutorFactory: contextMenuExecutorFactory, | |
| 94 | + | ContextMenuRenderer: gtkmenu.NewRenderer(nil), | |
| 93 | 95 | Clipboard: clipboardinfra.New(), | |
| 94 | 96 | ImageDataResolver: webkit.NewContextMenuResolver(), | |
| 95 | 97 | } | |
| @@ -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 { | |||
| 275 | 275 | func (a *Cef2gtkAdapter) IsDestroyed() bool { | |
| 276 | 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 | - | } | |
| @@ -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 ( | |||
| 8 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/gtkmenu" | |
| 12 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 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 | 23 | // ContextMenuHandler (implemented on handlerSet) | |
| 18 | 24 | // =========================================================================== | |
| @@ -20,6 +26,7 @@ import ( | |||
| 20 | 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 | 30 | func (h *handlerSet) RunContextMenu( | |
| 24 | 31 | _ purecef.Browser, _ purecef.Frame, | |
| 25 | 32 | params purecef.ContextMenuParams, model purecef.MenuModel, | |
| @@ -82,7 +89,12 @@ func (h *handlerSet) RunContextMenu( | |||
| 82 | 89 | } | |
| 83 | 90 | widget := wv.viewBridge.Widget() | |
| 84 | 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 | 98 | items, | |
| 87 | 99 | widget, | |
| 88 | 100 | 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 { | |||
| 40 | 40 | defaultConfigPayload func() ([]byte, error) | |
| 41 | 41 | ctxMenuBuilder port.ContextMenuBuilder | |
| 42 | 42 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 43 | + | ctxMenuRenderer ContextMenuRenderer | |
| 43 | 44 | clipboard port.Clipboard | |
| 44 | 45 | clipboardTextOrchestrator port.ClipboardTextOrchestrator | |
| 45 | 46 | onClipboardCopied func(textLen int) | |
| @@ -83,6 +84,13 @@ func (e *Engine) Factory() port.WebViewFactory { | |||
| 83 | 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 | 94 | func (e *Engine) Pool() port.WebViewPool { | |
| 87 | 95 | return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx} | |
| 88 | 96 | } | |
| @@ -40,6 +40,7 @@ type Engine struct { | |||||
| 40 | defaultConfigPayload func() ([]byte, error) | 40 | defaultConfigPayload func() ([]byte, error) | ||
| 41 | ctxMenuBuilder port.ContextMenuBuilder | 41 | ctxMenuBuilder port.ContextMenuBuilder | ||
| 42 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | 42 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | ||
| 43 | + | ctxMenuRenderer ContextMenuRenderer | |||
| 43 | clipboard port.Clipboard | 44 | clipboard port.Clipboard | ||
| 44 | clipboardTextOrchestrator port.ClipboardTextOrchestrator | 45 | 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 ( | |||
| 17 | 17 | ||
| 18 | 18 | "github.com/bnema/dumber/assets" | |
| 19 | 19 | "github.com/bnema/dumber/internal/application/port" | |
| 20 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 21 | 20 | "github.com/bnema/dumber/internal/logging" | |
| 22 | 21 | ) | |
| 23 | 22 | ||
| 24 | 23 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |
| 25 | 24 | const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |
| 25 | + | const defaultCEFWindowlessFrameRate = 60 | |
| 26 | 26 | ||
| 27 | 27 | // RuntimePaths contains the concrete filesystem paths the CEF adapter needs. | |
| 28 | 28 | type RuntimePaths struct { | |
| @@ -43,7 +43,7 @@ func NewEngine( | |||
| 43 | 43 | logger := logging.FromContext(ctx) | |
| 44 | 44 | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | |
| 45 | 45 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 46 | - | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | |
| 46 | + | windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate) | |
| 47 | 47 | ||
| 48 | 48 | settings, err := prepareCEFSettings(opts, paths, cfg, logger) | |
| 49 | 49 | if err != nil { | |
| @@ -65,6 +65,7 @@ func NewEngine( | |||
| 65 | 65 | defaultConfigPayload: deps.DefaultConfigPayload, | |
| 66 | 66 | ctxMenuBuilder: deps.ContextMenuBuilder, | |
| 67 | 67 | ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory, | |
| 68 | + | ctxMenuRenderer: deps.ContextMenuRenderer, | |
| 68 | 69 | clipboard: deps.Clipboard, | |
| 69 | 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 | 104 | func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string { | |
| 97 | 105 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |
| 98 | 106 | 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 root | 106 | return root | ||
M internal/infrastructure/cef/process_diagnostics.go +88 -27
| @@ -17,18 +17,29 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | 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 | |
| 24 | 29 | ) | |
| 25 | 30 | ||
| 26 | - | var renderStallBacktraceLastUnixNS atomic.Int64 | |
| 31 | + | var ( | |
| 32 | + | renderStallProcessDiagnosticsLastUnixNS atomic.Int64 | |
| 33 | + | renderStallBacktraceLastUnixNS atomic.Int64 | |
| 34 | + | ) | |
| 27 | 35 | ||
| 28 | 36 | func (wv *WebView) logCEFProcessDiagnostics(reason string, classification renderStallClassification) { | |
| 29 | 37 | if wv == nil || wv.ctx == nil { | |
| 30 | 38 | return | |
| 31 | 39 | } | |
| 40 | + | if !reserveRenderStallProcessDiagnostics(time.Now()) { | |
| 41 | + | return | |
| 42 | + | } | |
| 32 | 43 | pids := append([]int{os.Getpid()}, childPIDs(os.Getpid())...) | |
| 33 | 44 | for _, pid := range pids { | |
| 34 | 45 | cmdline := procCmdline(pid) | |
| @@ -45,7 +56,7 @@ func (wv *WebView) logCEFProcessDiagnostics(reason string, classification render | |||
| 45 | 56 | Bool("cef_io_alive", classification.CEFIOAlive). | |
| 46 | 57 | Int("pid", pid). | |
| 47 | 58 | Str("process_type", processType). | |
| 48 | - | Str("cmdline", logging.TruncateURL(cmdline, 1024)). | |
| 59 | + | Strs("cmdline_flags", safeChromiumCmdlineFlags(cmdline)). | |
| 49 | 60 | Int("thread_count", procThreadCount(pid)). | |
| 50 | 61 | Strs("hot_threads", threads). | |
| 51 | 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 | 121 | func childPIDs(parent int) []int { | |
| 61 | 122 | if out := childPIDsFromProcChildren(parent); len(out) > 0 { | |
| 62 | 123 | return out | |
| 63 | 124 | } | |
| 64 | - | entries, err := os.ReadDir("/proc") | |
| 125 | + | entries, err := os.ReadDir(procRoot) | |
| 65 | 126 | if err != nil { | |
| 66 | 127 | return nil | |
| 67 | 128 | } | |
| @@ -83,7 +144,7 @@ func childPIDs(parent int) []int { | |||
| 83 | 144 | } | |
| 84 | 145 | ||
| 85 | 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 | 148 | b, err := os.ReadFile(path) | |
| 88 | 149 | if err != nil { | |
| 89 | 150 | return nil | |
| @@ -102,7 +163,7 @@ func childPIDsFromProcChildren(parent int) []int { | |||
| 102 | 163 | } | |
| 103 | 164 | ||
| 104 | 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 | 167 | if err != nil { | |
| 107 | 168 | return 0 | |
| 108 | 169 | } | |
| @@ -120,7 +181,7 @@ func procPPID(pid int) int { | |||
| 120 | 181 | } | |
| 121 | 182 | ||
| 122 | 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 | 185 | if err != nil { | |
| 125 | 186 | return "" | |
| 126 | 187 | } | |
| @@ -137,7 +198,7 @@ func chromiumProcessType(cmdline string) string { | |||
| 137 | 198 | } | |
| 138 | 199 | ||
| 139 | 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 | 202 | if err != nil { | |
| 142 | 203 | return 0 | |
| 143 | 204 | } | |
| @@ -158,7 +219,7 @@ func procDetailedThreadSummaries(pid, limit int) []string { | |||
| 158 | 219 | } | |
| 159 | 220 | ||
| 160 | 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 | 223 | if err != nil { | |
| 163 | 224 | return nil | |
| 164 | 225 | } | |
| @@ -168,7 +229,7 @@ func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string { | |||
| 168 | 229 | if err != nil { | |
| 169 | 230 | 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 | 233 | if err != nil { | |
| 173 | 234 | continue | |
| 174 | 235 | } | |
| @@ -194,27 +255,27 @@ func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string { | |||
| 194 | 255 | ||
| 195 | 256 | func parseThreadStat(tid int, stat string) (string, uint64) { | |
| 196 | 257 | 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) { | |
| 199 | 260 | return strconv.Itoa(tid), 0 | |
| 200 | 261 | } | |
| 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:]) | |
| 203 | 264 | state := "?" | |
| 204 | 265 | if len(rest) > 0 { | |
| 205 | 266 | state = rest[0] | |
| 206 | 267 | } | |
| 207 | 268 | 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) | |
| 211 | 272 | ticks = utime + stime | |
| 212 | 273 | } | |
| 213 | 274 | return "tid=" + strconv.Itoa(tid) + " state=" + state + " ticks=" + strconv.FormatUint(ticks, 10) + " comm=" + comm, ticks | |
| 214 | 275 | } | |
| 215 | 276 | ||
| 216 | 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 | 279 | if err != nil { | |
| 219 | 280 | return "" | |
| 220 | 281 | } | |
| @@ -222,13 +283,13 @@ func procThreadWchan(pid, tid int) string { | |||
| 222 | 283 | } | |
| 223 | 284 | ||
| 224 | 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 | 287 | if err != nil { | |
| 227 | 288 | return "" | |
| 228 | 289 | } | |
| 229 | 290 | 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] | |
| 232 | 293 | } | |
| 233 | 294 | return strings.Join(lines, " | ") | |
| 234 | 295 | } | |
| @@ -260,7 +321,7 @@ func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classificatio | |||
| 260 | 321 | Msg("cef: render stall native backtrace skipped; gdb not available") | |
| 261 | 322 | return | |
| 262 | 323 | } | |
| 263 | - | if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() { | |
| 324 | + | if wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() { | |
| 264 | 325 | return | |
| 265 | 326 | } | |
| 266 | 327 | logCtx := wv.ctx | |
| @@ -290,13 +351,13 @@ func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classificatio | |||
| 290 | 351 | } | |
| 291 | 352 | ||
| 292 | 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 | 355 | if err != nil { | |
| 295 | 356 | return nil | |
| 296 | 357 | } | |
| 297 | 358 | var out []string | |
| 298 | 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 | 361 | if err != nil { | |
| 301 | 362 | continue | |
| 302 | 363 | } | |
| @@ -17,18 +17,29 @@ import ( | |||||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | const ( | 19 | const ( | ||
| 20 | - | maxLoggedThreadsPerProcess = 8 | 20 | + | procRoot = "/proc" |
| 21 | - | maxDetailedThreadsPerProcess = 48 | 21 | + | maxLoggedThreadsPerProcess = 8 |
| 22 | - | renderStallBacktraceCooldown = 30 * time.Second | 22 | + | maxDetailedThreadsPerProcess = 48 |
| 23 | - | renderStallBacktraceCommandLimit = 64 * 1024 | 23 | + | 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.Int64 | 31 | + | 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 | return | 38 | 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 out | 123 | 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 nil | 127 | 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 nil | 150 | 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 0 | 168 | 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 0 | 203 | 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 nil | 224 | 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 | continue | 230 | 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 | continue | 234 | 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), 0 | 260 | 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 uint64 | 268 | 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 + stime | 272 | ticks = utime + stime | ||
| 212 | } | 273 | } | ||
| 213 | return "tid=" + strconv.Itoa(tid) + " state=" + state + " ticks=" + strconv.FormatUint(ticks, 10) + " comm=" + comm, ticks | 274 | 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 | return | 322 | return | ||
| 262 | } | 323 | } | ||
| 263 | - | if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() { | 324 | + | if wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() { |
| 264 | return | 325 | return | ||
| 265 | } | 326 | } | ||
| 266 | logCtx := wv.ctx | 327 | 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 nil | 356 | return nil | ||
| 296 | } | 357 | } | ||
| 297 | var out []string | 358 | 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 | continue | 362 | 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 { | |||
| 88 | 88 | Snapshot cef2gtk.ProfileSnapshot `json:"snapshot"` | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | + | const ( | |
| 92 | + | profileLogDirPerm = 0o755 | |
| 93 | + | profileLogFilePerm = 0o644 | |
| 94 | + | ) | |
| 95 | + | ||
| 91 | 96 | func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions { | |
| 92 | 97 | if e == nil || wv == nil || !cef2gtkProfileEnabled() { | |
| 93 | 98 | return cef2gtk.ProfileOptions{} | |
| @@ -96,11 +101,11 @@ func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions { | |||
| 96 | 101 | if path == "" { | |
| 97 | 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 | 105 | logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to create profile log directory") | |
| 101 | 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 | 109 | if err != nil { | |
| 105 | 110 | logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to open profile log") | |
| 106 | 111 | 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 ( | |||
| 12 | 12 | ||
| 13 | 13 | 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 | 16 | if e == nil { | |
| 17 | 17 | return | |
| 18 | 18 | } | |
| @@ -33,7 +33,10 @@ func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, stalledID port.WebV | |||
| 33 | 33 | isLoading := wv.isLoading | |
| 34 | 34 | pendingURI := wv.pendingURI | |
| 35 | 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 | 40 | return true | |
| 38 | 41 | }) | |
| 39 | 42 | } | |
| @@ -122,7 +125,7 @@ func (wv *WebView) logRenderDiagnosticSnapshot( | |||
| 122 | 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 | 129 | if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallRecoveryEnabled() { | |
| 127 | 130 | return | |
| 128 | 131 | } | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | const renderStallRecoveryCooldown = 10 * time.Second | 13 | 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 | return | 17 | return | ||
| 18 | } | 18 | } | ||
| @@ -33,7 +33,10 @@ func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, stalledID port.WebV | |||||
| 33 | isLoading := wv.isLoading | 33 | isLoading := wv.isLoading | ||
| 34 | pendingURI := wv.pendingURI | 34 | 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 true | 40 | 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 | return | 130 | return | ||
| 128 | } | 131 | } | ||
M internal/infrastructure/cef/render_stack_env.go +27 -12
| @@ -6,8 +6,6 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 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 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 10 | ) | |
| 13 | 11 | ||
| @@ -15,6 +13,7 @@ const ( | |||
| 15 | 13 | dumberRenderStackEnvVar = "DUMBER_RENDER_STACK" | |
| 16 | 14 | dumberRenderStackAllowSplitEnvVar = "DUMBER_RENDER_STACK_ALLOW_SPLIT" | |
| 17 | 15 | ||
| 16 | + | cefEngineType = "cef" | |
| 18 | 17 | renderStackAuto = "auto" | |
| 19 | 18 | renderStackVulkanDMABUF = "vulkan-dmabuf" | |
| 20 | 19 | renderStackLegacyGL = "legacy-gl" | |
| @@ -45,7 +44,7 @@ func ApplyDefaultRenderStackEnvironment(ctx context.Context) string { | |||
| 45 | 44 | } | |
| 46 | 45 | ||
| 47 | 46 | func applyDefaultRenderStackEnvironment(ctx context.Context) string { | |
| 48 | - | stack := normalizeRenderStack(os.Getenv(dumberRenderStackEnvVar)) | |
| 47 | + | stack := normalizeRenderStack(ctx, os.Getenv(dumberRenderStackEnvVar)) | |
| 49 | 48 | switch stack { | |
| 50 | 49 | case renderStackLegacyGL: | |
| 51 | 50 | setRenderStackEnv(ctx, gskRendererEnvVar, gskRendererOpenGL) | |
| @@ -74,26 +73,34 @@ func logRenderStackEnvironment(ctx context.Context, stack string) { | |||
| 74 | 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 | 85 | // ApplyDefaultHardwareDecodeEnvironment maps Dumber's media config to CEF's | |
| 78 | 86 | // developer-facing VAAPI switch env var before CEF command-line callbacks run. | |
| 79 | 87 | // 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 { | |
| 82 | 90 | return | |
| 83 | 91 | } | |
| 84 | - | if cfg.Media.HardwareDecodingMode == config.HardwareDecodingDisable { | |
| 92 | + | if opts.HardwareDecodingDisabled { | |
| 85 | 93 | setEnvDefault(ctx, cefEnableVAAPIEnvVar, "0") | |
| 86 | 94 | return | |
| 87 | 95 | } | |
| 88 | 96 | setEnvDefault(ctx, cefEnableVAAPIEnvVar, "1") | |
| 89 | - | applyDefaultLIBVADriverEnvironment(ctx) | |
| 97 | + | applyDefaultLIBVADriverEnvironment(ctx, opts.RenderingEnvManager) | |
| 90 | 98 | } | |
| 91 | 99 | ||
| 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 { | |
| 94 | 102 | return | |
| 95 | 103 | } | |
| 96 | - | manager := renderenv.NewManager() | |
| 97 | 104 | switch manager.DetectGPUVendor(ctx) { | |
| 98 | 105 | case port.GPUVendorAMD: | |
| 99 | 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 { | |
| 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 { | |
| 113 | 121 | case "", renderStackAuto, renderStackVulkanDMABUF: | |
| 114 | 122 | return renderStackVulkanDMABUF | |
| 115 | 123 | case renderStackLegacyGL: | |
| 116 | 124 | return renderStackLegacyGL | |
| 117 | 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 | 133 | return renderStackVulkanDMABUF | |
| 119 | 134 | } | |
| 120 | 135 | } | |
| @@ -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's | 85 | // 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 | return | 90 | 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 | return | 94 | 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 | return | 102 | 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 renderStackVulkanDMABUF | 122 | return renderStackVulkanDMABUF | ||
| 115 | case renderStackLegacyGL: | 123 | case renderStackLegacyGL: | ||
| 116 | return renderStackLegacyGL | 124 | 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 renderStackVulkanDMABUF | 133 | return renderStackVulkanDMABUF | ||
| 119 | } | 134 | } | ||
| 120 | } | 135 | } | ||
M internal/infrastructure/cef/render_stack_env_test.go +24 -15
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | "os" | |
| 6 | 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 | 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 | 106 | func TestApplyDefaultHardwareDecodeEnvironment_DefaultsCEFToVAAPIForAuto(t *testing.T) { | |
| 99 | 107 | t.Setenv(cefEnableVAAPIEnvVar, "") | |
| 100 | 108 | ||
| 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}) | |
| 105 | 110 | ||
| 106 | 111 | if got := os.Getenv(cefEnableVAAPIEnvVar); got != "1" { | |
| 107 | 112 | t.Fatalf("%s = %q, want 1", cefEnableVAAPIEnvVar, got) | |
| @@ -112,9 +117,9 @@ func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitLIBVADriver(t *t | |||
| 112 | 117 | t.Setenv(cefEnableVAAPIEnvVar, "") | |
| 113 | 118 | t.Setenv("LIBVA_DRIVER_NAME", "custom-driver") | |
| 114 | 119 | ||
| 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{}, | |
| 118 | 123 | }) | |
| 119 | 124 | ||
| 120 | 125 | if got := os.Getenv("LIBVA_DRIVER_NAME"); got != "custom-driver" { | |
| @@ -125,9 +130,9 @@ func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitLIBVADriver(t *t | |||
| 125 | 130 | func TestApplyDefaultHardwareDecodeEnvironment_DisablesCEFVAAPIWhenMediaDisabled(t *testing.T) { | |
| 126 | 131 | t.Setenv(cefEnableVAAPIEnvVar, "") | |
| 127 | 132 | ||
| 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, | |
| 131 | 136 | }) | |
| 132 | 137 | ||
| 133 | 138 | if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" { | |
| @@ -138,12 +143,16 @@ func TestApplyDefaultHardwareDecodeEnvironment_DisablesCEFVAAPIWhenMediaDisabled | |||
| 138 | 143 | func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitCEFVAAPIOverride(t *testing.T) { | |
| 139 | 144 | t.Setenv(cefEnableVAAPIEnvVar, "0") | |
| 140 | 145 | ||
| 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}) | |
| 145 | 147 | ||
| 146 | 148 | if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" { | |
| 147 | 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 } | |
| @@ -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 { | |||
| 36 | 36 | ||
| 37 | 37 | func classifyRenderStall(uiHeartbeat, ioHeartbeat cefHeartbeatSnapshot) renderStallClassification { | |
| 38 | 38 | 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 | |
| 40 | 43 | category := "osr-paint-stalled" | |
| 41 | 44 | if uiBlocked { | |
| 42 | 45 | category = "cef-ui-task-runner-blocked" | |
| @@ -173,7 +176,10 @@ func (wv *WebView) collectStallDiagnostics(bridge *Cef2gtkAdapter, now time.Time | |||
| 173 | 176 | func (wv *WebView) logRenderStall(bridge *Cef2gtkAdapter, now time.Time, diag renderStallDiagnostics) { | |
| 174 | 177 | uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now) | |
| 175 | 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 | 183 | wv.logCEFProcessDiagnostics("stall", classification) | |
| 178 | 184 | if wv.engine != nil { | |
| 179 | 185 | 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 >= renderStallCEFUIBlockedAfter | 38 | 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 | |||
| 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 { | |||
| 34 | 34 | DefaultConfigPayload func() ([]byte, error) | |
| 35 | 35 | ContextMenuBuilder port.ContextMenuBuilder | |
| 36 | 36 | ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 37 | + | ContextMenuRenderer ContextMenuRenderer | |
| 37 | 38 | Clipboard port.Clipboard | |
| 38 | 39 | ImageDataResolver port.ImageDataResolver | |
| 39 | 40 | } | |
| @@ -34,6 +34,7 @@ type EngineDependencies struct { | |||||
| 34 | DefaultConfigPayload func() ([]byte, error) | 34 | DefaultConfigPayload func() ([]byte, error) | ||
| 35 | ContextMenuBuilder port.ContextMenuBuilder | 35 | ContextMenuBuilder port.ContextMenuBuilder | ||
| 36 | ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory | 36 | ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory | ||
| 37 | + | ContextMenuRenderer ContextMenuRenderer | |||
| 37 | Clipboard port.Clipboard | 38 | Clipboard port.Clipboard | ||
| 38 | ImageDataResolver port.ImageDataResolver | 39 | ImageDataResolver port.ImageDataResolver | ||
| 39 | } | 40 | } | ||
M internal/infrastructure/cef/webview_handlers.go +4 -7
| @@ -2,7 +2,6 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "strings" | |
| 7 | 6 | "sync" | |
| 8 | 7 | ||
| @@ -596,13 +595,11 @@ func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost) | |||
| 596 | 595 | if wv.ctx != nil { | |
| 597 | 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) { | |
| 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 | |
| 605 | 601 | } | |
| 602 | + | wv.runCloseCallbacks() | |
| 606 | 603 | } | |
| 607 | 604 | ||
| 608 | 605 | 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) { | |||
| 35 | 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 | 51 | // stubBrowserHost intentionally only stubs CloseBrowser. The embedded | |
| 39 | 52 | // purecef.BrowserHost may be nil; calling any other BrowserHost method will | |
| 40 | 53 | // 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 embedded | 51 | // stubBrowserHost intentionally only stubs CloseBrowser. The embedded | ||
| 39 | // purecef.BrowserHost may be nil; calling any other BrowserHost method will | 52 | // 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 { | |||
| 25 | 25 | ctx context.Context | |
| 26 | 26 | registered map[KeyBinding]Action | |
| 27 | 27 | lastDispatchAt map[Action]time.Time | |
| 28 | + | // generation is mutated only from the GTK main thread alongside controller replacement. | |
| 29 | + | generation uint64 | |
| 28 | 30 | ||
| 29 | 31 | // Keep references to callbacks to prevent GC from collecting them | |
| 30 | 32 | callbacks []gtk.ShortcutFunc | |
| @@ -184,11 +186,21 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 184 | 186 | return false | |
| 185 | 187 | } | |
| 186 | 188 | ||
| 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. | |
| 189 | 192 | actionToDispatch := action | |
| 193 | + | generation := h.generation | |
| 190 | 194 | callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool { | |
| 191 | 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 | 204 | if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) { | |
| 193 | 205 | log.Trace(). | |
| 194 | 206 | Str("action", string(actionToDispatch)). | |
| @@ -275,6 +287,8 @@ func globalShortcutActionMap() map[string]Action { | |||
| 275 | 287 | // are removed. | |
| 276 | 288 | func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | |
| 277 | 289 | log := logging.FromContext(ctx) | |
| 290 | + | h.ctx = ctx | |
| 291 | + | h.generation++ | |
| 278 | 292 | ||
| 279 | 293 | if h.window == nil || h.controller == nil { | |
| 280 | 294 | return | |
| @@ -358,6 +372,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||
| 358 | 372 | // Note: GTK handles cleanup when the widget is destroyed, | |
| 359 | 373 | // but we clear our references here. | |
| 360 | 374 | func (h *GlobalShortcutHandler) Detach() { | |
| 375 | + | h.generation++ | |
| 361 | 376 | h.controller = nil | |
| 362 | 377 | h.callbacks = nil | |
| 363 | 378 | h.registered = nil | |
| @@ -366,6 +381,10 @@ func (h *GlobalShortcutHandler) Detach() { | |||
| 366 | 381 | ||
| 367 | 382 | 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 | 388 | func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool { | |
| 370 | 389 | if h == nil || !isRepeatedGlobalShortcutSuppressed(action) { | |
| 371 | 390 | return false | |
| @@ -25,6 +25,8 @@ type GlobalShortcutHandler struct { | |||||
| 25 | ctx context.Context | 25 | ctx context.Context | ||
| 26 | registered map[KeyBinding]Action | 26 | registered map[KeyBinding]Action | ||
| 27 | lastDispatchAt map[Action]time.Time | 27 | 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 them | 31 | // Keep references to callbacks to prevent GC from collecting them | ||
| 30 | callbacks []gtk.ShortcutFunc | 32 | callbacks []gtk.ShortcutFunc | ||
| @@ -184,11 +186,21 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||||
| 184 | return false | 186 | return false | ||
| 185 | } | 187 | } | ||
| 186 | 188 | ||||
| 187 | - | // Create callback action | 189 | + | // Create callback action. |
| 188 | - | // We need to capture the action in the closure | 190 | + | // Capture the generation so callbacks from removed/reloaded controllers cannot |
| 191 | + | // dispatch one-shot actions after a shortcut reload. | |||
| 189 | actionToDispatch := action | 192 | 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 | return | 294 | 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 = nil | 376 | h.controller = nil | ||
| 362 | h.callbacks = nil | 377 | h.callbacks = nil | ||
| 363 | h.registered = nil | 378 | h.registered = nil | ||
| @@ -366,6 +381,10 @@ func (h *GlobalShortcutHandler) Detach() { | |||||
| 366 | 381 | ||||
| 367 | const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond | 382 | 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 false | 390 | return false | ||
M internal/ui/input/global_shortcuts_test.go +13 -0
| @@ -46,3 +46,16 @@ func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) | |||
| 46 | 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 | + | } | |
| @@ -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 | |||
| 9 | 9 | YouTube video is playing. | |
| 10 | 10 | ||
| 11 | 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 | 13 | -o, --out DIR Output directory. Default: .dev/dumber/gpu-diagnostics/<timestamp> | |
| 14 | 14 | -n, --samples N amdgpu_top JSON samples. Default: 10 | |
| 15 | 15 | -i, --interval-ms MS amdgpu_top sample interval. Default: 1000 | |
| @@ -65,7 +65,11 @@ while [[ $# -gt 0 ]]; do | |||
| 65 | 65 | done | |
| 66 | 66 | ||
| 67 | 67 | 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 | 73 | fi | |
| 70 | 74 | if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then | |
| 71 | 75 | echo "could not find a live dumber PID; pass --pid" >&2 | |
| @@ -160,11 +164,19 @@ else | |||
| 160 | 164 | log "amdgpu_top not found; skipping GPU samples" | |
| 161 | 165 | fi | |
| 162 | 166 | ||
| 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")" | |
| 165 | 173 | 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" | |
| 168 | 180 | elif [[ -n "$codec_stats" ]]; then | |
| 169 | 181 | printf '%s\n' "$codec_stats" > "$out_dir/youtube_stats_for_nerds.txt" | |
| 170 | 182 | else | |
| @@ -177,8 +189,14 @@ fi | |||
| 177 | 189 | ||
| 178 | 190 | if [[ -n "$devtools_port" ]]; then | |
| 179 | 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" || 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 | |
| 182 | 200 | fi | |
| 183 | 201 | ||
| 184 | 202 | 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: 10 | 14 | -n, --samples N amdgpu_top JSON samples. Default: 10 | ||
| 15 | -i, --interval-ms MS amdgpu_top sample interval. Default: 1000 | 15 | -i, --interval-ms MS amdgpu_top sample interval. Default: 1000 | ||
| @@ -65,7 +65,11 @@ while [[ $# -gt 0 ]]; do | |||||
| 65 | done | 65 | done | ||
| 66 | 66 | ||||
| 67 | if [[ -z "$pid" ]]; then | 67 | 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 | fi | 73 | fi | ||
| 70 | if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then | 74 | if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then | ||
| 71 | echo "could not find a live dumber PID; pass --pid" >&2 | 75 | 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 | fi | 165 | fi | ||
| 162 | 166 | ||||
| 163 | - | if [[ -n "$session_log" && -r "$session_log" ]]; then | 167 | + | 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 | fi | 173 | fi | ||
| 166 | - | if [[ -n "$codec_stats_file" && -r "$codec_stats_file" ]]; then | 174 | + | 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" ]]; then | 180 | 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 | else | 182 | else | ||
| @@ -177,8 +189,14 @@ fi | |||||
| 177 | 189 | ||||
| 178 | if [[ -n "$devtools_port" ]]; then | 190 | 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" || 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 |
| 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 | 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 | |||
| 182 | fi | 200 | fi | ||
| 183 | 201 | ||||
| 184 | log "bundle complete: $out_dir" | 202 | log "bundle complete: $out_dir" | ||