fix: terminate WebKit web processes on pane close to prevent resource leaks
- commit
- b9e91d64fedd2e73811741d77650a7ad02942b20
- author
- brice · · 2025-12-23 22:57 UTC
- committer
- brice · · 2025-12-24 16:11 UTC
- parent
- e736791
- signature
- unsigned
7 files · +105 -37
M CHANGELOG.md +5 -0
| @@ -5,11 +5,16 @@ All notable changes to this project will be documented in this file. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Fixed | |
| 8 | + | - **WebKit process leak on pane close**: Fixed zombie WebKit web processes not being terminated when panes were closed. `WebView.Destroy()` now properly calls `TerminateWebProcess()` and unparents GTK widgets, freeing GPU resources (VA-API, DMA-BUF, GL contexts). This should significantly improve video playback stability and reduce memory usage when opening/closing many panes. | |
| 8 | 9 | - **White flash on navigation**: Eliminated white page flash when navigating in dark mode by setting WebView background color to match theme and hiding WebView until content commits. | |
| 9 | 10 | - **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference. | |
| 10 | 11 | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | |
| 11 | 12 | - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.). | |
| 12 | 13 | ||
| 14 | + | ### Changed | |
| 15 | + | - **GSK renderer default**: Changed default from `vulkan` to `auto` to let GTK choose the best renderer and avoid potential DMA-BUF synchronization conflicts with WebKit. | |
| 16 | + | - **Removed invalid GStreamer config**: Removed `video_buffer_size_mb` and `queue_buffer_time_sec` config options as these were not valid GStreamer environment variables (they are element properties that cannot be set via env vars). | |
| 17 | + | ||
| 13 | 18 | ### Added | |
| 14 | 19 | - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers. | |
| 15 | 20 | - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI. | |
| @@ -5,11 +5,16 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Fixed | 7 | ### Fixed | ||
| 8 | + | - **WebKit process leak on pane close**: Fixed zombie WebKit web processes not being terminated when panes were closed. `WebView.Destroy()` now properly calls `TerminateWebProcess()` and unparents GTK widgets, freeing GPU resources (VA-API, DMA-BUF, GL contexts). This should significantly improve video playback stability and reduce memory usage when opening/closing many panes. | |||
| 8 | - **White flash on navigation**: Eliminated white page flash when navigating in dark mode by setting WebView background color to match theme and hiding WebView until content commits. | 9 | - **White flash on navigation**: Eliminated white page flash when navigating in dark mode by setting WebView background color to match theme and hiding WebView until content commits. | ||
| 9 | - **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference. | 10 | - **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference. | ||
| 10 | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | 11 | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | ||
| 11 | - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.). | 12 | - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.). | ||
| 12 | 13 | ||||
| 14 | + | ### Changed | |||
| 15 | + | - **GSK renderer default**: Changed default from `vulkan` to `auto` to let GTK choose the best renderer and avoid potential DMA-BUF synchronization conflicts with WebKit. | |||
| 16 | + | - **Removed invalid GStreamer config**: Removed `video_buffer_size_mb` and `queue_buffer_time_sec` config options as these were not valid GStreamer environment variables (they are element properties that cannot be set via env vars). | |||
| 17 | + | ||||
| 13 | ### Added | 18 | ### Added | ||
| 14 | - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers. | 19 | - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers. | ||
| 15 | - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI. | 20 | - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI. | ||
M docs/CONFIG.md +6 -14
| @@ -182,7 +182,7 @@ border = "#363636" | |||
| 182 | 182 | | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) | | |
| 183 | 183 | | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) | | |
| 184 | 184 | | `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) | | |
| 185 | - | | `rendering.gsk_renderer` | string | `"vulkan"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) | | |
| 185 | + | | `rendering.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) | | |
| 186 | 186 | | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) | | |
| 187 | 187 | | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) | | |
| 188 | 188 | | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) | | |
| @@ -282,13 +282,11 @@ cancel = ["escape"] | |||
| 282 | 282 | | Key | Type | Default | Valid Values | Description | | |
| 283 | 283 | |-----|------|---------|--------------|-------------| | |
| 284 | 284 | | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode | | |
| 285 | - | | `media.prefer_av1` | bool | `true` | - | Prefer AV1 codec when available | | |
| 286 | - | | `media.show_diagnostics` | bool | `true` | - | Show media diagnostics warnings at startup | | |
| 285 | + | | `media.prefer_av1` | bool | `false` | - | Prefer AV1 codec when available | | |
| 286 | + | | `media.show_diagnostics` | bool | `false` | - | Show media diagnostics warnings at startup | | |
| 287 | 287 | | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) | | |
| 288 | 288 | | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering | | |
| 289 | 289 | | `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) | | |
| 290 | - | | `media.video_buffer_size_mb` | int | `64` | > 0 | Video buffer size in MB for smoother streaming | | |
| 291 | - | | `media.queue_buffer_time_sec` | int | `20` | > 0 | Queue prebuffer time in seconds | | |
| 292 | 290 | ||
| 293 | 291 | **Hardware decoding modes:** | |
| 294 | 292 | - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000 | |
| @@ -307,21 +305,15 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal | |||
| 307 | 305 | - **Intel**: Uses `iHD` driver (modern, for Broadwell+) | |
| 308 | 306 | - **NVIDIA**: Uses `nvidia` driver with EGL platform | |
| 309 | 307 | ||
| 310 | - | **Buffering settings:** | |
| 311 | - | - `video_buffer_size_mb`: Controls GStreamer buffer size. Larger buffers reduce rebuffering on bursty streams (Twitch, YouTube). Uses more memory. | |
| 312 | - | - `queue_buffer_time_sec`: Controls prebuffer duration. Higher values allow more data to be buffered ahead of playback. | |
| 313 | - | ||
| 314 | 308 | **Example:** | |
| 315 | 309 | ```toml | |
| 316 | 310 | [media] | |
| 317 | 311 | hardware_decoding = "auto" # HW preferred, SW fallback | |
| 318 | - | prefer_av1 = true # AV1 is most efficient codec | |
| 319 | - | show_diagnostics = true # Log warnings if HW accel unavailable | |
| 312 | + | prefer_av1 = false # Let site choose codec | |
| 313 | + | show_diagnostics = false # Enable for debugging | |
| 320 | 314 | force_vsync = false # Let compositor handle VSync | |
| 321 | 315 | gl_rendering_mode = "auto" # GStreamer picks best GL API | |
| 322 | 316 | gstreamer_debug_level = 0 # Increase to 3-5 for debugging | |
| 323 | - | video_buffer_size_mb = 64 # 64 MB buffer for smooth streaming | |
| 324 | - | queue_buffer_time_sec = 20 # 20 seconds prebuffer | |
| 325 | 317 | ``` | |
| 326 | 318 | ||
| 327 | 319 | **Diagnostics CLI:** | |
| @@ -368,7 +360,7 @@ All config values can be overridden via environment variables with the prefix `D | |||
| 368 | 360 | DUMBER_DATABASE_PATH=/custom/path/db.sqlite | |
| 369 | 361 | ||
| 370 | 362 | # Rendering (examples) | |
| 371 | - | # Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="vulkan" | |
| 363 | + | # Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto" | |
| 372 | 364 | DUMBER_RENDERING_MODE=cpu | |
| 373 | 365 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | |
| 374 | 366 | # DUMBER_RENDERING_GSK_RENDERER=opengl | |
| @@ -182,7 +182,7 @@ border = "#363636" | |||||
| 182 | | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) | | 182 | | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) | | ||
| 183 | | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) | | 183 | | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) | | ||
| 184 | | `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) | | 184 | | `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) | | ||
| 185 | - | | `rendering.gsk_renderer` | string | `"vulkan"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) | | 185 | + | | `rendering.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) | |
| 186 | | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) | | 186 | | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) | | ||
| 187 | | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) | | 187 | | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) | | ||
| 188 | | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) | | 188 | | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) | | ||
| @@ -282,13 +282,11 @@ cancel = ["escape"] | |||||
| 282 | | Key | Type | Default | Valid Values | Description | | 282 | | Key | Type | Default | Valid Values | Description | | ||
| 283 | |-----|------|---------|--------------|-------------| | 283 | |-----|------|---------|--------------|-------------| | ||
| 284 | | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode | | 284 | | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode | | ||
| 285 | - | | `media.prefer_av1` | bool | `true` | - | Prefer AV1 codec when available | | 285 | + | | `media.prefer_av1` | bool | `false` | - | Prefer AV1 codec when available | |
| 286 | - | | `media.show_diagnostics` | bool | `true` | - | Show media diagnostics warnings at startup | | 286 | + | | `media.show_diagnostics` | bool | `false` | - | Show media diagnostics warnings at startup | |
| 287 | | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) | | 287 | | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) | | ||
| 288 | | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering | | 288 | | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering | | ||
| 289 | | `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) | | 289 | | `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) | | ||
| 290 | - | | `media.video_buffer_size_mb` | int | `64` | > 0 | Video buffer size in MB for smoother streaming | | |||
| 291 | - | | `media.queue_buffer_time_sec` | int | `20` | > 0 | Queue prebuffer time in seconds | | |||
| 292 | 290 | ||||
| 293 | **Hardware decoding modes:** | 291 | **Hardware decoding modes:** | ||
| 294 | - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000 | 292 | - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000 | ||
| @@ -307,21 +305,15 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal | |||||
| 307 | - **Intel**: Uses `iHD` driver (modern, for Broadwell+) | 305 | - **Intel**: Uses `iHD` driver (modern, for Broadwell+) | ||
| 308 | - **NVIDIA**: Uses `nvidia` driver with EGL platform | 306 | - **NVIDIA**: Uses `nvidia` driver with EGL platform | ||
| 309 | 307 | ||||
| 310 | - | **Buffering settings:** | |||
| 311 | - | - `video_buffer_size_mb`: Controls GStreamer buffer size. Larger buffers reduce rebuffering on bursty streams (Twitch, YouTube). Uses more memory. | |||
| 312 | - | - `queue_buffer_time_sec`: Controls prebuffer duration. Higher values allow more data to be buffered ahead of playback. | |||
| 313 | - | ||||
| 314 | **Example:** | 308 | **Example:** | ||
| 315 | ```toml | 309 | ```toml | ||
| 316 | [media] | 310 | [media] | ||
| 317 | hardware_decoding = "auto" # HW preferred, SW fallback | 311 | hardware_decoding = "auto" # HW preferred, SW fallback | ||
| 318 | - | prefer_av1 = true # AV1 is most efficient codec | 312 | + | prefer_av1 = false # Let site choose codec |
| 319 | - | show_diagnostics = true # Log warnings if HW accel unavailable | 313 | + | show_diagnostics = false # Enable for debugging |
| 320 | force_vsync = false # Let compositor handle VSync | 314 | force_vsync = false # Let compositor handle VSync | ||
| 321 | gl_rendering_mode = "auto" # GStreamer picks best GL API | 315 | gl_rendering_mode = "auto" # GStreamer picks best GL API | ||
| 322 | gstreamer_debug_level = 0 # Increase to 3-5 for debugging | 316 | gstreamer_debug_level = 0 # Increase to 3-5 for debugging | ||
| 323 | - | video_buffer_size_mb = 64 # 64 MB buffer for smooth streaming | |||
| 324 | - | queue_buffer_time_sec = 20 # 20 seconds prebuffer | |||
| 325 | ``` | 317 | ``` | ||
| 326 | 318 | ||||
| 327 | **Diagnostics CLI:** | 319 | **Diagnostics CLI:** | ||
| @@ -368,7 +360,7 @@ All config values can be overridden via environment variables with the prefix `D | |||||
| 368 | DUMBER_DATABASE_PATH=/custom/path/db.sqlite | 360 | DUMBER_DATABASE_PATH=/custom/path/db.sqlite | ||
| 369 | 361 | ||||
| 370 | # Rendering (examples) | 362 | # Rendering (examples) | ||
| 371 | - | # Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="vulkan" | 363 | + | # Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto" |
| 372 | DUMBER_RENDERING_MODE=cpu | 364 | DUMBER_RENDERING_MODE=cpu | ||
| 373 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | 365 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | ||
| 374 | # DUMBER_RENDERING_GSK_RENDERER=opengl | 366 | # DUMBER_RENDERING_GSK_RENDERER=opengl | ||
M internal/infrastructure/config/defaults.go +5 -5
| @@ -117,7 +117,7 @@ func DefaultConfig() *Config { | |||
| 117 | 117 | DisableDMABufRenderer: false, | |
| 118 | 118 | ForceCompositingMode: false, | |
| 119 | 119 | DisableCompositingMode: false, | |
| 120 | - | GSKRenderer: GSKRendererVulkan, | |
| 120 | + | GSKRenderer: GSKRendererAuto, // Let GTK choose - Vulkan can conflict with WebKit's DMA-BUF | |
| 121 | 121 | DisableMipmaps: false, | |
| 122 | 122 | PreferGL: false, | |
| 123 | 123 | DrawCompositingIndicators: false, | |
| @@ -194,13 +194,13 @@ func DefaultConfig() *Config { | |||
| 194 | 194 | }, | |
| 195 | 195 | Media: MediaConfig{ | |
| 196 | 196 | HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback | |
| 197 | - | PreferAV1: true, // AV1 is most efficient codec | |
| 198 | - | ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable | |
| 197 | + | PreferAV1: false, // Don't force codec preference, let site choose | |
| 198 | + | ShowDiagnosticsOnStartup: false, // Disabled - diagnostics can be noisy | |
| 199 | 199 | ForceVSync: false, // Let compositor handle VSync | |
| 200 | 200 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | |
| 201 | 201 | GStreamerDebugLevel: 0, // Disabled by default | |
| 202 | - | VideoBufferSizeMB: 64, // Larger buffer for bursty streams (Twitch, YouTube) | |
| 203 | - | QueueBufferTimeSec: 20, // More prebuffering for smooth playback | |
| 202 | + | VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed | |
| 203 | + | QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed | |
| 204 | 204 | }, | |
| 205 | 205 | Runtime: RuntimeConfig{ | |
| 206 | 206 | Prefix: "", | |
| @@ -117,7 +117,7 @@ func DefaultConfig() *Config { | |||||
| 117 | DisableDMABufRenderer: false, | 117 | DisableDMABufRenderer: false, | ||
| 118 | ForceCompositingMode: false, | 118 | ForceCompositingMode: false, | ||
| 119 | DisableCompositingMode: false, | 119 | DisableCompositingMode: false, | ||
| 120 | - | GSKRenderer: GSKRendererVulkan, | 120 | + | GSKRenderer: GSKRendererAuto, // Let GTK choose - Vulkan can conflict with WebKit's DMA-BUF |
| 121 | DisableMipmaps: false, | 121 | DisableMipmaps: false, | ||
| 122 | PreferGL: false, | 122 | PreferGL: false, | ||
| 123 | DrawCompositingIndicators: false, | 123 | DrawCompositingIndicators: false, | ||
| @@ -194,13 +194,13 @@ func DefaultConfig() *Config { | |||||
| 194 | }, | 194 | }, | ||
| 195 | Media: MediaConfig{ | 195 | Media: MediaConfig{ | ||
| 196 | HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback | 196 | HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback | ||
| 197 | - | PreferAV1: true, // AV1 is most efficient codec | 197 | + | PreferAV1: false, // Don't force codec preference, let site choose |
| 198 | - | ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable | 198 | + | ShowDiagnosticsOnStartup: false, // Disabled - diagnostics can be noisy |
| 199 | ForceVSync: false, // Let compositor handle VSync | 199 | ForceVSync: false, // Let compositor handle VSync | ||
| 200 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | 200 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | ||
| 201 | GStreamerDebugLevel: 0, // Disabled by default | 201 | GStreamerDebugLevel: 0, // Disabled by default | ||
| 202 | - | VideoBufferSizeMB: 64, // Larger buffer for bursty streams (Twitch, YouTube) | 202 | + | VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed |
| 203 | - | QueueBufferTimeSec: 20, // More prebuffering for smooth playback | 203 | + | QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed |
| 204 | }, | 204 | }, | ||
| 205 | Runtime: RuntimeConfig{ | 205 | Runtime: RuntimeConfig{ | ||
| 206 | Prefix: "", | 206 | Prefix: "", | ||
M internal/infrastructure/env/manager.go +4 -12
| @@ -91,18 +91,10 @@ func (m *Manager) applyGStreamerEnv(settings port.RenderingEnvSettings) { | |||
| 91 | 91 | m.setEnv("GST_DEBUG", fmt.Sprintf("%d", level)) | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | - | // Video buffer size | |
| 95 | - | if settings.VideoBufferSizeMB > 0 { | |
| 96 | - | bufferBytes := settings.VideoBufferSizeMB * 1024 * 1024 | |
| 97 | - | m.setEnv("GST_BUFFER_SIZE", fmt.Sprintf("%d", bufferBytes)) | |
| 98 | - | m.setEnv("GST_QUEUE2_MAX_SIZE_BYTES", fmt.Sprintf("%d", bufferBytes)) | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | // Queue buffer time | |
| 102 | - | if settings.QueueBufferTimeSec > 0 { | |
| 103 | - | bufferTimeNs := settings.QueueBufferTimeSec * 1_000_000_000 | |
| 104 | - | m.setEnv("GST_QUEUE2_MAX_SIZE_TIME", fmt.Sprintf("%d", bufferTimeNs)) | |
| 105 | - | } | |
| 94 | + | // NOTE: GST_BUFFER_SIZE, GST_QUEUE2_MAX_SIZE_BYTES, GST_QUEUE2_MAX_SIZE_TIME | |
| 95 | + | // are NOT valid GStreamer environment variables. They are element properties | |
| 96 | + | // that must be set programmatically on individual GStreamer elements. | |
| 97 | + | // WebKitGTK manages its own GStreamer pipeline internally. | |
| 106 | 98 | } | |
| 107 | 99 | ||
| 108 | 100 | func (m *Manager) applyGPUSpecificEnv() { | |
| @@ -91,18 +91,10 @@ func (m *Manager) applyGStreamerEnv(settings port.RenderingEnvSettings) { | |||||
| 91 | m.setEnv("GST_DEBUG", fmt.Sprintf("%d", level)) | 91 | m.setEnv("GST_DEBUG", fmt.Sprintf("%d", level)) | ||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | - | // Video buffer size | 94 | + | // NOTE: GST_BUFFER_SIZE, GST_QUEUE2_MAX_SIZE_BYTES, GST_QUEUE2_MAX_SIZE_TIME |
| 95 | - | if settings.VideoBufferSizeMB > 0 { | 95 | + | // are NOT valid GStreamer environment variables. They are element properties |
| 96 | - | bufferBytes := settings.VideoBufferSizeMB * 1024 * 1024 | 96 | + | // that must be set programmatically on individual GStreamer elements. |
| 97 | - | m.setEnv("GST_BUFFER_SIZE", fmt.Sprintf("%d", bufferBytes)) | 97 | + | // WebKitGTK manages its own GStreamer pipeline internally. |
| 98 | - | m.setEnv("GST_QUEUE2_MAX_SIZE_BYTES", fmt.Sprintf("%d", bufferBytes)) | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | // Queue buffer time | |||
| 102 | - | if settings.QueueBufferTimeSec > 0 { | |||
| 103 | - | bufferTimeNs := settings.QueueBufferTimeSec * 1_000_000_000 | |||
| 104 | - | m.setEnv("GST_QUEUE2_MAX_SIZE_TIME", fmt.Sprintf("%d", bufferTimeNs)) | |||
| 105 | - | } | |||
| 106 | } | 98 | } | ||
| 107 | 99 | ||||
| 108 | func (m *Manager) applyGPUSpecificEnv() { | 100 | func (m *Manager) applyGPUSpecificEnv() { | ||
M internal/infrastructure/webkit/webview.go +41 -4
| @@ -856,19 +856,56 @@ func (wv *WebView) DisconnectSignals() { | |||
| 856 | 856 | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected") | |
| 857 | 857 | } | |
| 858 | 858 | ||
| 859 | - | // Destroy cleans up the WebView resources. | |
| 859 | + | // Destroy cleans up the WebView resources and terminates the web process. | |
| 860 | + | // This must be called when a WebView is no longer needed to free GPU resources, | |
| 861 | + | // VA-API decoder contexts, and DMA-BUF buffers held by the web process. | |
| 860 | 862 | func (wv *WebView) Destroy() { | |
| 861 | 863 | if wv.destroyed.Swap(true) { | |
| 862 | 864 | return // Already destroyed | |
| 863 | 865 | } | |
| 864 | 866 | ||
| 865 | - | // Disconnect all signal handlers first | |
| 867 | + | // 1. Disconnect all signal handlers first to prevent callbacks during cleanup | |
| 866 | 868 | wv.DisconnectSignals() | |
| 867 | 869 | ||
| 868 | - | // Unregister from global registry | |
| 870 | + | // 2. Clear all callbacks to prevent use-after-free | |
| 871 | + | wv.OnLoadChanged = nil | |
| 872 | + | wv.OnTitleChanged = nil | |
| 873 | + | wv.OnURIChanged = nil | |
| 874 | + | wv.OnProgressChanged = nil | |
| 875 | + | wv.OnFaviconChanged = nil | |
| 876 | + | wv.OnClose = nil | |
| 877 | + | wv.OnCreate = nil | |
| 878 | + | wv.OnReadyToShow = nil | |
| 879 | + | wv.OnLinkMiddleClick = nil | |
| 880 | + | wv.OnEnterFullscreen = nil | |
| 881 | + | wv.OnLeaveFullscreen = nil | |
| 882 | + | ||
| 883 | + | // 3. Clear async callback references | |
| 884 | + | wv.mu.Lock() | |
| 885 | + | wv.asyncCallbacks = nil | |
| 886 | + | wv.mu.Unlock() | |
| 887 | + | ||
| 888 | + | // 4. Unparent from GTK hierarchy (must happen before process termination) | |
| 889 | + | // This releases the widget from any parent container. | |
| 890 | + | if wv.inner != nil { | |
| 891 | + | wv.inner.Unparent() | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts) | |
| 895 | + | // This is critical to prevent zombie processes that hold video decoder resources. | |
| 896 | + | if wv.inner != nil { | |
| 897 | + | wv.inner.TerminateWebProcess() | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | // 6. Unregister from global registry | |
| 869 | 901 | globalRegistry.unregister(wv.id) | |
| 870 | 902 | ||
| 871 | - | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed") | |
| 903 | + | // 7. Clear internal references to allow GC | |
| 904 | + | wv.inner = nil | |
| 905 | + | wv.ucm = nil | |
| 906 | + | wv.findController = nil | |
| 907 | + | ||
| 908 | + | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated") | |
| 872 | 909 | } | |
| 873 | 910 | ||
| 874 | 911 | // RunJavaScript executes script in the specified world (empty for main world). | |
| @@ -856,19 +856,56 @@ func (wv *WebView) DisconnectSignals() { | |||||
| 856 | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected") | 856 | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected") | ||
| 857 | } | 857 | } | ||
| 858 | 858 | ||||
| 859 | - | // Destroy cleans up the WebView resources. | 859 | + | // Destroy cleans up the WebView resources and terminates the web process. |
| 860 | + | // This must be called when a WebView is no longer needed to free GPU resources, | |||
| 861 | + | // VA-API decoder contexts, and DMA-BUF buffers held by the web process. | |||
| 860 | func (wv *WebView) Destroy() { | 862 | func (wv *WebView) Destroy() { | ||
| 861 | if wv.destroyed.Swap(true) { | 863 | if wv.destroyed.Swap(true) { | ||
| 862 | return // Already destroyed | 864 | return // Already destroyed | ||
| 863 | } | 865 | } | ||
| 864 | 866 | ||||
| 865 | - | // Disconnect all signal handlers first | 867 | + | // 1. Disconnect all signal handlers first to prevent callbacks during cleanup |
| 866 | wv.DisconnectSignals() | 868 | wv.DisconnectSignals() | ||
| 867 | 869 | ||||
| 868 | - | // Unregister from global registry | 870 | + | // 2. Clear all callbacks to prevent use-after-free |
| 871 | + | wv.OnLoadChanged = nil | |||
| 872 | + | wv.OnTitleChanged = nil | |||
| 873 | + | wv.OnURIChanged = nil | |||
| 874 | + | wv.OnProgressChanged = nil | |||
| 875 | + | wv.OnFaviconChanged = nil | |||
| 876 | + | wv.OnClose = nil | |||
| 877 | + | wv.OnCreate = nil | |||
| 878 | + | wv.OnReadyToShow = nil | |||
| 879 | + | wv.OnLinkMiddleClick = nil | |||
| 880 | + | wv.OnEnterFullscreen = nil | |||
| 881 | + | wv.OnLeaveFullscreen = nil | |||
| 882 | + | ||||
| 883 | + | // 3. Clear async callback references | |||
| 884 | + | wv.mu.Lock() | |||
| 885 | + | wv.asyncCallbacks = nil | |||
| 886 | + | wv.mu.Unlock() | |||
| 887 | + | ||||
| 888 | + | // 4. Unparent from GTK hierarchy (must happen before process termination) | |||
| 889 | + | // This releases the widget from any parent container. | |||
| 890 | + | if wv.inner != nil { | |||
| 891 | + | wv.inner.Unparent() | |||
| 892 | + | } | |||
| 893 | + | ||||
| 894 | + | // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts) | |||
| 895 | + | // This is critical to prevent zombie processes that hold video decoder resources. | |||
| 896 | + | if wv.inner != nil { | |||
| 897 | + | wv.inner.TerminateWebProcess() | |||
| 898 | + | } | |||
| 899 | + | ||||
| 900 | + | // 6. Unregister from global registry | |||
| 869 | globalRegistry.unregister(wv.id) | 901 | globalRegistry.unregister(wv.id) | ||
| 870 | 902 | ||||
| 871 | - | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed") | 903 | + | // 7. Clear internal references to allow GC |
| 904 | + | wv.inner = nil | |||
| 905 | + | wv.ucm = nil | |||
| 906 | + | wv.findController = nil | |||
| 907 | + | ||||
| 908 | + | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated") | |||
| 872 | } | 909 | } | ||
| 873 | 910 | ||||
| 874 | // RunJavaScript executes script in the specified world (empty for main world). | 911 | // RunJavaScript executes script in the specified world (empty for main world). | ||
M internal/ui/component/pane_view.go +35 -0
| @@ -349,3 +349,38 @@ func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) { | |||
| 349 | 349 | ||
| 350 | 350 | t.ShowZoom(ctx, zoomPercent) | |
| 351 | 351 | } | |
| 352 | + | ||
| 353 | + | // Cleanup removes the WebView widget from the overlay and clears references. | |
| 354 | + | // This must be called before destroying the WebView to ensure proper GTK cleanup. | |
| 355 | + | // After calling Cleanup, the PaneView should not be reused. | |
| 356 | + | func (pv *PaneView) Cleanup() { | |
| 357 | + | pv.mu.Lock() | |
| 358 | + | defer pv.mu.Unlock() | |
| 359 | + | ||
| 360 | + | // Clear callbacks to prevent use-after-free | |
| 361 | + | pv.onFocusIn = nil | |
| 362 | + | pv.onFocusOut = nil | |
| 363 | + | pv.onHover = nil | |
| 364 | + | ||
| 365 | + | // Detach hover handler if present | |
| 366 | + | if pv.hoverHandler != nil { | |
| 367 | + | pv.hoverHandler.Detach() | |
| 368 | + | pv.hoverHandler = nil | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // Remove WebView from overlay (unparents it from GTK hierarchy) | |
| 372 | + | if pv.webViewWidget != nil { | |
| 373 | + | pv.overlay.SetChild(nil) | |
| 374 | + | pv.webViewWidget = nil | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | // Clear other overlay children | |
| 378 | + | if pv.progressBar != nil { | |
| 379 | + | pv.overlay.RemoveOverlay(pv.progressBar.Widget()) | |
| 380 | + | pv.progressBar = nil | |
| 381 | + | } | |
| 382 | + | if pv.toaster != nil { | |
| 383 | + | pv.overlay.RemoveOverlay(pv.toaster.Widget()) | |
| 384 | + | pv.toaster = nil | |
| 385 | + | } | |
| 386 | + | } | |
| @@ -349,3 +349,38 @@ func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) { | |||||
| 349 | 349 | ||||
| 350 | t.ShowZoom(ctx, zoomPercent) | 350 | t.ShowZoom(ctx, zoomPercent) | ||
| 351 | } | 351 | } | ||
| 352 | + | ||||
| 353 | + | // Cleanup removes the WebView widget from the overlay and clears references. | |||
| 354 | + | // This must be called before destroying the WebView to ensure proper GTK cleanup. | |||
| 355 | + | // After calling Cleanup, the PaneView should not be reused. | |||
| 356 | + | func (pv *PaneView) Cleanup() { | |||
| 357 | + | pv.mu.Lock() | |||
| 358 | + | defer pv.mu.Unlock() | |||
| 359 | + | ||||
| 360 | + | // Clear callbacks to prevent use-after-free | |||
| 361 | + | pv.onFocusIn = nil | |||
| 362 | + | pv.onFocusOut = nil | |||
| 363 | + | pv.onHover = nil | |||
| 364 | + | ||||
| 365 | + | // Detach hover handler if present | |||
| 366 | + | if pv.hoverHandler != nil { | |||
| 367 | + | pv.hoverHandler.Detach() | |||
| 368 | + | pv.hoverHandler = nil | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // Remove WebView from overlay (unparents it from GTK hierarchy) | |||
| 372 | + | if pv.webViewWidget != nil { | |||
| 373 | + | pv.overlay.SetChild(nil) | |||
| 374 | + | pv.webViewWidget = nil | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | // Clear other overlay children | |||
| 378 | + | if pv.progressBar != nil { | |||
| 379 | + | pv.overlay.RemoveOverlay(pv.progressBar.Widget()) | |||
| 380 | + | pv.progressBar = nil | |||
| 381 | + | } | |||
| 382 | + | if pv.toaster != nil { | |||
| 383 | + | pv.overlay.RemoveOverlay(pv.toaster.Widget()) | |||
| 384 | + | pv.toaster = nil | |||
| 385 | + | } | |||
| 386 | + | } | |||
M internal/ui/component/workspace_view.go +9 -2
| @@ -379,12 +379,19 @@ func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) { | |||
| 379 | 379 | wv.updateSinglePaneModeInternal() | |
| 380 | 380 | } | |
| 381 | 381 | ||
| 382 | - | // UnregisterPaneView removes a PaneView from the tracking map. | |
| 383 | - | // Use this when closing a pane incrementally. | |
| 382 | + | // UnregisterPaneView removes a PaneView from the tracking map and cleans it up. | |
| 383 | + | // This must be called when closing a pane to properly release GTK resources | |
| 384 | + | // before destroying the WebView. | |
| 384 | 385 | func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) { | |
| 385 | 386 | wv.mu.Lock() | |
| 386 | 387 | defer wv.mu.Unlock() | |
| 387 | 388 | ||
| 389 | + | // Call Cleanup on the PaneView to unparent widgets and clear callbacks | |
| 390 | + | // This must happen before WebView.Destroy() is called | |
| 391 | + | if pv, ok := wv.paneViews[paneID]; ok && pv != nil { | |
| 392 | + | pv.Cleanup() | |
| 393 | + | } | |
| 394 | + | ||
| 388 | 395 | delete(wv.paneViews, paneID) | |
| 389 | 396 | wv.updateSinglePaneModeInternal() | |
| 390 | 397 | } | |
| @@ -379,12 +379,19 @@ func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) { | |||||
| 379 | wv.updateSinglePaneModeInternal() | 379 | wv.updateSinglePaneModeInternal() | ||
| 380 | } | 380 | } | ||
| 381 | 381 | ||||
| 382 | - | // UnregisterPaneView removes a PaneView from the tracking map. | 382 | + | // UnregisterPaneView removes a PaneView from the tracking map and cleans it up. |
| 383 | - | // Use this when closing a pane incrementally. | 383 | + | // This must be called when closing a pane to properly release GTK resources |
| 384 | + | // before destroying the WebView. | |||
| 384 | func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) { | 385 | func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) { | ||
| 385 | wv.mu.Lock() | 386 | wv.mu.Lock() | ||
| 386 | defer wv.mu.Unlock() | 387 | defer wv.mu.Unlock() | ||
| 387 | 388 | ||||
| 389 | + | // Call Cleanup on the PaneView to unparent widgets and clear callbacks | |||
| 390 | + | // This must happen before WebView.Destroy() is called | |||
| 391 | + | if pv, ok := wv.paneViews[paneID]; ok && pv != nil { | |||
| 392 | + | pv.Cleanup() | |||
| 393 | + | } | |||
| 394 | + | ||||
| 388 | delete(wv.paneViews, paneID) | 395 | delete(wv.paneViews, paneID) | ||
| 389 | wv.updateSinglePaneModeInternal() | 396 | wv.updateSinglePaneModeInternal() | ||
| 390 | } | 397 | } | ||