Merge pull request #65 from bnema/feature/issue-62-webkitgtk-performance-tuning
feat: WebKitGTK performance profile tuning
26 files · +3094 -41
M .mockery.yaml +3 -0
| @@ -18,6 +18,9 @@ packages: | |||
| 18 | 18 | WebView: {} | |
| 19 | 19 | WebViewFactory: {} | |
| 20 | 20 | WebViewPool: {} | |
| 21 | + | MemoryPressureApplier: {} | |
| 22 | + | WebKitContextProvider: {} | |
| 23 | + | HardwareSurveyor: {} | |
| 21 | 24 | github.com/bnema/dumber/internal/domain/repository: | |
| 22 | 25 | config: | |
| 23 | 26 | dir: '{{.InterfaceDir}}/mocks' | |
| @@ -18,6 +18,9 @@ packages: | |||||
| 18 | WebView: {} | 18 | WebView: {} | ||
| 19 | WebViewFactory: {} | 19 | WebViewFactory: {} | ||
| 20 | WebViewPool: {} | 20 | WebViewPool: {} | ||
| 21 | + | MemoryPressureApplier: {} | |||
| 22 | + | WebKitContextProvider: {} | |||
| 23 | + | HardwareSurveyor: {} | |||
| 21 | github.com/bnema/dumber/internal/domain/repository: | 24 | github.com/bnema/dumber/internal/domain/repository: | ||
| 22 | config: | 25 | config: | ||
| 23 | dir: '{{.InterfaceDir}}/mocks' | 26 | dir: '{{.InterfaceDir}}/mocks' | ||
M docs/CONFIG.md +90 -0
| @@ -491,6 +491,96 @@ dumber config migrate | |||
| 491 | 491 | dumber config migrate --yes | |
| 492 | 492 | ``` | |
| 493 | 493 | ||
| 494 | + | ## Performance Profiles | |
| 495 | + | ||
| 496 | + | Performance profiles provide preset configurations for WebKitGTK tuning. These settings affect Skia rendering threads, memory pressure handling, and WebView pool behavior. | |
| 497 | + | ||
| 498 | + | > **Note:** Performance settings are applied at browser startup. Changes require a restart to take effect. | |
| 499 | + | ||
| 500 | + | | Key | Type | Default | Valid Values | Description | | |
| 501 | + | |-----|------|---------|--------------|-------------| | |
| 502 | + | | `performance.profile` | string | `"default"` | `default`, `lite`, `max`, `custom` | Performance profile selection | | |
| 503 | + | ||
| 504 | + | ### Profiles | |
| 505 | + | ||
| 506 | + | | Profile | Description | Use Case | | |
| 507 | + | |---------|-------------|----------| | |
| 508 | + | | `default` | No tuning, uses WebKit defaults | Normal browsing, most users | | |
| 509 | + | | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving | | |
| 510 | + | | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems | | |
| 511 | + | | `custom` | Manual control over all settings | Advanced users who want fine-grained control | | |
| 512 | + | ||
| 513 | + | ### Profile Settings Matrix | |
| 514 | + | ||
| 515 | + | | Setting | default | lite | max | | |
| 516 | + | |---------|---------|------|-----| | |
| 517 | + | | Skia CPU threads | unset | 2 | `NumCPU()/2` (min 4) | | |
| 518 | + | | Skia GPU threads | unset | unset | 2 | | |
| 519 | + | | Web process memory (MB) | unset | 512 | 2048 | | |
| 520 | + | | Network process memory (MB) | unset | 256 | 512 | | |
| 521 | + | | Conservative threshold | unset | 0.25 | 0.5 | | |
| 522 | + | | Strict threshold | unset | 0.4 | 0.7 | | |
| 523 | + | | Kill threshold | unset | 0.8 | unset (never) | | |
| 524 | + | | WebView pool prewarm | 4 | 2 | 8 | | |
| 525 | + | ||
| 526 | + | **Example:** | |
| 527 | + | ```toml | |
| 528 | + | [performance] | |
| 529 | + | profile = "default" # Most users - no tuning needed | |
| 530 | + | ||
| 531 | + | # For low-RAM systems: | |
| 532 | + | # profile = "lite" | |
| 533 | + | ||
| 534 | + | # For heavy pages (GitHub PRs, complex web apps): | |
| 535 | + | # profile = "max" | |
| 536 | + | ||
| 537 | + | # For manual control: | |
| 538 | + | # profile = "custom" | |
| 539 | + | ``` | |
| 540 | + | ||
| 541 | + | ### Custom Profile Settings | |
| 542 | + | ||
| 543 | + | When `profile = "custom"`, you can configure individual tuning options: | |
| 544 | + | ||
| 545 | + | | Key | Type | Default | Description | | |
| 546 | + | |-----|------|---------|-------------| | |
| 547 | + | | `performance.skia_cpu_painting_threads` | int | `0` | Skia CPU rendering threads (0=unset) | | |
| 548 | + | | `performance.skia_gpu_painting_threads` | int | `-1` | Skia GPU rendering threads (-1=unset, 0=disable) | | |
| 549 | + | | `performance.skia_enable_cpu_rendering` | bool | `false` | Force CPU rendering | | |
| 550 | + | | `performance.web_process_memory_limit_mb` | int | `0` | Web process memory limit in MB (0=unset) | | |
| 551 | + | | `performance.web_process_memory_poll_interval_sec` | float | `0` | Memory check interval (0=WebKit default: 30s) | | |
| 552 | + | | `performance.web_process_memory_conservative_threshold` | float | `0` | Conservative cleanup threshold (0=unset) | | |
| 553 | + | | `performance.web_process_memory_strict_threshold` | float | `0` | Strict cleanup threshold (0=unset) | | |
| 554 | + | | `performance.web_process_memory_kill_threshold` | float | `-1` | Process kill threshold (-1=unset, 0=never kill) | | |
| 555 | + | | `performance.network_process_memory_limit_mb` | int | `0` | Network process memory limit in MB | | |
| 556 | + | | `performance.network_process_memory_poll_interval_sec` | float | `0` | Network memory check interval | | |
| 557 | + | | `performance.network_process_memory_conservative_threshold` | float | `0` | Network conservative threshold | | |
| 558 | + | | `performance.network_process_memory_strict_threshold` | float | `0` | Network strict threshold | | |
| 559 | + | | `performance.network_process_memory_kill_threshold` | float | `-1` | Network process kill threshold | | |
| 560 | + | | `performance.webview_pool_prewarm_count` | int | `4` | WebViews to pre-create at startup | | |
| 561 | + | | `performance.zoom_cache_size` | int | `256` | Domain zoom levels to cache | | |
| 562 | + | ||
| 563 | + | **Custom profile example:** | |
| 564 | + | ```toml | |
| 565 | + | [performance] | |
| 566 | + | profile = "custom" | |
| 567 | + | ||
| 568 | + | # Skia threading - tune for your CPU | |
| 569 | + | skia_cpu_painting_threads = 4 | |
| 570 | + | skia_gpu_painting_threads = 2 | |
| 571 | + | ||
| 572 | + | # Web process memory pressure | |
| 573 | + | web_process_memory_limit_mb = 1024 | |
| 574 | + | web_process_memory_conservative_threshold = 0.4 | |
| 575 | + | web_process_memory_strict_threshold = 0.6 | |
| 576 | + | web_process_memory_kill_threshold = -1 # Never kill | |
| 577 | + | ||
| 578 | + | # WebView pool | |
| 579 | + | webview_pool_prewarm_count = 6 | |
| 580 | + | ``` | |
| 581 | + | ||
| 582 | + | > **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning. | |
| 583 | + | ||
| 494 | 584 | ## Environment Variables | |
| 495 | 585 | ||
| 496 | 586 | All config values can be overridden via environment variables with the prefix `DUMBER_`: | |
| @@ -491,6 +491,96 @@ dumber config migrate | |||||
| 491 | dumber config migrate --yes | 491 | dumber config migrate --yes | ||
| 492 | ``` | 492 | ``` | ||
| 493 | 493 | ||||
| 494 | + | ## Performance Profiles | |||
| 495 | + | ||||
| 496 | + | Performance profiles provide preset configurations for WebKitGTK tuning. These settings affect Skia rendering threads, memory pressure handling, and WebView pool behavior. | |||
| 497 | + | ||||
| 498 | + | > **Note:** Performance settings are applied at browser startup. Changes require a restart to take effect. | |||
| 499 | + | ||||
| 500 | + | | Key | Type | Default | Valid Values | Description | | |||
| 501 | + | |-----|------|---------|--------------|-------------| | |||
| 502 | + | | `performance.profile` | string | `"default"` | `default`, `lite`, `max`, `custom` | Performance profile selection | | |||
| 503 | + | ||||
| 504 | + | ### Profiles | |||
| 505 | + | ||||
| 506 | + | | Profile | Description | Use Case | | |||
| 507 | + | |---------|-------------|----------| | |||
| 508 | + | | `default` | No tuning, uses WebKit defaults | Normal browsing, most users | | |||
| 509 | + | | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving | | |||
| 510 | + | | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems | | |||
| 511 | + | | `custom` | Manual control over all settings | Advanced users who want fine-grained control | | |||
| 512 | + | ||||
| 513 | + | ### Profile Settings Matrix | |||
| 514 | + | ||||
| 515 | + | | Setting | default | lite | max | | |||
| 516 | + | |---------|---------|------|-----| | |||
| 517 | + | | Skia CPU threads | unset | 2 | `NumCPU()/2` (min 4) | | |||
| 518 | + | | Skia GPU threads | unset | unset | 2 | | |||
| 519 | + | | Web process memory (MB) | unset | 512 | 2048 | | |||
| 520 | + | | Network process memory (MB) | unset | 256 | 512 | | |||
| 521 | + | | Conservative threshold | unset | 0.25 | 0.5 | | |||
| 522 | + | | Strict threshold | unset | 0.4 | 0.7 | | |||
| 523 | + | | Kill threshold | unset | 0.8 | unset (never) | | |||
| 524 | + | | WebView pool prewarm | 4 | 2 | 8 | | |||
| 525 | + | ||||
| 526 | + | **Example:** | |||
| 527 | + | ```toml | |||
| 528 | + | [performance] | |||
| 529 | + | profile = "default" # Most users - no tuning needed | |||
| 530 | + | ||||
| 531 | + | # For low-RAM systems: | |||
| 532 | + | # profile = "lite" | |||
| 533 | + | ||||
| 534 | + | # For heavy pages (GitHub PRs, complex web apps): | |||
| 535 | + | # profile = "max" | |||
| 536 | + | ||||
| 537 | + | # For manual control: | |||
| 538 | + | # profile = "custom" | |||
| 539 | + | ``` | |||
| 540 | + | ||||
| 541 | + | ### Custom Profile Settings | |||
| 542 | + | ||||
| 543 | + | When `profile = "custom"`, you can configure individual tuning options: | |||
| 544 | + | ||||
| 545 | + | | Key | Type | Default | Description | | |||
| 546 | + | |-----|------|---------|-------------| | |||
| 547 | + | | `performance.skia_cpu_painting_threads` | int | `0` | Skia CPU rendering threads (0=unset) | | |||
| 548 | + | | `performance.skia_gpu_painting_threads` | int | `-1` | Skia GPU rendering threads (-1=unset, 0=disable) | | |||
| 549 | + | | `performance.skia_enable_cpu_rendering` | bool | `false` | Force CPU rendering | | |||
| 550 | + | | `performance.web_process_memory_limit_mb` | int | `0` | Web process memory limit in MB (0=unset) | | |||
| 551 | + | | `performance.web_process_memory_poll_interval_sec` | float | `0` | Memory check interval (0=WebKit default: 30s) | | |||
| 552 | + | | `performance.web_process_memory_conservative_threshold` | float | `0` | Conservative cleanup threshold (0=unset) | | |||
| 553 | + | | `performance.web_process_memory_strict_threshold` | float | `0` | Strict cleanup threshold (0=unset) | | |||
| 554 | + | | `performance.web_process_memory_kill_threshold` | float | `-1` | Process kill threshold (-1=unset, 0=never kill) | | |||
| 555 | + | | `performance.network_process_memory_limit_mb` | int | `0` | Network process memory limit in MB | | |||
| 556 | + | | `performance.network_process_memory_poll_interval_sec` | float | `0` | Network memory check interval | | |||
| 557 | + | | `performance.network_process_memory_conservative_threshold` | float | `0` | Network conservative threshold | | |||
| 558 | + | | `performance.network_process_memory_strict_threshold` | float | `0` | Network strict threshold | | |||
| 559 | + | | `performance.network_process_memory_kill_threshold` | float | `-1` | Network process kill threshold | | |||
| 560 | + | | `performance.webview_pool_prewarm_count` | int | `4` | WebViews to pre-create at startup | | |||
| 561 | + | | `performance.zoom_cache_size` | int | `256` | Domain zoom levels to cache | | |||
| 562 | + | ||||
| 563 | + | **Custom profile example:** | |||
| 564 | + | ```toml | |||
| 565 | + | [performance] | |||
| 566 | + | profile = "custom" | |||
| 567 | + | ||||
| 568 | + | # Skia threading - tune for your CPU | |||
| 569 | + | skia_cpu_painting_threads = 4 | |||
| 570 | + | skia_gpu_painting_threads = 2 | |||
| 571 | + | ||||
| 572 | + | # Web process memory pressure | |||
| 573 | + | web_process_memory_limit_mb = 1024 | |||
| 574 | + | web_process_memory_conservative_threshold = 0.4 | |||
| 575 | + | web_process_memory_strict_threshold = 0.6 | |||
| 576 | + | web_process_memory_kill_threshold = -1 # Never kill | |||
| 577 | + | ||||
| 578 | + | # WebView pool | |||
| 579 | + | webview_pool_prewarm_count = 6 | |||
| 580 | + | ``` | |||
| 581 | + | ||||
| 582 | + | > **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning. | |||
| 583 | + | ||||
| 494 | ## Environment Variables | 584 | ## Environment Variables | ||
| 495 | 585 | ||||
| 496 | All config values can be overridden via environment variables with the prefix `DUMBER_`: | 586 | All config values can be overridden via environment variables with the prefix `DUMBER_`: | ||
M go.mod +2 -2
| @@ -3,7 +3,7 @@ module github.com/bnema/dumber | |||
| 3 | 3 | go 1.25.3 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/puregotk-webkit v0.0.7 | |
| 6 | + | github.com/bnema/puregotk-webkit v0.0.9 | |
| 7 | 7 | github.com/charmbracelet/bubbles v0.21.0 | |
| 8 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| @@ -19,6 +19,7 @@ require ( | |||
| 19 | 19 | github.com/stretchr/testify v1.11.1 | |
| 20 | 20 | golang.org/x/image v0.34.0 | |
| 21 | 21 | golang.org/x/sys v0.38.0 | |
| 22 | + | gopkg.in/yaml.v3 v3.0.1 | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| 24 | 25 | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 | |
| @@ -64,5 +65,4 @@ require ( | |||
| 64 | 65 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 65 | 66 | golang.org/x/sync v0.19.0 // indirect | |
| 66 | 67 | golang.org/x/text v0.32.0 // indirect | |
| 67 | - | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 68 | 68 | ) | |
| @@ -3,7 +3,7 @@ module github.com/bnema/dumber | |||||
| 3 | go 1.25.3 | 3 | go 1.25.3 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/puregotk-webkit v0.0.7 | 6 | + | github.com/bnema/puregotk-webkit v0.0.9 |
| 7 | github.com/charmbracelet/bubbles v0.21.0 | 7 | github.com/charmbracelet/bubbles v0.21.0 | ||
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| @@ -19,6 +19,7 @@ require ( | |||||
| 19 | github.com/stretchr/testify v1.11.1 | 19 | github.com/stretchr/testify v1.11.1 | ||
| 20 | golang.org/x/image v0.34.0 | 20 | golang.org/x/image v0.34.0 | ||
| 21 | golang.org/x/sys v0.38.0 | 21 | golang.org/x/sys v0.38.0 | ||
| 22 | + | gopkg.in/yaml.v3 v3.0.1 | |||
| 22 | ) | 23 | ) | ||
| 23 | 24 | ||||
| 24 | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 | 25 | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 | ||
| @@ -64,5 +65,4 @@ require ( | |||||
| 64 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 65 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 65 | golang.org/x/sync v0.19.0 // indirect | 66 | golang.org/x/sync v0.19.0 // indirect | ||
| 66 | golang.org/x/text v0.32.0 // indirect | 67 | golang.org/x/text v0.32.0 // indirect | ||
| 67 | - | gopkg.in/yaml.v3 v3.0.1 // indirect | |||
| 68 | ) | 68 | ) | ||
M go.sum +2 -2 generated file: collapsed
A internal/application/port/hardware.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | // Package port defines interfaces for external dependencies. | |
| 2 | + | package port | |
| 3 | + | ||
| 4 | + | import "context" | |
| 5 | + | ||
| 6 | + | // HardwareInfo contains detected system hardware specifications. | |
| 7 | + | // Used for performance profile auto-tuning. | |
| 8 | + | type HardwareInfo struct { | |
| 9 | + | // CPU information | |
| 10 | + | CPUCores int // Number of physical CPU cores | |
| 11 | + | CPUThreads int // Number of logical CPU threads (with hyperthreading) | |
| 12 | + | ||
| 13 | + | // Memory information (in bytes) | |
| 14 | + | TotalRAM uint64 // Total system RAM | |
| 15 | + | AvailableRAM uint64 // Currently available RAM | |
| 16 | + | ||
| 17 | + | // GPU information | |
| 18 | + | GPUVendor GPUVendor // AMD, Intel, NVIDIA, or Unknown | |
| 19 | + | GPUName string // Human-readable GPU name (e.g., "AMD Radeon RX 9070 XT") | |
| 20 | + | VRAM uint64 // Video memory in bytes (0 if unknown) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // CPUCoresOrDefault returns CPUCores if > 0, otherwise returns the fallback. | |
| 24 | + | func (h HardwareInfo) CPUCoresOrDefault(fallback int) int { | |
| 25 | + | if h.CPUCores > 0 { | |
| 26 | + | return h.CPUCores | |
| 27 | + | } | |
| 28 | + | return fallback | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // CPUThreadsOrDefault returns CPUThreads if > 0, otherwise returns the fallback. | |
| 32 | + | func (h HardwareInfo) CPUThreadsOrDefault(fallback int) int { | |
| 33 | + | if h.CPUThreads > 0 { | |
| 34 | + | return h.CPUThreads | |
| 35 | + | } | |
| 36 | + | return fallback | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // TotalRAMMB returns total RAM in megabytes. | |
| 40 | + | // Returns 0 if RAM exceeds int range (unlikely on any real system). | |
| 41 | + | func (h HardwareInfo) TotalRAMMB() int { | |
| 42 | + | mb := h.TotalRAM / (1024 * 1024) | |
| 43 | + | if mb > uint64(^uint(0)>>1) { // Check against max int | |
| 44 | + | return 0 | |
| 45 | + | } | |
| 46 | + | return int(mb) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // VRAMMB returns VRAM in megabytes. | |
| 50 | + | // Returns 0 if VRAM exceeds int range (unlikely on any real system). | |
| 51 | + | func (h HardwareInfo) VRAMMB() int { | |
| 52 | + | mb := h.VRAM / (1024 * 1024) | |
| 53 | + | if mb > uint64(^uint(0)>>1) { // Check against max int | |
| 54 | + | return 0 | |
| 55 | + | } | |
| 56 | + | return int(mb) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // HasDedicatedGPU returns true if a discrete GPU with VRAM was detected. | |
| 60 | + | func (h HardwareInfo) HasDedicatedGPU() bool { | |
| 61 | + | return h.VRAM > 0 && h.GPUVendor != GPUVendorUnknown | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // HardwareSurveyor detects system hardware specifications. | |
| 65 | + | type HardwareSurveyor interface { | |
| 66 | + | // Survey detects and returns hardware information. | |
| 67 | + | // This should be called once at startup and cached. | |
| 68 | + | Survey(ctx context.Context) HardwareInfo | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | // Package port defines interfaces for external dependencies. | |||
| 2 | + | package port | |||
| 3 | + | ||||
| 4 | + | import "context" | |||
| 5 | + | ||||
| 6 | + | // HardwareInfo contains detected system hardware specifications. | |||
| 7 | + | // Used for performance profile auto-tuning. | |||
| 8 | + | type HardwareInfo struct { | |||
| 9 | + | // CPU information | |||
| 10 | + | CPUCores int // Number of physical CPU cores | |||
| 11 | + | CPUThreads int // Number of logical CPU threads (with hyperthreading) | |||
| 12 | + | ||||
| 13 | + | // Memory information (in bytes) | |||
| 14 | + | TotalRAM uint64 // Total system RAM | |||
| 15 | + | AvailableRAM uint64 // Currently available RAM | |||
| 16 | + | ||||
| 17 | + | // GPU information | |||
| 18 | + | GPUVendor GPUVendor // AMD, Intel, NVIDIA, or Unknown | |||
| 19 | + | GPUName string // Human-readable GPU name (e.g., "AMD Radeon RX 9070 XT") | |||
| 20 | + | VRAM uint64 // Video memory in bytes (0 if unknown) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // CPUCoresOrDefault returns CPUCores if > 0, otherwise returns the fallback. | |||
| 24 | + | func (h HardwareInfo) CPUCoresOrDefault(fallback int) int { | |||
| 25 | + | if h.CPUCores > 0 { | |||
| 26 | + | return h.CPUCores | |||
| 27 | + | } | |||
| 28 | + | return fallback | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // CPUThreadsOrDefault returns CPUThreads if > 0, otherwise returns the fallback. | |||
| 32 | + | func (h HardwareInfo) CPUThreadsOrDefault(fallback int) int { | |||
| 33 | + | if h.CPUThreads > 0 { | |||
| 34 | + | return h.CPUThreads | |||
| 35 | + | } | |||
| 36 | + | return fallback | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // TotalRAMMB returns total RAM in megabytes. | |||
| 40 | + | // Returns 0 if RAM exceeds int range (unlikely on any real system). | |||
| 41 | + | func (h HardwareInfo) TotalRAMMB() int { | |||
| 42 | + | mb := h.TotalRAM / (1024 * 1024) | |||
| 43 | + | if mb > uint64(^uint(0)>>1) { // Check against max int | |||
| 44 | + | return 0 | |||
| 45 | + | } | |||
| 46 | + | return int(mb) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // VRAMMB returns VRAM in megabytes. | |||
| 50 | + | // Returns 0 if VRAM exceeds int range (unlikely on any real system). | |||
| 51 | + | func (h HardwareInfo) VRAMMB() int { | |||
| 52 | + | mb := h.VRAM / (1024 * 1024) | |||
| 53 | + | if mb > uint64(^uint(0)>>1) { // Check against max int | |||
| 54 | + | return 0 | |||
| 55 | + | } | |||
| 56 | + | return int(mb) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // HasDedicatedGPU returns true if a discrete GPU with VRAM was detected. | |||
| 60 | + | func (h HardwareInfo) HasDedicatedGPU() bool { | |||
| 61 | + | return h.VRAM > 0 && h.GPUVendor != GPUVendorUnknown | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // HardwareSurveyor detects system hardware specifications. | |||
| 65 | + | type HardwareSurveyor interface { | |||
| 66 | + | // Survey detects and returns hardware information. | |||
| 67 | + | // This should be called once at startup and cached. | |||
| 68 | + | Survey(ctx context.Context) HardwareInfo | |||
| 69 | + | } | |||
M internal/application/port/mocks/mocks.go +486 -0
| @@ -638,6 +638,84 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) | |||
| 638 | 638 | return _c | |
| 639 | 639 | } | |
| 640 | 640 | ||
| 641 | + | // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 642 | + | // The first argument is typically a *testing.T value. | |
| 643 | + | func NewMockHardwareSurveyor(t interface { | |
| 644 | + | mock.TestingT | |
| 645 | + | Cleanup(func()) | |
| 646 | + | }) *MockHardwareSurveyor { | |
| 647 | + | mock := &MockHardwareSurveyor{} | |
| 648 | + | mock.Mock.Test(t) | |
| 649 | + | ||
| 650 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 651 | + | ||
| 652 | + | return mock | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | // MockHardwareSurveyor is an autogenerated mock type for the HardwareSurveyor type | |
| 656 | + | type MockHardwareSurveyor struct { | |
| 657 | + | mock.Mock | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | type MockHardwareSurveyor_Expecter struct { | |
| 661 | + | mock *mock.Mock | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | func (_m *MockHardwareSurveyor) EXPECT() *MockHardwareSurveyor_Expecter { | |
| 665 | + | return &MockHardwareSurveyor_Expecter{mock: &_m.Mock} | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | // Survey provides a mock function for the type MockHardwareSurveyor | |
| 669 | + | func (_mock *MockHardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo { | |
| 670 | + | ret := _mock.Called(ctx) | |
| 671 | + | ||
| 672 | + | if len(ret) == 0 { | |
| 673 | + | panic("no return value specified for Survey") | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | var r0 port.HardwareInfo | |
| 677 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) port.HardwareInfo); ok { | |
| 678 | + | r0 = returnFunc(ctx) | |
| 679 | + | } else { | |
| 680 | + | r0 = ret.Get(0).(port.HardwareInfo) | |
| 681 | + | } | |
| 682 | + | return r0 | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | // MockHardwareSurveyor_Survey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Survey' | |
| 686 | + | type MockHardwareSurveyor_Survey_Call struct { | |
| 687 | + | *mock.Call | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | // Survey is a helper method to define mock.On call | |
| 691 | + | // - ctx context.Context | |
| 692 | + | func (_e *MockHardwareSurveyor_Expecter) Survey(ctx interface{}) *MockHardwareSurveyor_Survey_Call { | |
| 693 | + | return &MockHardwareSurveyor_Survey_Call{Call: _e.mock.On("Survey", ctx)} | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | func (_c *MockHardwareSurveyor_Survey_Call) Run(run func(ctx context.Context)) *MockHardwareSurveyor_Survey_Call { | |
| 697 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 698 | + | var arg0 context.Context | |
| 699 | + | if args[0] != nil { | |
| 700 | + | arg0 = args[0].(context.Context) | |
| 701 | + | } | |
| 702 | + | run( | |
| 703 | + | arg0, | |
| 704 | + | ) | |
| 705 | + | }) | |
| 706 | + | return _c | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | func (_c *MockHardwareSurveyor_Survey_Call) Return(hardwareInfo port.HardwareInfo) *MockHardwareSurveyor_Survey_Call { | |
| 710 | + | _c.Call.Return(hardwareInfo) | |
| 711 | + | return _c | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Context) port.HardwareInfo) *MockHardwareSurveyor_Survey_Call { | |
| 715 | + | _c.Call.Return(run) | |
| 716 | + | return _c | |
| 717 | + | } | |
| 718 | + | ||
| 641 | 719 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 642 | 720 | // The first argument is typically a *testing.T value. | |
| 643 | 721 | func NewMockSessionLogger(t interface { | |
| @@ -940,6 +1018,414 @@ func (_c *MockSessionSpawner_SpawnWithSession_Call) RunAndReturn(run func(sessio | |||
| 940 | 1018 | return _c | |
| 941 | 1019 | } | |
| 942 | 1020 | ||
| 1021 | + | // NewMockMemoryPressureApplier creates a new instance of MockMemoryPressureApplier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1022 | + | // The first argument is typically a *testing.T value. | |
| 1023 | + | func NewMockMemoryPressureApplier(t interface { | |
| 1024 | + | mock.TestingT | |
| 1025 | + | Cleanup(func()) | |
| 1026 | + | }) *MockMemoryPressureApplier { | |
| 1027 | + | mock := &MockMemoryPressureApplier{} | |
| 1028 | + | mock.Mock.Test(t) | |
| 1029 | + | ||
| 1030 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1031 | + | ||
| 1032 | + | return mock | |
| 1033 | + | } | |
| 1034 | + | ||
| 1035 | + | // MockMemoryPressureApplier is an autogenerated mock type for the MemoryPressureApplier type | |
| 1036 | + | type MockMemoryPressureApplier struct { | |
| 1037 | + | mock.Mock | |
| 1038 | + | } | |
| 1039 | + | ||
| 1040 | + | type MockMemoryPressureApplier_Expecter struct { | |
| 1041 | + | mock *mock.Mock | |
| 1042 | + | } | |
| 1043 | + | ||
| 1044 | + | func (_m *MockMemoryPressureApplier) EXPECT() *MockMemoryPressureApplier_Expecter { | |
| 1045 | + | return &MockMemoryPressureApplier_Expecter{mock: &_m.Mock} | |
| 1046 | + | } | |
| 1047 | + | ||
| 1048 | + | // ApplyNetworkProcessSettings provides a mock function for the type MockMemoryPressureApplier | |
| 1049 | + | func (_mock *MockMemoryPressureApplier) ApplyNetworkProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) error { | |
| 1050 | + | ret := _mock.Called(ctx, cfg) | |
| 1051 | + | ||
| 1052 | + | if len(ret) == 0 { | |
| 1053 | + | panic("no return value specified for ApplyNetworkProcessSettings") | |
| 1054 | + | } | |
| 1055 | + | ||
| 1056 | + | var r0 error | |
| 1057 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) error); ok { | |
| 1058 | + | r0 = returnFunc(ctx, cfg) | |
| 1059 | + | } else { | |
| 1060 | + | r0 = ret.Error(0) | |
| 1061 | + | } | |
| 1062 | + | return r0 | |
| 1063 | + | } | |
| 1064 | + | ||
| 1065 | + | // MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyNetworkProcessSettings' | |
| 1066 | + | type MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call struct { | |
| 1067 | + | *mock.Call | |
| 1068 | + | } | |
| 1069 | + | ||
| 1070 | + | // ApplyNetworkProcessSettings is a helper method to define mock.On call | |
| 1071 | + | // - ctx context.Context | |
| 1072 | + | // - cfg *port.MemoryPressureConfig | |
| 1073 | + | func (_e *MockMemoryPressureApplier_Expecter) ApplyNetworkProcessSettings(ctx interface{}, cfg interface{}) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |
| 1074 | + | return &MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call{Call: _e.mock.On("ApplyNetworkProcessSettings", ctx, cfg)} | |
| 1075 | + | } | |
| 1076 | + | ||
| 1077 | + | func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) Run(run func(ctx context.Context, cfg *port.MemoryPressureConfig)) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |
| 1078 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1079 | + | var arg0 context.Context | |
| 1080 | + | if args[0] != nil { | |
| 1081 | + | arg0 = args[0].(context.Context) | |
| 1082 | + | } | |
| 1083 | + | var arg1 *port.MemoryPressureConfig | |
| 1084 | + | if args[1] != nil { | |
| 1085 | + | arg1 = args[1].(*port.MemoryPressureConfig) | |
| 1086 | + | } | |
| 1087 | + | run( | |
| 1088 | + | arg0, | |
| 1089 | + | arg1, | |
| 1090 | + | ) | |
| 1091 | + | }) | |
| 1092 | + | return _c | |
| 1093 | + | } | |
| 1094 | + | ||
| 1095 | + | func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) Return(err error) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |
| 1096 | + | _c.Call.Return(err) | |
| 1097 | + | return _c | |
| 1098 | + | } | |
| 1099 | + | ||
| 1100 | + | func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) RunAndReturn(run func(ctx context.Context, cfg *port.MemoryPressureConfig) error) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |
| 1101 | + | _c.Call.Return(run) | |
| 1102 | + | return _c | |
| 1103 | + | } | |
| 1104 | + | ||
| 1105 | + | // ApplyWebProcessSettings provides a mock function for the type MockMemoryPressureApplier | |
| 1106 | + | func (_mock *MockMemoryPressureApplier) ApplyWebProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error) { | |
| 1107 | + | ret := _mock.Called(ctx, cfg) | |
| 1108 | + | ||
| 1109 | + | if len(ret) == 0 { | |
| 1110 | + | panic("no return value specified for ApplyWebProcessSettings") | |
| 1111 | + | } | |
| 1112 | + | ||
| 1113 | + | var r0 any | |
| 1114 | + | var r1 error | |
| 1115 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) (any, error)); ok { | |
| 1116 | + | return returnFunc(ctx, cfg) | |
| 1117 | + | } | |
| 1118 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) any); ok { | |
| 1119 | + | r0 = returnFunc(ctx, cfg) | |
| 1120 | + | } else { | |
| 1121 | + | if ret.Get(0) != nil { | |
| 1122 | + | r0 = ret.Get(0).(any) | |
| 1123 | + | } | |
| 1124 | + | } | |
| 1125 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, *port.MemoryPressureConfig) error); ok { | |
| 1126 | + | r1 = returnFunc(ctx, cfg) | |
| 1127 | + | } else { | |
| 1128 | + | r1 = ret.Error(1) | |
| 1129 | + | } | |
| 1130 | + | return r0, r1 | |
| 1131 | + | } | |
| 1132 | + | ||
| 1133 | + | // MockMemoryPressureApplier_ApplyWebProcessSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyWebProcessSettings' | |
| 1134 | + | type MockMemoryPressureApplier_ApplyWebProcessSettings_Call struct { | |
| 1135 | + | *mock.Call | |
| 1136 | + | } | |
| 1137 | + | ||
| 1138 | + | // ApplyWebProcessSettings is a helper method to define mock.On call | |
| 1139 | + | // - ctx context.Context | |
| 1140 | + | // - cfg *port.MemoryPressureConfig | |
| 1141 | + | func (_e *MockMemoryPressureApplier_Expecter) ApplyWebProcessSettings(ctx interface{}, cfg interface{}) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |
| 1142 | + | return &MockMemoryPressureApplier_ApplyWebProcessSettings_Call{Call: _e.mock.On("ApplyWebProcessSettings", ctx, cfg)} | |
| 1143 | + | } | |
| 1144 | + | ||
| 1145 | + | func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) Run(run func(ctx context.Context, cfg *port.MemoryPressureConfig)) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |
| 1146 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1147 | + | var arg0 context.Context | |
| 1148 | + | if args[0] != nil { | |
| 1149 | + | arg0 = args[0].(context.Context) | |
| 1150 | + | } | |
| 1151 | + | var arg1 *port.MemoryPressureConfig | |
| 1152 | + | if args[1] != nil { | |
| 1153 | + | arg1 = args[1].(*port.MemoryPressureConfig) | |
| 1154 | + | } | |
| 1155 | + | run( | |
| 1156 | + | arg0, | |
| 1157 | + | arg1, | |
| 1158 | + | ) | |
| 1159 | + | }) | |
| 1160 | + | return _c | |
| 1161 | + | } | |
| 1162 | + | ||
| 1163 | + | func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) Return(v any, err error) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |
| 1164 | + | _c.Call.Return(v, err) | |
| 1165 | + | return _c | |
| 1166 | + | } | |
| 1167 | + | ||
| 1168 | + | func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) RunAndReturn(run func(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error)) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |
| 1169 | + | _c.Call.Return(run) | |
| 1170 | + | return _c | |
| 1171 | + | } | |
| 1172 | + | ||
| 1173 | + | // NewMockWebKitContextProvider creates a new instance of MockWebKitContextProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1174 | + | // The first argument is typically a *testing.T value. | |
| 1175 | + | func NewMockWebKitContextProvider(t interface { | |
| 1176 | + | mock.TestingT | |
| 1177 | + | Cleanup(func()) | |
| 1178 | + | }) *MockWebKitContextProvider { | |
| 1179 | + | mock := &MockWebKitContextProvider{} | |
| 1180 | + | mock.Mock.Test(t) | |
| 1181 | + | ||
| 1182 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1183 | + | ||
| 1184 | + | return mock | |
| 1185 | + | } | |
| 1186 | + | ||
| 1187 | + | // MockWebKitContextProvider is an autogenerated mock type for the WebKitContextProvider type | |
| 1188 | + | type MockWebKitContextProvider struct { | |
| 1189 | + | mock.Mock | |
| 1190 | + | } | |
| 1191 | + | ||
| 1192 | + | type MockWebKitContextProvider_Expecter struct { | |
| 1193 | + | mock *mock.Mock | |
| 1194 | + | } | |
| 1195 | + | ||
| 1196 | + | func (_m *MockWebKitContextProvider) EXPECT() *MockWebKitContextProvider_Expecter { | |
| 1197 | + | return &MockWebKitContextProvider_Expecter{mock: &_m.Mock} | |
| 1198 | + | } | |
| 1199 | + | ||
| 1200 | + | // CacheDir provides a mock function for the type MockWebKitContextProvider | |
| 1201 | + | func (_mock *MockWebKitContextProvider) CacheDir() string { | |
| 1202 | + | ret := _mock.Called() | |
| 1203 | + | ||
| 1204 | + | if len(ret) == 0 { | |
| 1205 | + | panic("no return value specified for CacheDir") | |
| 1206 | + | } | |
| 1207 | + | ||
| 1208 | + | var r0 string | |
| 1209 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 1210 | + | r0 = returnFunc() | |
| 1211 | + | } else { | |
| 1212 | + | r0 = ret.Get(0).(string) | |
| 1213 | + | } | |
| 1214 | + | return r0 | |
| 1215 | + | } | |
| 1216 | + | ||
| 1217 | + | // MockWebKitContextProvider_CacheDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CacheDir' | |
| 1218 | + | type MockWebKitContextProvider_CacheDir_Call struct { | |
| 1219 | + | *mock.Call | |
| 1220 | + | } | |
| 1221 | + | ||
| 1222 | + | // CacheDir is a helper method to define mock.On call | |
| 1223 | + | func (_e *MockWebKitContextProvider_Expecter) CacheDir() *MockWebKitContextProvider_CacheDir_Call { | |
| 1224 | + | return &MockWebKitContextProvider_CacheDir_Call{Call: _e.mock.On("CacheDir")} | |
| 1225 | + | } | |
| 1226 | + | ||
| 1227 | + | func (_c *MockWebKitContextProvider_CacheDir_Call) Run(run func()) *MockWebKitContextProvider_CacheDir_Call { | |
| 1228 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1229 | + | run() | |
| 1230 | + | }) | |
| 1231 | + | return _c | |
| 1232 | + | } | |
| 1233 | + | ||
| 1234 | + | func (_c *MockWebKitContextProvider_CacheDir_Call) Return(s string) *MockWebKitContextProvider_CacheDir_Call { | |
| 1235 | + | _c.Call.Return(s) | |
| 1236 | + | return _c | |
| 1237 | + | } | |
| 1238 | + | ||
| 1239 | + | func (_c *MockWebKitContextProvider_CacheDir_Call) RunAndReturn(run func() string) *MockWebKitContextProvider_CacheDir_Call { | |
| 1240 | + | _c.Call.Return(run) | |
| 1241 | + | return _c | |
| 1242 | + | } | |
| 1243 | + | ||
| 1244 | + | // Close provides a mock function for the type MockWebKitContextProvider | |
| 1245 | + | func (_mock *MockWebKitContextProvider) Close() error { | |
| 1246 | + | ret := _mock.Called() | |
| 1247 | + | ||
| 1248 | + | if len(ret) == 0 { | |
| 1249 | + | panic("no return value specified for Close") | |
| 1250 | + | } | |
| 1251 | + | ||
| 1252 | + | var r0 error | |
| 1253 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 1254 | + | r0 = returnFunc() | |
| 1255 | + | } else { | |
| 1256 | + | r0 = ret.Error(0) | |
| 1257 | + | } | |
| 1258 | + | return r0 | |
| 1259 | + | } | |
| 1260 | + | ||
| 1261 | + | // MockWebKitContextProvider_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |
| 1262 | + | type MockWebKitContextProvider_Close_Call struct { | |
| 1263 | + | *mock.Call | |
| 1264 | + | } | |
| 1265 | + | ||
| 1266 | + | // Close is a helper method to define mock.On call | |
| 1267 | + | func (_e *MockWebKitContextProvider_Expecter) Close() *MockWebKitContextProvider_Close_Call { | |
| 1268 | + | return &MockWebKitContextProvider_Close_Call{Call: _e.mock.On("Close")} | |
| 1269 | + | } | |
| 1270 | + | ||
| 1271 | + | func (_c *MockWebKitContextProvider_Close_Call) Run(run func()) *MockWebKitContextProvider_Close_Call { | |
| 1272 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1273 | + | run() | |
| 1274 | + | }) | |
| 1275 | + | return _c | |
| 1276 | + | } | |
| 1277 | + | ||
| 1278 | + | func (_c *MockWebKitContextProvider_Close_Call) Return(err error) *MockWebKitContextProvider_Close_Call { | |
| 1279 | + | _c.Call.Return(err) | |
| 1280 | + | return _c | |
| 1281 | + | } | |
| 1282 | + | ||
| 1283 | + | func (_c *MockWebKitContextProvider_Close_Call) RunAndReturn(run func() error) *MockWebKitContextProvider_Close_Call { | |
| 1284 | + | _c.Call.Return(run) | |
| 1285 | + | return _c | |
| 1286 | + | } | |
| 1287 | + | ||
| 1288 | + | // DataDir provides a mock function for the type MockWebKitContextProvider | |
| 1289 | + | func (_mock *MockWebKitContextProvider) DataDir() string { | |
| 1290 | + | ret := _mock.Called() | |
| 1291 | + | ||
| 1292 | + | if len(ret) == 0 { | |
| 1293 | + | panic("no return value specified for DataDir") | |
| 1294 | + | } | |
| 1295 | + | ||
| 1296 | + | var r0 string | |
| 1297 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 1298 | + | r0 = returnFunc() | |
| 1299 | + | } else { | |
| 1300 | + | r0 = ret.Get(0).(string) | |
| 1301 | + | } | |
| 1302 | + | return r0 | |
| 1303 | + | } | |
| 1304 | + | ||
| 1305 | + | // MockWebKitContextProvider_DataDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DataDir' | |
| 1306 | + | type MockWebKitContextProvider_DataDir_Call struct { | |
| 1307 | + | *mock.Call | |
| 1308 | + | } | |
| 1309 | + | ||
| 1310 | + | // DataDir is a helper method to define mock.On call | |
| 1311 | + | func (_e *MockWebKitContextProvider_Expecter) DataDir() *MockWebKitContextProvider_DataDir_Call { | |
| 1312 | + | return &MockWebKitContextProvider_DataDir_Call{Call: _e.mock.On("DataDir")} | |
| 1313 | + | } | |
| 1314 | + | ||
| 1315 | + | func (_c *MockWebKitContextProvider_DataDir_Call) Run(run func()) *MockWebKitContextProvider_DataDir_Call { | |
| 1316 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1317 | + | run() | |
| 1318 | + | }) | |
| 1319 | + | return _c | |
| 1320 | + | } | |
| 1321 | + | ||
| 1322 | + | func (_c *MockWebKitContextProvider_DataDir_Call) Return(s string) *MockWebKitContextProvider_DataDir_Call { | |
| 1323 | + | _c.Call.Return(s) | |
| 1324 | + | return _c | |
| 1325 | + | } | |
| 1326 | + | ||
| 1327 | + | func (_c *MockWebKitContextProvider_DataDir_Call) RunAndReturn(run func() string) *MockWebKitContextProvider_DataDir_Call { | |
| 1328 | + | _c.Call.Return(run) | |
| 1329 | + | return _c | |
| 1330 | + | } | |
| 1331 | + | ||
| 1332 | + | // Initialize provides a mock function for the type MockWebKitContextProvider | |
| 1333 | + | func (_mock *MockWebKitContextProvider) Initialize(ctx context.Context, opts port.WebKitContextOptions) error { | |
| 1334 | + | ret := _mock.Called(ctx, opts) | |
| 1335 | + | ||
| 1336 | + | if len(ret) == 0 { | |
| 1337 | + | panic("no return value specified for Initialize") | |
| 1338 | + | } | |
| 1339 | + | ||
| 1340 | + | var r0 error | |
| 1341 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebKitContextOptions) error); ok { | |
| 1342 | + | r0 = returnFunc(ctx, opts) | |
| 1343 | + | } else { | |
| 1344 | + | r0 = ret.Error(0) | |
| 1345 | + | } | |
| 1346 | + | return r0 | |
| 1347 | + | } | |
| 1348 | + | ||
| 1349 | + | // MockWebKitContextProvider_Initialize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Initialize' | |
| 1350 | + | type MockWebKitContextProvider_Initialize_Call struct { | |
| 1351 | + | *mock.Call | |
| 1352 | + | } | |
| 1353 | + | ||
| 1354 | + | // Initialize is a helper method to define mock.On call | |
| 1355 | + | // - ctx context.Context | |
| 1356 | + | // - opts port.WebKitContextOptions | |
| 1357 | + | func (_e *MockWebKitContextProvider_Expecter) Initialize(ctx interface{}, opts interface{}) *MockWebKitContextProvider_Initialize_Call { | |
| 1358 | + | return &MockWebKitContextProvider_Initialize_Call{Call: _e.mock.On("Initialize", ctx, opts)} | |
| 1359 | + | } | |
| 1360 | + | ||
| 1361 | + | func (_c *MockWebKitContextProvider_Initialize_Call) Run(run func(ctx context.Context, opts port.WebKitContextOptions)) *MockWebKitContextProvider_Initialize_Call { | |
| 1362 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1363 | + | var arg0 context.Context | |
| 1364 | + | if args[0] != nil { | |
| 1365 | + | arg0 = args[0].(context.Context) | |
| 1366 | + | } | |
| 1367 | + | var arg1 port.WebKitContextOptions | |
| 1368 | + | if args[1] != nil { | |
| 1369 | + | arg1 = args[1].(port.WebKitContextOptions) | |
| 1370 | + | } | |
| 1371 | + | run( | |
| 1372 | + | arg0, | |
| 1373 | + | arg1, | |
| 1374 | + | ) | |
| 1375 | + | }) | |
| 1376 | + | return _c | |
| 1377 | + | } | |
| 1378 | + | ||
| 1379 | + | func (_c *MockWebKitContextProvider_Initialize_Call) Return(err error) *MockWebKitContextProvider_Initialize_Call { | |
| 1380 | + | _c.Call.Return(err) | |
| 1381 | + | return _c | |
| 1382 | + | } | |
| 1383 | + | ||
| 1384 | + | func (_c *MockWebKitContextProvider_Initialize_Call) RunAndReturn(run func(ctx context.Context, opts port.WebKitContextOptions) error) *MockWebKitContextProvider_Initialize_Call { | |
| 1385 | + | _c.Call.Return(run) | |
| 1386 | + | return _c | |
| 1387 | + | } | |
| 1388 | + | ||
| 1389 | + | // PrefetchDNS provides a mock function for the type MockWebKitContextProvider | |
| 1390 | + | func (_mock *MockWebKitContextProvider) PrefetchDNS(hostname string) { | |
| 1391 | + | _mock.Called(hostname) | |
| 1392 | + | return | |
| 1393 | + | } | |
| 1394 | + | ||
| 1395 | + | // MockWebKitContextProvider_PrefetchDNS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrefetchDNS' | |
| 1396 | + | type MockWebKitContextProvider_PrefetchDNS_Call struct { | |
| 1397 | + | *mock.Call | |
| 1398 | + | } | |
| 1399 | + | ||
| 1400 | + | // PrefetchDNS is a helper method to define mock.On call | |
| 1401 | + | // - hostname string | |
| 1402 | + | func (_e *MockWebKitContextProvider_Expecter) PrefetchDNS(hostname interface{}) *MockWebKitContextProvider_PrefetchDNS_Call { | |
| 1403 | + | return &MockWebKitContextProvider_PrefetchDNS_Call{Call: _e.mock.On("PrefetchDNS", hostname)} | |
| 1404 | + | } | |
| 1405 | + | ||
| 1406 | + | func (_c *MockWebKitContextProvider_PrefetchDNS_Call) Run(run func(hostname string)) *MockWebKitContextProvider_PrefetchDNS_Call { | |
| 1407 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1408 | + | var arg0 string | |
| 1409 | + | if args[0] != nil { | |
| 1410 | + | arg0 = args[0].(string) | |
| 1411 | + | } | |
| 1412 | + | run( | |
| 1413 | + | arg0, | |
| 1414 | + | ) | |
| 1415 | + | }) | |
| 1416 | + | return _c | |
| 1417 | + | } | |
| 1418 | + | ||
| 1419 | + | func (_c *MockWebKitContextProvider_PrefetchDNS_Call) Return() *MockWebKitContextProvider_PrefetchDNS_Call { | |
| 1420 | + | _c.Call.Return() | |
| 1421 | + | return _c | |
| 1422 | + | } | |
| 1423 | + | ||
| 1424 | + | func (_c *MockWebKitContextProvider_PrefetchDNS_Call) RunAndReturn(run func(hostname string)) *MockWebKitContextProvider_PrefetchDNS_Call { | |
| 1425 | + | _c.Run(run) | |
| 1426 | + | return _c | |
| 1427 | + | } | |
| 1428 | + | ||
| 943 | 1429 | // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 944 | 1430 | // The first argument is typically a *testing.T value. | |
| 945 | 1431 | func NewMockWebView(t interface { | |
| @@ -638,6 +638,84 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) | |||||
| 638 | return _c | 638 | return _c | ||
| 639 | } | 639 | } | ||
| 640 | 640 | ||||
| 641 | + | // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 642 | + | // The first argument is typically a *testing.T value. | |||
| 643 | + | func NewMockHardwareSurveyor(t interface { | |||
| 644 | + | mock.TestingT | |||
| 645 | + | Cleanup(func()) | |||
| 646 | + | }) *MockHardwareSurveyor { | |||
| 647 | + | mock := &MockHardwareSurveyor{} | |||
| 648 | + | mock.Mock.Test(t) | |||
| 649 | + | ||||
| 650 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 651 | + | ||||
| 652 | + | return mock | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | // MockHardwareSurveyor is an autogenerated mock type for the HardwareSurveyor type | |||
| 656 | + | type MockHardwareSurveyor struct { | |||
| 657 | + | mock.Mock | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | type MockHardwareSurveyor_Expecter struct { | |||
| 661 | + | mock *mock.Mock | |||
| 662 | + | } | |||
| 663 | + | ||||
| 664 | + | func (_m *MockHardwareSurveyor) EXPECT() *MockHardwareSurveyor_Expecter { | |||
| 665 | + | return &MockHardwareSurveyor_Expecter{mock: &_m.Mock} | |||
| 666 | + | } | |||
| 667 | + | ||||
| 668 | + | // Survey provides a mock function for the type MockHardwareSurveyor | |||
| 669 | + | func (_mock *MockHardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo { | |||
| 670 | + | ret := _mock.Called(ctx) | |||
| 671 | + | ||||
| 672 | + | if len(ret) == 0 { | |||
| 673 | + | panic("no return value specified for Survey") | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | var r0 port.HardwareInfo | |||
| 677 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) port.HardwareInfo); ok { | |||
| 678 | + | r0 = returnFunc(ctx) | |||
| 679 | + | } else { | |||
| 680 | + | r0 = ret.Get(0).(port.HardwareInfo) | |||
| 681 | + | } | |||
| 682 | + | return r0 | |||
| 683 | + | } | |||
| 684 | + | ||||
| 685 | + | // MockHardwareSurveyor_Survey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Survey' | |||
| 686 | + | type MockHardwareSurveyor_Survey_Call struct { | |||
| 687 | + | *mock.Call | |||
| 688 | + | } | |||
| 689 | + | ||||
| 690 | + | // Survey is a helper method to define mock.On call | |||
| 691 | + | // - ctx context.Context | |||
| 692 | + | func (_e *MockHardwareSurveyor_Expecter) Survey(ctx interface{}) *MockHardwareSurveyor_Survey_Call { | |||
| 693 | + | return &MockHardwareSurveyor_Survey_Call{Call: _e.mock.On("Survey", ctx)} | |||
| 694 | + | } | |||
| 695 | + | ||||
| 696 | + | func (_c *MockHardwareSurveyor_Survey_Call) Run(run func(ctx context.Context)) *MockHardwareSurveyor_Survey_Call { | |||
| 697 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 698 | + | var arg0 context.Context | |||
| 699 | + | if args[0] != nil { | |||
| 700 | + | arg0 = args[0].(context.Context) | |||
| 701 | + | } | |||
| 702 | + | run( | |||
| 703 | + | arg0, | |||
| 704 | + | ) | |||
| 705 | + | }) | |||
| 706 | + | return _c | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | func (_c *MockHardwareSurveyor_Survey_Call) Return(hardwareInfo port.HardwareInfo) *MockHardwareSurveyor_Survey_Call { | |||
| 710 | + | _c.Call.Return(hardwareInfo) | |||
| 711 | + | return _c | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Context) port.HardwareInfo) *MockHardwareSurveyor_Survey_Call { | |||
| 715 | + | _c.Call.Return(run) | |||
| 716 | + | return _c | |||
| 717 | + | } | |||
| 718 | + | ||||
| 641 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 719 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 642 | // The first argument is typically a *testing.T value. | 720 | // The first argument is typically a *testing.T value. | ||
| 643 | func NewMockSessionLogger(t interface { | 721 | func NewMockSessionLogger(t interface { | ||
| @@ -940,6 +1018,414 @@ func (_c *MockSessionSpawner_SpawnWithSession_Call) RunAndReturn(run func(sessio | |||||
| 940 | return _c | 1018 | return _c | ||
| 941 | } | 1019 | } | ||
| 942 | 1020 | ||||
| 1021 | + | // NewMockMemoryPressureApplier creates a new instance of MockMemoryPressureApplier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1022 | + | // The first argument is typically a *testing.T value. | |||
| 1023 | + | func NewMockMemoryPressureApplier(t interface { | |||
| 1024 | + | mock.TestingT | |||
| 1025 | + | Cleanup(func()) | |||
| 1026 | + | }) *MockMemoryPressureApplier { | |||
| 1027 | + | mock := &MockMemoryPressureApplier{} | |||
| 1028 | + | mock.Mock.Test(t) | |||
| 1029 | + | ||||
| 1030 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1031 | + | ||||
| 1032 | + | return mock | |||
| 1033 | + | } | |||
| 1034 | + | ||||
| 1035 | + | // MockMemoryPressureApplier is an autogenerated mock type for the MemoryPressureApplier type | |||
| 1036 | + | type MockMemoryPressureApplier struct { | |||
| 1037 | + | mock.Mock | |||
| 1038 | + | } | |||
| 1039 | + | ||||
| 1040 | + | type MockMemoryPressureApplier_Expecter struct { | |||
| 1041 | + | mock *mock.Mock | |||
| 1042 | + | } | |||
| 1043 | + | ||||
| 1044 | + | func (_m *MockMemoryPressureApplier) EXPECT() *MockMemoryPressureApplier_Expecter { | |||
| 1045 | + | return &MockMemoryPressureApplier_Expecter{mock: &_m.Mock} | |||
| 1046 | + | } | |||
| 1047 | + | ||||
| 1048 | + | // ApplyNetworkProcessSettings provides a mock function for the type MockMemoryPressureApplier | |||
| 1049 | + | func (_mock *MockMemoryPressureApplier) ApplyNetworkProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) error { | |||
| 1050 | + | ret := _mock.Called(ctx, cfg) | |||
| 1051 | + | ||||
| 1052 | + | if len(ret) == 0 { | |||
| 1053 | + | panic("no return value specified for ApplyNetworkProcessSettings") | |||
| 1054 | + | } | |||
| 1055 | + | ||||
| 1056 | + | var r0 error | |||
| 1057 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) error); ok { | |||
| 1058 | + | r0 = returnFunc(ctx, cfg) | |||
| 1059 | + | } else { | |||
| 1060 | + | r0 = ret.Error(0) | |||
| 1061 | + | } | |||
| 1062 | + | return r0 | |||
| 1063 | + | } | |||
| 1064 | + | ||||
| 1065 | + | // MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyNetworkProcessSettings' | |||
| 1066 | + | type MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call struct { | |||
| 1067 | + | *mock.Call | |||
| 1068 | + | } | |||
| 1069 | + | ||||
| 1070 | + | // ApplyNetworkProcessSettings is a helper method to define mock.On call | |||
| 1071 | + | // - ctx context.Context | |||
| 1072 | + | // - cfg *port.MemoryPressureConfig | |||
| 1073 | + | func (_e *MockMemoryPressureApplier_Expecter) ApplyNetworkProcessSettings(ctx interface{}, cfg interface{}) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |||
| 1074 | + | return &MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call{Call: _e.mock.On("ApplyNetworkProcessSettings", ctx, cfg)} | |||
| 1075 | + | } | |||
| 1076 | + | ||||
| 1077 | + | func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) Run(run func(ctx context.Context, cfg *port.MemoryPressureConfig)) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |||
| 1078 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1079 | + | var arg0 context.Context | |||
| 1080 | + | if args[0] != nil { | |||
| 1081 | + | arg0 = args[0].(context.Context) | |||
| 1082 | + | } | |||
| 1083 | + | var arg1 *port.MemoryPressureConfig | |||
| 1084 | + | if args[1] != nil { | |||
| 1085 | + | arg1 = args[1].(*port.MemoryPressureConfig) | |||
| 1086 | + | } | |||
| 1087 | + | run( | |||
| 1088 | + | arg0, | |||
| 1089 | + | arg1, | |||
| 1090 | + | ) | |||
| 1091 | + | }) | |||
| 1092 | + | return _c | |||
| 1093 | + | } | |||
| 1094 | + | ||||
| 1095 | + | func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) Return(err error) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |||
| 1096 | + | _c.Call.Return(err) | |||
| 1097 | + | return _c | |||
| 1098 | + | } | |||
| 1099 | + | ||||
| 1100 | + | func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) RunAndReturn(run func(ctx context.Context, cfg *port.MemoryPressureConfig) error) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |||
| 1101 | + | _c.Call.Return(run) | |||
| 1102 | + | return _c | |||
| 1103 | + | } | |||
| 1104 | + | ||||
| 1105 | + | // ApplyWebProcessSettings provides a mock function for the type MockMemoryPressureApplier | |||
| 1106 | + | func (_mock *MockMemoryPressureApplier) ApplyWebProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error) { | |||
| 1107 | + | ret := _mock.Called(ctx, cfg) | |||
| 1108 | + | ||||
| 1109 | + | if len(ret) == 0 { | |||
| 1110 | + | panic("no return value specified for ApplyWebProcessSettings") | |||
| 1111 | + | } | |||
| 1112 | + | ||||
| 1113 | + | var r0 any | |||
| 1114 | + | var r1 error | |||
| 1115 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) (any, error)); ok { | |||
| 1116 | + | return returnFunc(ctx, cfg) | |||
| 1117 | + | } | |||
| 1118 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) any); ok { | |||
| 1119 | + | r0 = returnFunc(ctx, cfg) | |||
| 1120 | + | } else { | |||
| 1121 | + | if ret.Get(0) != nil { | |||
| 1122 | + | r0 = ret.Get(0).(any) | |||
| 1123 | + | } | |||
| 1124 | + | } | |||
| 1125 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, *port.MemoryPressureConfig) error); ok { | |||
| 1126 | + | r1 = returnFunc(ctx, cfg) | |||
| 1127 | + | } else { | |||
| 1128 | + | r1 = ret.Error(1) | |||
| 1129 | + | } | |||
| 1130 | + | return r0, r1 | |||
| 1131 | + | } | |||
| 1132 | + | ||||
| 1133 | + | // MockMemoryPressureApplier_ApplyWebProcessSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyWebProcessSettings' | |||
| 1134 | + | type MockMemoryPressureApplier_ApplyWebProcessSettings_Call struct { | |||
| 1135 | + | *mock.Call | |||
| 1136 | + | } | |||
| 1137 | + | ||||
| 1138 | + | // ApplyWebProcessSettings is a helper method to define mock.On call | |||
| 1139 | + | // - ctx context.Context | |||
| 1140 | + | // - cfg *port.MemoryPressureConfig | |||
| 1141 | + | func (_e *MockMemoryPressureApplier_Expecter) ApplyWebProcessSettings(ctx interface{}, cfg interface{}) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |||
| 1142 | + | return &MockMemoryPressureApplier_ApplyWebProcessSettings_Call{Call: _e.mock.On("ApplyWebProcessSettings", ctx, cfg)} | |||
| 1143 | + | } | |||
| 1144 | + | ||||
| 1145 | + | func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) Run(run func(ctx context.Context, cfg *port.MemoryPressureConfig)) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |||
| 1146 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1147 | + | var arg0 context.Context | |||
| 1148 | + | if args[0] != nil { | |||
| 1149 | + | arg0 = args[0].(context.Context) | |||
| 1150 | + | } | |||
| 1151 | + | var arg1 *port.MemoryPressureConfig | |||
| 1152 | + | if args[1] != nil { | |||
| 1153 | + | arg1 = args[1].(*port.MemoryPressureConfig) | |||
| 1154 | + | } | |||
| 1155 | + | run( | |||
| 1156 | + | arg0, | |||
| 1157 | + | arg1, | |||
| 1158 | + | ) | |||
| 1159 | + | }) | |||
| 1160 | + | return _c | |||
| 1161 | + | } | |||
| 1162 | + | ||||
| 1163 | + | func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) Return(v any, err error) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |||
| 1164 | + | _c.Call.Return(v, err) | |||
| 1165 | + | return _c | |||
| 1166 | + | } | |||
| 1167 | + | ||||
| 1168 | + | func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) RunAndReturn(run func(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error)) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |||
| 1169 | + | _c.Call.Return(run) | |||
| 1170 | + | return _c | |||
| 1171 | + | } | |||
| 1172 | + | ||||
| 1173 | + | // NewMockWebKitContextProvider creates a new instance of MockWebKitContextProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1174 | + | // The first argument is typically a *testing.T value. | |||
| 1175 | + | func NewMockWebKitContextProvider(t interface { | |||
| 1176 | + | mock.TestingT | |||
| 1177 | + | Cleanup(func()) | |||
| 1178 | + | }) *MockWebKitContextProvider { | |||
| 1179 | + | mock := &MockWebKitContextProvider{} | |||
| 1180 | + | mock.Mock.Test(t) | |||
| 1181 | + | ||||
| 1182 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1183 | + | ||||
| 1184 | + | return mock | |||
| 1185 | + | } | |||
| 1186 | + | ||||
| 1187 | + | // MockWebKitContextProvider is an autogenerated mock type for the WebKitContextProvider type | |||
| 1188 | + | type MockWebKitContextProvider struct { | |||
| 1189 | + | mock.Mock | |||
| 1190 | + | } | |||
| 1191 | + | ||||
| 1192 | + | type MockWebKitContextProvider_Expecter struct { | |||
| 1193 | + | mock *mock.Mock | |||
| 1194 | + | } | |||
| 1195 | + | ||||
| 1196 | + | func (_m *MockWebKitContextProvider) EXPECT() *MockWebKitContextProvider_Expecter { | |||
| 1197 | + | return &MockWebKitContextProvider_Expecter{mock: &_m.Mock} | |||
| 1198 | + | } | |||
| 1199 | + | ||||
| 1200 | + | // CacheDir provides a mock function for the type MockWebKitContextProvider | |||
| 1201 | + | func (_mock *MockWebKitContextProvider) CacheDir() string { | |||
| 1202 | + | ret := _mock.Called() | |||
| 1203 | + | ||||
| 1204 | + | if len(ret) == 0 { | |||
| 1205 | + | panic("no return value specified for CacheDir") | |||
| 1206 | + | } | |||
| 1207 | + | ||||
| 1208 | + | var r0 string | |||
| 1209 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 1210 | + | r0 = returnFunc() | |||
| 1211 | + | } else { | |||
| 1212 | + | r0 = ret.Get(0).(string) | |||
| 1213 | + | } | |||
| 1214 | + | return r0 | |||
| 1215 | + | } | |||
| 1216 | + | ||||
| 1217 | + | // MockWebKitContextProvider_CacheDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CacheDir' | |||
| 1218 | + | type MockWebKitContextProvider_CacheDir_Call struct { | |||
| 1219 | + | *mock.Call | |||
| 1220 | + | } | |||
| 1221 | + | ||||
| 1222 | + | // CacheDir is a helper method to define mock.On call | |||
| 1223 | + | func (_e *MockWebKitContextProvider_Expecter) CacheDir() *MockWebKitContextProvider_CacheDir_Call { | |||
| 1224 | + | return &MockWebKitContextProvider_CacheDir_Call{Call: _e.mock.On("CacheDir")} | |||
| 1225 | + | } | |||
| 1226 | + | ||||
| 1227 | + | func (_c *MockWebKitContextProvider_CacheDir_Call) Run(run func()) *MockWebKitContextProvider_CacheDir_Call { | |||
| 1228 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1229 | + | run() | |||
| 1230 | + | }) | |||
| 1231 | + | return _c | |||
| 1232 | + | } | |||
| 1233 | + | ||||
| 1234 | + | func (_c *MockWebKitContextProvider_CacheDir_Call) Return(s string) *MockWebKitContextProvider_CacheDir_Call { | |||
| 1235 | + | _c.Call.Return(s) | |||
| 1236 | + | return _c | |||
| 1237 | + | } | |||
| 1238 | + | ||||
| 1239 | + | func (_c *MockWebKitContextProvider_CacheDir_Call) RunAndReturn(run func() string) *MockWebKitContextProvider_CacheDir_Call { | |||
| 1240 | + | _c.Call.Return(run) | |||
| 1241 | + | return _c | |||
| 1242 | + | } | |||
| 1243 | + | ||||
| 1244 | + | // Close provides a mock function for the type MockWebKitContextProvider | |||
| 1245 | + | func (_mock *MockWebKitContextProvider) Close() error { | |||
| 1246 | + | ret := _mock.Called() | |||
| 1247 | + | ||||
| 1248 | + | if len(ret) == 0 { | |||
| 1249 | + | panic("no return value specified for Close") | |||
| 1250 | + | } | |||
| 1251 | + | ||||
| 1252 | + | var r0 error | |||
| 1253 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 1254 | + | r0 = returnFunc() | |||
| 1255 | + | } else { | |||
| 1256 | + | r0 = ret.Error(0) | |||
| 1257 | + | } | |||
| 1258 | + | return r0 | |||
| 1259 | + | } | |||
| 1260 | + | ||||
| 1261 | + | // MockWebKitContextProvider_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |||
| 1262 | + | type MockWebKitContextProvider_Close_Call struct { | |||
| 1263 | + | *mock.Call | |||
| 1264 | + | } | |||
| 1265 | + | ||||
| 1266 | + | // Close is a helper method to define mock.On call | |||
| 1267 | + | func (_e *MockWebKitContextProvider_Expecter) Close() *MockWebKitContextProvider_Close_Call { | |||
| 1268 | + | return &MockWebKitContextProvider_Close_Call{Call: _e.mock.On("Close")} | |||
| 1269 | + | } | |||
| 1270 | + | ||||
| 1271 | + | func (_c *MockWebKitContextProvider_Close_Call) Run(run func()) *MockWebKitContextProvider_Close_Call { | |||
| 1272 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1273 | + | run() | |||
| 1274 | + | }) | |||
| 1275 | + | return _c | |||
| 1276 | + | } | |||
| 1277 | + | ||||
| 1278 | + | func (_c *MockWebKitContextProvider_Close_Call) Return(err error) *MockWebKitContextProvider_Close_Call { | |||
| 1279 | + | _c.Call.Return(err) | |||
| 1280 | + | return _c | |||
| 1281 | + | } | |||
| 1282 | + | ||||
| 1283 | + | func (_c *MockWebKitContextProvider_Close_Call) RunAndReturn(run func() error) *MockWebKitContextProvider_Close_Call { | |||
| 1284 | + | _c.Call.Return(run) | |||
| 1285 | + | return _c | |||
| 1286 | + | } | |||
| 1287 | + | ||||
| 1288 | + | // DataDir provides a mock function for the type MockWebKitContextProvider | |||
| 1289 | + | func (_mock *MockWebKitContextProvider) DataDir() string { | |||
| 1290 | + | ret := _mock.Called() | |||
| 1291 | + | ||||
| 1292 | + | if len(ret) == 0 { | |||
| 1293 | + | panic("no return value specified for DataDir") | |||
| 1294 | + | } | |||
| 1295 | + | ||||
| 1296 | + | var r0 string | |||
| 1297 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 1298 | + | r0 = returnFunc() | |||
| 1299 | + | } else { | |||
| 1300 | + | r0 = ret.Get(0).(string) | |||
| 1301 | + | } | |||
| 1302 | + | return r0 | |||
| 1303 | + | } | |||
| 1304 | + | ||||
| 1305 | + | // MockWebKitContextProvider_DataDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DataDir' | |||
| 1306 | + | type MockWebKitContextProvider_DataDir_Call struct { | |||
| 1307 | + | *mock.Call | |||
| 1308 | + | } | |||
| 1309 | + | ||||
| 1310 | + | // DataDir is a helper method to define mock.On call | |||
| 1311 | + | func (_e *MockWebKitContextProvider_Expecter) DataDir() *MockWebKitContextProvider_DataDir_Call { | |||
| 1312 | + | return &MockWebKitContextProvider_DataDir_Call{Call: _e.mock.On("DataDir")} | |||
| 1313 | + | } | |||
| 1314 | + | ||||
| 1315 | + | func (_c *MockWebKitContextProvider_DataDir_Call) Run(run func()) *MockWebKitContextProvider_DataDir_Call { | |||
| 1316 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1317 | + | run() | |||
| 1318 | + | }) | |||
| 1319 | + | return _c | |||
| 1320 | + | } | |||
| 1321 | + | ||||
| 1322 | + | func (_c *MockWebKitContextProvider_DataDir_Call) Return(s string) *MockWebKitContextProvider_DataDir_Call { | |||
| 1323 | + | _c.Call.Return(s) | |||
| 1324 | + | return _c | |||
| 1325 | + | } | |||
| 1326 | + | ||||
| 1327 | + | func (_c *MockWebKitContextProvider_DataDir_Call) RunAndReturn(run func() string) *MockWebKitContextProvider_DataDir_Call { | |||
| 1328 | + | _c.Call.Return(run) | |||
| 1329 | + | return _c | |||
| 1330 | + | } | |||
| 1331 | + | ||||
| 1332 | + | // Initialize provides a mock function for the type MockWebKitContextProvider | |||
| 1333 | + | func (_mock *MockWebKitContextProvider) Initialize(ctx context.Context, opts port.WebKitContextOptions) error { | |||
| 1334 | + | ret := _mock.Called(ctx, opts) | |||
| 1335 | + | ||||
| 1336 | + | if len(ret) == 0 { | |||
| 1337 | + | panic("no return value specified for Initialize") | |||
| 1338 | + | } | |||
| 1339 | + | ||||
| 1340 | + | var r0 error | |||
| 1341 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebKitContextOptions) error); ok { | |||
| 1342 | + | r0 = returnFunc(ctx, opts) | |||
| 1343 | + | } else { | |||
| 1344 | + | r0 = ret.Error(0) | |||
| 1345 | + | } | |||
| 1346 | + | return r0 | |||
| 1347 | + | } | |||
| 1348 | + | ||||
| 1349 | + | // MockWebKitContextProvider_Initialize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Initialize' | |||
| 1350 | + | type MockWebKitContextProvider_Initialize_Call struct { | |||
| 1351 | + | *mock.Call | |||
| 1352 | + | } | |||
| 1353 | + | ||||
| 1354 | + | // Initialize is a helper method to define mock.On call | |||
| 1355 | + | // - ctx context.Context | |||
| 1356 | + | // - opts port.WebKitContextOptions | |||
| 1357 | + | func (_e *MockWebKitContextProvider_Expecter) Initialize(ctx interface{}, opts interface{}) *MockWebKitContextProvider_Initialize_Call { | |||
| 1358 | + | return &MockWebKitContextProvider_Initialize_Call{Call: _e.mock.On("Initialize", ctx, opts)} | |||
| 1359 | + | } | |||
| 1360 | + | ||||
| 1361 | + | func (_c *MockWebKitContextProvider_Initialize_Call) Run(run func(ctx context.Context, opts port.WebKitContextOptions)) *MockWebKitContextProvider_Initialize_Call { | |||
| 1362 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1363 | + | var arg0 context.Context | |||
| 1364 | + | if args[0] != nil { | |||
| 1365 | + | arg0 = args[0].(context.Context) | |||
| 1366 | + | } | |||
| 1367 | + | var arg1 port.WebKitContextOptions | |||
| 1368 | + | if args[1] != nil { | |||
| 1369 | + | arg1 = args[1].(port.WebKitContextOptions) | |||
| 1370 | + | } | |||
| 1371 | + | run( | |||
| 1372 | + | arg0, | |||
| 1373 | + | arg1, | |||
| 1374 | + | ) | |||
| 1375 | + | }) | |||
| 1376 | + | return _c | |||
| 1377 | + | } | |||
| 1378 | + | ||||
| 1379 | + | func (_c *MockWebKitContextProvider_Initialize_Call) Return(err error) *MockWebKitContextProvider_Initialize_Call { | |||
| 1380 | + | _c.Call.Return(err) | |||
| 1381 | + | return _c | |||
| 1382 | + | } | |||
| 1383 | + | ||||
| 1384 | + | func (_c *MockWebKitContextProvider_Initialize_Call) RunAndReturn(run func(ctx context.Context, opts port.WebKitContextOptions) error) *MockWebKitContextProvider_Initialize_Call { | |||
| 1385 | + | _c.Call.Return(run) | |||
| 1386 | + | return _c | |||
| 1387 | + | } | |||
| 1388 | + | ||||
| 1389 | + | // PrefetchDNS provides a mock function for the type MockWebKitContextProvider | |||
| 1390 | + | func (_mock *MockWebKitContextProvider) PrefetchDNS(hostname string) { | |||
| 1391 | + | _mock.Called(hostname) | |||
| 1392 | + | return | |||
| 1393 | + | } | |||
| 1394 | + | ||||
| 1395 | + | // MockWebKitContextProvider_PrefetchDNS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrefetchDNS' | |||
| 1396 | + | type MockWebKitContextProvider_PrefetchDNS_Call struct { | |||
| 1397 | + | *mock.Call | |||
| 1398 | + | } | |||
| 1399 | + | ||||
| 1400 | + | // PrefetchDNS is a helper method to define mock.On call | |||
| 1401 | + | // - hostname string | |||
| 1402 | + | func (_e *MockWebKitContextProvider_Expecter) PrefetchDNS(hostname interface{}) *MockWebKitContextProvider_PrefetchDNS_Call { | |||
| 1403 | + | return &MockWebKitContextProvider_PrefetchDNS_Call{Call: _e.mock.On("PrefetchDNS", hostname)} | |||
| 1404 | + | } | |||
| 1405 | + | ||||
| 1406 | + | func (_c *MockWebKitContextProvider_PrefetchDNS_Call) Run(run func(hostname string)) *MockWebKitContextProvider_PrefetchDNS_Call { | |||
| 1407 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1408 | + | var arg0 string | |||
| 1409 | + | if args[0] != nil { | |||
| 1410 | + | arg0 = args[0].(string) | |||
| 1411 | + | } | |||
| 1412 | + | run( | |||
| 1413 | + | arg0, | |||
| 1414 | + | ) | |||
| 1415 | + | }) | |||
| 1416 | + | return _c | |||
| 1417 | + | } | |||
| 1418 | + | ||||
| 1419 | + | func (_c *MockWebKitContextProvider_PrefetchDNS_Call) Return() *MockWebKitContextProvider_PrefetchDNS_Call { | |||
| 1420 | + | _c.Call.Return() | |||
| 1421 | + | return _c | |||
| 1422 | + | } | |||
| 1423 | + | ||||
| 1424 | + | func (_c *MockWebKitContextProvider_PrefetchDNS_Call) RunAndReturn(run func(hostname string)) *MockWebKitContextProvider_PrefetchDNS_Call { | |||
| 1425 | + | _c.Run(run) | |||
| 1426 | + | return _c | |||
| 1427 | + | } | |||
| 1428 | + | ||||
| 943 | // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1429 | // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 944 | // The first argument is typically a *testing.T value. | 1430 | // The first argument is typically a *testing.T value. | ||
| 945 | func NewMockWebView(t interface { | 1431 | func NewMockWebView(t interface { | ||
M internal/application/port/rendering_env.go +10 -0
| @@ -46,6 +46,16 @@ type RenderingEnvSettings struct { | |||
| 46 | 46 | ShowFPS bool | |
| 47 | 47 | SampleMemory bool | |
| 48 | 48 | DebugFrames bool | |
| 49 | + | ||
| 50 | + | // --- Skia rendering thread settings (from PerformanceConfig) --- | |
| 51 | + | // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS. | |
| 52 | + | // 0 means unset (use WebKit default). | |
| 53 | + | SkiaCPUPaintingThreads int | |
| 54 | + | // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS. | |
| 55 | + | // -1 means unset; 0 disables GPU tile painting. | |
| 56 | + | SkiaGPUPaintingThreads int | |
| 57 | + | // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1. | |
| 58 | + | SkiaEnableCPURendering bool | |
| 49 | 59 | } | |
| 50 | 60 | ||
| 51 | 61 | // RenderingEnvManager configures rendering environment variables | |
| @@ -46,6 +46,16 @@ type RenderingEnvSettings struct { | |||||
| 46 | ShowFPS bool | 46 | ShowFPS bool | ||
| 47 | SampleMemory bool | 47 | SampleMemory bool | ||
| 48 | DebugFrames bool | 48 | DebugFrames bool | ||
| 49 | + | ||||
| 50 | + | // --- Skia rendering thread settings (from PerformanceConfig) --- | |||
| 51 | + | // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS. | |||
| 52 | + | // 0 means unset (use WebKit default). | |||
| 53 | + | SkiaCPUPaintingThreads int | |||
| 54 | + | // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS. | |||
| 55 | + | // -1 means unset; 0 disables GPU tile painting. | |||
| 56 | + | SkiaGPUPaintingThreads int | |||
| 57 | + | // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1. | |||
| 58 | + | SkiaEnableCPURendering bool | |||
| 49 | } | 59 | } | ||
| 50 | 60 | ||||
| 51 | // RenderingEnvManager configures rendering environment variables | 61 | // RenderingEnvManager configures rendering environment variables | ||
A internal/application/port/webkit_context.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | // Package port defines interfaces for external dependencies. | |
| 2 | + | package port | |
| 3 | + | ||
| 4 | + | import "context" | |
| 5 | + | ||
| 6 | + | // MemoryPressureConfig holds memory pressure settings for a WebKit process. | |
| 7 | + | // Zero values mean "use WebKit defaults" except for KillThreshold where -1 means unset. | |
| 8 | + | type MemoryPressureConfig struct { | |
| 9 | + | // MemoryLimitMB sets the memory limit in megabytes. | |
| 10 | + | // 0 means unset (uses WebKit default: system RAM capped at 3GB). | |
| 11 | + | MemoryLimitMB int | |
| 12 | + | ||
| 13 | + | // PollIntervalSec sets the interval for memory usage checks. | |
| 14 | + | // 0 means unset (uses WebKit default: 30 seconds). | |
| 15 | + | PollIntervalSec float64 | |
| 16 | + | ||
| 17 | + | // ConservativeThreshold sets threshold for conservative memory release. | |
| 18 | + | // Must be in (0, 1). 0 means unset (uses WebKit default: 0.33). | |
| 19 | + | ConservativeThreshold float64 | |
| 20 | + | ||
| 21 | + | // StrictThreshold sets threshold for strict memory release. | |
| 22 | + | // Must be in (0, 1). 0 means unset (uses WebKit default: 0.5). | |
| 23 | + | StrictThreshold float64 | |
| 24 | + | ||
| 25 | + | // KillThreshold sets threshold for killing the process. | |
| 26 | + | // -1 means unset; 0 means never kill; values > 1 are allowed by WebKit. | |
| 27 | + | KillThreshold float64 | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // IsConfigured returns true if any memory pressure setting is configured. | |
| 31 | + | func (c *MemoryPressureConfig) IsConfigured() bool { | |
| 32 | + | if c == nil { | |
| 33 | + | return false | |
| 34 | + | } | |
| 35 | + | return c.MemoryLimitMB > 0 || | |
| 36 | + | c.PollIntervalSec > 0 || | |
| 37 | + | c.ConservativeThreshold > 0 || | |
| 38 | + | c.StrictThreshold > 0 || | |
| 39 | + | c.KillThreshold >= 0 | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // WebKitContextOptions configures WebKitContext creation. | |
| 43 | + | type WebKitContextOptions struct { | |
| 44 | + | // DataDir is the directory for persistent data (cookies, storage, etc.). | |
| 45 | + | DataDir string | |
| 46 | + | ||
| 47 | + | // CacheDir is the directory for cache data. | |
| 48 | + | CacheDir string | |
| 49 | + | ||
| 50 | + | // WebProcessMemory configures memory pressure for web processes. | |
| 51 | + | // nil means use WebKit defaults. | |
| 52 | + | WebProcessMemory *MemoryPressureConfig | |
| 53 | + | ||
| 54 | + | // NetworkProcessMemory configures memory pressure for the network process. | |
| 55 | + | // nil means use WebKit defaults. | |
| 56 | + | NetworkProcessMemory *MemoryPressureConfig | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // IsWebProcessMemoryConfigured returns true if web process memory settings are configured. | |
| 60 | + | func (o *WebKitContextOptions) IsWebProcessMemoryConfigured() bool { | |
| 61 | + | if o == nil { | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | return o.WebProcessMemory.IsConfigured() | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // IsNetworkProcessMemoryConfigured returns true if network process memory settings are configured. | |
| 68 | + | func (o *WebKitContextOptions) IsNetworkProcessMemoryConfigured() bool { | |
| 69 | + | if o == nil { | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | return o.NetworkProcessMemory.IsConfigured() | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // MemoryPressureApplier applies memory pressure settings to WebKit processes. | |
| 76 | + | // This interface abstracts the WebKit-specific memory pressure configuration | |
| 77 | + | // to allow testing without WebKit dependencies. | |
| 78 | + | type MemoryPressureApplier interface { | |
| 79 | + | // ApplyNetworkProcessSettings applies memory pressure settings to the network process. | |
| 80 | + | // Must be called BEFORE creating any NetworkSession. | |
| 81 | + | ApplyNetworkProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) error | |
| 82 | + | ||
| 83 | + | // ApplyWebProcessSettings applies memory pressure settings to web processes. | |
| 84 | + | // Returns an opaque settings object that should be passed to WebContext creation. | |
| 85 | + | // Returns nil if no settings are configured. | |
| 86 | + | ApplyWebProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) (any, error) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // WebKitContextProvider creates and manages WebKit contexts. | |
| 90 | + | // This interface abstracts WebKit context creation for testability. | |
| 91 | + | type WebKitContextProvider interface { | |
| 92 | + | // Initialize sets up the WebKit context with the given options. | |
| 93 | + | // Must be called before any other methods. | |
| 94 | + | Initialize(ctx context.Context, opts WebKitContextOptions) error | |
| 95 | + | ||
| 96 | + | // DataDir returns the data directory path. | |
| 97 | + | DataDir() string | |
| 98 | + | ||
| 99 | + | // CacheDir returns the cache directory path. | |
| 100 | + | CacheDir() string | |
| 101 | + | ||
| 102 | + | // PrefetchDNS prefetches DNS for the given hostname. | |
| 103 | + | PrefetchDNS(hostname string) | |
| 104 | + | ||
| 105 | + | // Close releases resources held by the context. | |
| 106 | + | Close() error | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | // Package port defines interfaces for external dependencies. | |||
| 2 | + | package port | |||
| 3 | + | ||||
| 4 | + | import "context" | |||
| 5 | + | ||||
| 6 | + | // MemoryPressureConfig holds memory pressure settings for a WebKit process. | |||
| 7 | + | // Zero values mean "use WebKit defaults" except for KillThreshold where -1 means unset. | |||
| 8 | + | type MemoryPressureConfig struct { | |||
| 9 | + | // MemoryLimitMB sets the memory limit in megabytes. | |||
| 10 | + | // 0 means unset (uses WebKit default: system RAM capped at 3GB). | |||
| 11 | + | MemoryLimitMB int | |||
| 12 | + | ||||
| 13 | + | // PollIntervalSec sets the interval for memory usage checks. | |||
| 14 | + | // 0 means unset (uses WebKit default: 30 seconds). | |||
| 15 | + | PollIntervalSec float64 | |||
| 16 | + | ||||
| 17 | + | // ConservativeThreshold sets threshold for conservative memory release. | |||
| 18 | + | // Must be in (0, 1). 0 means unset (uses WebKit default: 0.33). | |||
| 19 | + | ConservativeThreshold float64 | |||
| 20 | + | ||||
| 21 | + | // StrictThreshold sets threshold for strict memory release. | |||
| 22 | + | // Must be in (0, 1). 0 means unset (uses WebKit default: 0.5). | |||
| 23 | + | StrictThreshold float64 | |||
| 24 | + | ||||
| 25 | + | // KillThreshold sets threshold for killing the process. | |||
| 26 | + | // -1 means unset; 0 means never kill; values > 1 are allowed by WebKit. | |||
| 27 | + | KillThreshold float64 | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // IsConfigured returns true if any memory pressure setting is configured. | |||
| 31 | + | func (c *MemoryPressureConfig) IsConfigured() bool { | |||
| 32 | + | if c == nil { | |||
| 33 | + | return false | |||
| 34 | + | } | |||
| 35 | + | return c.MemoryLimitMB > 0 || | |||
| 36 | + | c.PollIntervalSec > 0 || | |||
| 37 | + | c.ConservativeThreshold > 0 || | |||
| 38 | + | c.StrictThreshold > 0 || | |||
| 39 | + | c.KillThreshold >= 0 | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // WebKitContextOptions configures WebKitContext creation. | |||
| 43 | + | type WebKitContextOptions struct { | |||
| 44 | + | // DataDir is the directory for persistent data (cookies, storage, etc.). | |||
| 45 | + | DataDir string | |||
| 46 | + | ||||
| 47 | + | // CacheDir is the directory for cache data. | |||
| 48 | + | CacheDir string | |||
| 49 | + | ||||
| 50 | + | // WebProcessMemory configures memory pressure for web processes. | |||
| 51 | + | // nil means use WebKit defaults. | |||
| 52 | + | WebProcessMemory *MemoryPressureConfig | |||
| 53 | + | ||||
| 54 | + | // NetworkProcessMemory configures memory pressure for the network process. | |||
| 55 | + | // nil means use WebKit defaults. | |||
| 56 | + | NetworkProcessMemory *MemoryPressureConfig | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // IsWebProcessMemoryConfigured returns true if web process memory settings are configured. | |||
| 60 | + | func (o *WebKitContextOptions) IsWebProcessMemoryConfigured() bool { | |||
| 61 | + | if o == nil { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | return o.WebProcessMemory.IsConfigured() | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // IsNetworkProcessMemoryConfigured returns true if network process memory settings are configured. | |||
| 68 | + | func (o *WebKitContextOptions) IsNetworkProcessMemoryConfigured() bool { | |||
| 69 | + | if o == nil { | |||
| 70 | + | return false | |||
| 71 | + | } | |||
| 72 | + | return o.NetworkProcessMemory.IsConfigured() | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // MemoryPressureApplier applies memory pressure settings to WebKit processes. | |||
| 76 | + | // This interface abstracts the WebKit-specific memory pressure configuration | |||
| 77 | + | // to allow testing without WebKit dependencies. | |||
| 78 | + | type MemoryPressureApplier interface { | |||
| 79 | + | // ApplyNetworkProcessSettings applies memory pressure settings to the network process. | |||
| 80 | + | // Must be called BEFORE creating any NetworkSession. | |||
| 81 | + | ApplyNetworkProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) error | |||
| 82 | + | ||||
| 83 | + | // ApplyWebProcessSettings applies memory pressure settings to web processes. | |||
| 84 | + | // Returns an opaque settings object that should be passed to WebContext creation. | |||
| 85 | + | // Returns nil if no settings are configured. | |||
| 86 | + | ApplyWebProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) (any, error) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // WebKitContextProvider creates and manages WebKit contexts. | |||
| 90 | + | // This interface abstracts WebKit context creation for testability. | |||
| 91 | + | type WebKitContextProvider interface { | |||
| 92 | + | // Initialize sets up the WebKit context with the given options. | |||
| 93 | + | // Must be called before any other methods. | |||
| 94 | + | Initialize(ctx context.Context, opts WebKitContextOptions) error | |||
| 95 | + | ||||
| 96 | + | // DataDir returns the data directory path. | |||
| 97 | + | DataDir() string | |||
| 98 | + | ||||
| 99 | + | // CacheDir returns the cache directory path. | |||
| 100 | + | CacheDir() string | |||
| 101 | + | ||||
| 102 | + | // PrefetchDNS prefetches DNS for the given hostname. | |||
| 103 | + | PrefetchDNS(hostname string) | |||
| 104 | + | ||||
| 105 | + | // Close releases resources held by the context. | |||
| 106 | + | Close() error | |||
| 107 | + | } | |||
A internal/application/port/webkit_context_test.go +185 -0
| @@ -0,0 +1,185 @@ | |||
| 1 | + | package port_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestMemoryPressureConfig_IsConfigured(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | config *port.MemoryPressureConfig | |
| 14 | + | expected bool | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "nil config", | |
| 18 | + | config: nil, | |
| 19 | + | expected: false, | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | name: "empty config with default kill threshold", | |
| 23 | + | config: &port.MemoryPressureConfig{ | |
| 24 | + | KillThreshold: -1, // explicit unset | |
| 25 | + | }, | |
| 26 | + | expected: false, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "only MemoryLimitMB set", | |
| 30 | + | config: &port.MemoryPressureConfig{ | |
| 31 | + | MemoryLimitMB: 1024, | |
| 32 | + | }, | |
| 33 | + | expected: true, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "only PollIntervalSec set", | |
| 37 | + | config: &port.MemoryPressureConfig{ | |
| 38 | + | PollIntervalSec: 30.0, | |
| 39 | + | }, | |
| 40 | + | expected: true, | |
| 41 | + | }, | |
| 42 | + | { | |
| 43 | + | name: "only ConservativeThreshold set", | |
| 44 | + | config: &port.MemoryPressureConfig{ | |
| 45 | + | ConservativeThreshold: 0.33, | |
| 46 | + | }, | |
| 47 | + | expected: true, | |
| 48 | + | }, | |
| 49 | + | { | |
| 50 | + | name: "only StrictThreshold set", | |
| 51 | + | config: &port.MemoryPressureConfig{ | |
| 52 | + | StrictThreshold: 0.5, | |
| 53 | + | }, | |
| 54 | + | expected: true, | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | name: "KillThreshold set to 0 (never kill)", | |
| 58 | + | config: &port.MemoryPressureConfig{ | |
| 59 | + | KillThreshold: 0, | |
| 60 | + | }, | |
| 61 | + | expected: true, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | name: "KillThreshold set to positive value", | |
| 65 | + | config: &port.MemoryPressureConfig{ | |
| 66 | + | KillThreshold: 1.5, | |
| 67 | + | }, | |
| 68 | + | expected: true, | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | name: "KillThreshold unset (-1)", | |
| 72 | + | config: &port.MemoryPressureConfig{ | |
| 73 | + | KillThreshold: -1, | |
| 74 | + | }, | |
| 75 | + | expected: false, | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | name: "all defaults (unset)", | |
| 79 | + | config: &port.MemoryPressureConfig{ | |
| 80 | + | MemoryLimitMB: 0, | |
| 81 | + | PollIntervalSec: 0, | |
| 82 | + | ConservativeThreshold: 0, | |
| 83 | + | StrictThreshold: 0, | |
| 84 | + | KillThreshold: -1, | |
| 85 | + | }, | |
| 86 | + | expected: false, | |
| 87 | + | }, | |
| 88 | + | { | |
| 89 | + | name: "fully configured", | |
| 90 | + | config: &port.MemoryPressureConfig{ | |
| 91 | + | MemoryLimitMB: 2048, | |
| 92 | + | PollIntervalSec: 15.0, | |
| 93 | + | ConservativeThreshold: 0.25, | |
| 94 | + | StrictThreshold: 0.6, | |
| 95 | + | KillThreshold: 0.9, | |
| 96 | + | }, | |
| 97 | + | expected: true, | |
| 98 | + | }, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | for _, tt := range tests { | |
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |
| 103 | + | result := tt.config.IsConfigured() | |
| 104 | + | assert.Equal(t, tt.expected, result) | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestWebKitContextOptions_IsWebProcessMemoryConfigured(t *testing.T) { | |
| 110 | + | tests := []struct { | |
| 111 | + | name string | |
| 112 | + | opts port.WebKitContextOptions | |
| 113 | + | expected bool | |
| 114 | + | }{ | |
| 115 | + | { | |
| 116 | + | name: "nil WebProcessMemory", | |
| 117 | + | opts: port.WebKitContextOptions{}, | |
| 118 | + | expected: false, | |
| 119 | + | }, | |
| 120 | + | { | |
| 121 | + | name: "empty WebProcessMemory with default kill threshold", | |
| 122 | + | opts: port.WebKitContextOptions{ | |
| 123 | + | WebProcessMemory: &port.MemoryPressureConfig{ | |
| 124 | + | KillThreshold: -1, // explicit unset | |
| 125 | + | }, | |
| 126 | + | }, | |
| 127 | + | expected: false, | |
| 128 | + | }, | |
| 129 | + | { | |
| 130 | + | name: "configured WebProcessMemory", | |
| 131 | + | opts: port.WebKitContextOptions{ | |
| 132 | + | WebProcessMemory: &port.MemoryPressureConfig{ | |
| 133 | + | MemoryLimitMB: 1024, | |
| 134 | + | }, | |
| 135 | + | }, | |
| 136 | + | expected: true, | |
| 137 | + | }, | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | for _, tt := range tests { | |
| 141 | + | t.Run(tt.name, func(t *testing.T) { | |
| 142 | + | result := tt.opts.IsWebProcessMemoryConfigured() | |
| 143 | + | assert.Equal(t, tt.expected, result) | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestWebKitContextOptions_IsNetworkProcessMemoryConfigured(t *testing.T) { | |
| 149 | + | tests := []struct { | |
| 150 | + | name string | |
| 151 | + | opts port.WebKitContextOptions | |
| 152 | + | expected bool | |
| 153 | + | }{ | |
| 154 | + | { | |
| 155 | + | name: "nil NetworkProcessMemory", | |
| 156 | + | opts: port.WebKitContextOptions{}, | |
| 157 | + | expected: false, | |
| 158 | + | }, | |
| 159 | + | { | |
| 160 | + | name: "empty NetworkProcessMemory with default kill threshold", | |
| 161 | + | opts: port.WebKitContextOptions{ | |
| 162 | + | NetworkProcessMemory: &port.MemoryPressureConfig{ | |
| 163 | + | KillThreshold: -1, // explicit unset | |
| 164 | + | }, | |
| 165 | + | }, | |
| 166 | + | expected: false, | |
| 167 | + | }, | |
| 168 | + | { | |
| 169 | + | name: "configured NetworkProcessMemory", | |
| 170 | + | opts: port.WebKitContextOptions{ | |
| 171 | + | NetworkProcessMemory: &port.MemoryPressureConfig{ | |
| 172 | + | PollIntervalSec: 20.0, | |
| 173 | + | }, | |
| 174 | + | }, | |
| 175 | + | expected: true, | |
| 176 | + | }, | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | for _, tt := range tests { | |
| 180 | + | t.Run(tt.name, func(t *testing.T) { | |
| 181 | + | result := tt.opts.IsNetworkProcessMemoryConfigured() | |
| 182 | + | assert.Equal(t, tt.expected, result) | |
| 183 | + | }) | |
| 184 | + | } | |
| 185 | + | } | |
| @@ -0,0 +1,185 @@ | |||||
| 1 | + | package port_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestMemoryPressureConfig_IsConfigured(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | config *port.MemoryPressureConfig | |||
| 14 | + | expected bool | |||
| 15 | + | }{ | |||
| 16 | + | { | |||
| 17 | + | name: "nil config", | |||
| 18 | + | config: nil, | |||
| 19 | + | expected: false, | |||
| 20 | + | }, | |||
| 21 | + | { | |||
| 22 | + | name: "empty config with default kill threshold", | |||
| 23 | + | config: &port.MemoryPressureConfig{ | |||
| 24 | + | KillThreshold: -1, // explicit unset | |||
| 25 | + | }, | |||
| 26 | + | expected: false, | |||
| 27 | + | }, | |||
| 28 | + | { | |||
| 29 | + | name: "only MemoryLimitMB set", | |||
| 30 | + | config: &port.MemoryPressureConfig{ | |||
| 31 | + | MemoryLimitMB: 1024, | |||
| 32 | + | }, | |||
| 33 | + | expected: true, | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "only PollIntervalSec set", | |||
| 37 | + | config: &port.MemoryPressureConfig{ | |||
| 38 | + | PollIntervalSec: 30.0, | |||
| 39 | + | }, | |||
| 40 | + | expected: true, | |||
| 41 | + | }, | |||
| 42 | + | { | |||
| 43 | + | name: "only ConservativeThreshold set", | |||
| 44 | + | config: &port.MemoryPressureConfig{ | |||
| 45 | + | ConservativeThreshold: 0.33, | |||
| 46 | + | }, | |||
| 47 | + | expected: true, | |||
| 48 | + | }, | |||
| 49 | + | { | |||
| 50 | + | name: "only StrictThreshold set", | |||
| 51 | + | config: &port.MemoryPressureConfig{ | |||
| 52 | + | StrictThreshold: 0.5, | |||
| 53 | + | }, | |||
| 54 | + | expected: true, | |||
| 55 | + | }, | |||
| 56 | + | { | |||
| 57 | + | name: "KillThreshold set to 0 (never kill)", | |||
| 58 | + | config: &port.MemoryPressureConfig{ | |||
| 59 | + | KillThreshold: 0, | |||
| 60 | + | }, | |||
| 61 | + | expected: true, | |||
| 62 | + | }, | |||
| 63 | + | { | |||
| 64 | + | name: "KillThreshold set to positive value", | |||
| 65 | + | config: &port.MemoryPressureConfig{ | |||
| 66 | + | KillThreshold: 1.5, | |||
| 67 | + | }, | |||
| 68 | + | expected: true, | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | name: "KillThreshold unset (-1)", | |||
| 72 | + | config: &port.MemoryPressureConfig{ | |||
| 73 | + | KillThreshold: -1, | |||
| 74 | + | }, | |||
| 75 | + | expected: false, | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "all defaults (unset)", | |||
| 79 | + | config: &port.MemoryPressureConfig{ | |||
| 80 | + | MemoryLimitMB: 0, | |||
| 81 | + | PollIntervalSec: 0, | |||
| 82 | + | ConservativeThreshold: 0, | |||
| 83 | + | StrictThreshold: 0, | |||
| 84 | + | KillThreshold: -1, | |||
| 85 | + | }, | |||
| 86 | + | expected: false, | |||
| 87 | + | }, | |||
| 88 | + | { | |||
| 89 | + | name: "fully configured", | |||
| 90 | + | config: &port.MemoryPressureConfig{ | |||
| 91 | + | MemoryLimitMB: 2048, | |||
| 92 | + | PollIntervalSec: 15.0, | |||
| 93 | + | ConservativeThreshold: 0.25, | |||
| 94 | + | StrictThreshold: 0.6, | |||
| 95 | + | KillThreshold: 0.9, | |||
| 96 | + | }, | |||
| 97 | + | expected: true, | |||
| 98 | + | }, | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | for _, tt := range tests { | |||
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 103 | + | result := tt.config.IsConfigured() | |||
| 104 | + | assert.Equal(t, tt.expected, result) | |||
| 105 | + | }) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestWebKitContextOptions_IsWebProcessMemoryConfigured(t *testing.T) { | |||
| 110 | + | tests := []struct { | |||
| 111 | + | name string | |||
| 112 | + | opts port.WebKitContextOptions | |||
| 113 | + | expected bool | |||
| 114 | + | }{ | |||
| 115 | + | { | |||
| 116 | + | name: "nil WebProcessMemory", | |||
| 117 | + | opts: port.WebKitContextOptions{}, | |||
| 118 | + | expected: false, | |||
| 119 | + | }, | |||
| 120 | + | { | |||
| 121 | + | name: "empty WebProcessMemory with default kill threshold", | |||
| 122 | + | opts: port.WebKitContextOptions{ | |||
| 123 | + | WebProcessMemory: &port.MemoryPressureConfig{ | |||
| 124 | + | KillThreshold: -1, // explicit unset | |||
| 125 | + | }, | |||
| 126 | + | }, | |||
| 127 | + | expected: false, | |||
| 128 | + | }, | |||
| 129 | + | { | |||
| 130 | + | name: "configured WebProcessMemory", | |||
| 131 | + | opts: port.WebKitContextOptions{ | |||
| 132 | + | WebProcessMemory: &port.MemoryPressureConfig{ | |||
| 133 | + | MemoryLimitMB: 1024, | |||
| 134 | + | }, | |||
| 135 | + | }, | |||
| 136 | + | expected: true, | |||
| 137 | + | }, | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | for _, tt := range tests { | |||
| 141 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 142 | + | result := tt.opts.IsWebProcessMemoryConfigured() | |||
| 143 | + | assert.Equal(t, tt.expected, result) | |||
| 144 | + | }) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestWebKitContextOptions_IsNetworkProcessMemoryConfigured(t *testing.T) { | |||
| 149 | + | tests := []struct { | |||
| 150 | + | name string | |||
| 151 | + | opts port.WebKitContextOptions | |||
| 152 | + | expected bool | |||
| 153 | + | }{ | |||
| 154 | + | { | |||
| 155 | + | name: "nil NetworkProcessMemory", | |||
| 156 | + | opts: port.WebKitContextOptions{}, | |||
| 157 | + | expected: false, | |||
| 158 | + | }, | |||
| 159 | + | { | |||
| 160 | + | name: "empty NetworkProcessMemory with default kill threshold", | |||
| 161 | + | opts: port.WebKitContextOptions{ | |||
| 162 | + | NetworkProcessMemory: &port.MemoryPressureConfig{ | |||
| 163 | + | KillThreshold: -1, // explicit unset | |||
| 164 | + | }, | |||
| 165 | + | }, | |||
| 166 | + | expected: false, | |||
| 167 | + | }, | |||
| 168 | + | { | |||
| 169 | + | name: "configured NetworkProcessMemory", | |||
| 170 | + | opts: port.WebKitContextOptions{ | |||
| 171 | + | NetworkProcessMemory: &port.MemoryPressureConfig{ | |||
| 172 | + | PollIntervalSec: 20.0, | |||
| 173 | + | }, | |||
| 174 | + | }, | |||
| 175 | + | expected: true, | |||
| 176 | + | }, | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | for _, tt := range tests { | |||
| 180 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 181 | + | result := tt.opts.IsNetworkProcessMemoryConfigured() | |||
| 182 | + | assert.Equal(t, tt.expected, result) | |||
| 183 | + | }) | |||
| 184 | + | } | |||
| 185 | + | } | |||
M internal/application/port/webui_config.go +16 -0
| @@ -5,11 +5,27 @@ import "context" | |||
| 5 | 5 | // WebUIConfig represents the subset of configuration editable in dumb://config. | |
| 6 | 6 | type WebUIConfig struct { | |
| 7 | 7 | Appearance WebUIAppearanceConfig `json:"appearance"` | |
| 8 | + | Performance WebUIPerformanceConfig `json:"performance"` | |
| 8 | 9 | DefaultUIScale float64 `json:"default_ui_scale"` | |
| 9 | 10 | DefaultSearchEngine string `json:"default_search_engine"` | |
| 10 | 11 | SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"` | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 14 | + | // WebUIPerformanceConfig represents performance settings editable in dumb://config. | |
| 15 | + | type WebUIPerformanceConfig struct { | |
| 16 | + | Profile string `json:"profile"` | |
| 17 | + | Custom WebUICustomPerformanceConfig `json:"custom"` | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // WebUICustomPerformanceConfig holds user-editable fields for custom profile. | |
| 21 | + | type WebUICustomPerformanceConfig struct { | |
| 22 | + | SkiaCPUThreads int `json:"skia_cpu_threads"` | |
| 23 | + | SkiaGPUThreads int `json:"skia_gpu_threads"` | |
| 24 | + | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |
| 25 | + | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |
| 26 | + | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |
| 27 | + | } | |
| 28 | + | ||
| 13 | 29 | type WebUIAppearanceConfig struct { | |
| 14 | 30 | SansFont string `json:"sans_font"` | |
| 15 | 31 | SerifFont string `json:"serif_font"` | |
| @@ -5,11 +5,27 @@ import "context" | |||||
| 5 | // WebUIConfig represents the subset of configuration editable in dumb://config. | 5 | // WebUIConfig represents the subset of configuration editable in dumb://config. | ||
| 6 | type WebUIConfig struct { | 6 | type WebUIConfig struct { | ||
| 7 | Appearance WebUIAppearanceConfig `json:"appearance"` | 7 | Appearance WebUIAppearanceConfig `json:"appearance"` | ||
| 8 | + | Performance WebUIPerformanceConfig `json:"performance"` | |||
| 8 | DefaultUIScale float64 `json:"default_ui_scale"` | 9 | DefaultUIScale float64 `json:"default_ui_scale"` | ||
| 9 | DefaultSearchEngine string `json:"default_search_engine"` | 10 | DefaultSearchEngine string `json:"default_search_engine"` | ||
| 10 | SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"` | 11 | SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"` | ||
| 11 | } | 12 | } | ||
| 12 | 13 | ||||
| 14 | + | // WebUIPerformanceConfig represents performance settings editable in dumb://config. | |||
| 15 | + | type WebUIPerformanceConfig struct { | |||
| 16 | + | Profile string `json:"profile"` | |||
| 17 | + | Custom WebUICustomPerformanceConfig `json:"custom"` | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // WebUICustomPerformanceConfig holds user-editable fields for custom profile. | |||
| 21 | + | type WebUICustomPerformanceConfig struct { | |||
| 22 | + | SkiaCPUThreads int `json:"skia_cpu_threads"` | |||
| 23 | + | SkiaGPUThreads int `json:"skia_gpu_threads"` | |||
| 24 | + | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |||
| 25 | + | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |||
| 26 | + | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |||
| 27 | + | } | |||
| 28 | + | ||||
| 13 | type WebUIAppearanceConfig struct { | 29 | type WebUIAppearanceConfig struct { | ||
| 14 | SansFont string `json:"sans_font"` | 30 | SansFont string `json:"sans_font"` | ||
| 15 | SerifFont string `json:"serif_font"` | 31 | SerifFont string `json:"serif_font"` | ||
M internal/bootstrap/webkit_stack.go +75 -4
| @@ -32,6 +32,26 @@ func BuildWebKitStack( | |||
| 32 | 32 | themeManager *theme.Manager, | |
| 33 | 33 | logger zerolog.Logger, | |
| 34 | 34 | ) WebKitStack { | |
| 35 | + | // Detect hardware for performance profile scaling | |
| 36 | + | hwSurveyor := env.NewHardwareSurveyor() | |
| 37 | + | hwInfo := hwSurveyor.Survey(ctx) | |
| 38 | + | logger.Info(). | |
| 39 | + | Int("cpu_cores", hwInfo.CPUCores). | |
| 40 | + | Int("cpu_threads", hwInfo.CPUThreads). | |
| 41 | + | Uint64("ram_mb", hwInfo.TotalRAM/(1024*1024)). | |
| 42 | + | Str("gpu_vendor", string(hwInfo.GPUVendor)). | |
| 43 | + | Uint64("vram_mb", hwInfo.VRAM/(1024*1024)). | |
| 44 | + | Msg("hardware survey completed") | |
| 45 | + | ||
| 46 | + | // Resolve performance profile to get actual settings | |
| 47 | + | perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo) | |
| 48 | + | logger.Info(). | |
| 49 | + | Str("profile", string(cfg.Performance.Profile)). | |
| 50 | + | Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads). | |
| 51 | + | Int("skia_gpu_threads", perfSettings.SkiaGPUPaintingThreads). | |
| 52 | + | Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount). | |
| 53 | + | Msg("resolved performance profile") | |
| 54 | + | ||
| 35 | 55 | // CRITICAL: Configure rendering environment BEFORE WebKit/GTK initialization. | |
| 36 | 56 | // Environment variables must be set before GTK/WebKit/GStreamer initializes. | |
| 37 | 57 | renderEnv := env.NewManager() | |
| @@ -58,6 +78,11 @@ func BuildWebKitStack( | |||
| 58 | 78 | ShowFPS: cfg.Rendering.ShowFPS, | |
| 59 | 79 | SampleMemory: cfg.Rendering.SampleMemory, | |
| 60 | 80 | DebugFrames: cfg.Rendering.DebugFrames, | |
| 81 | + | ||
| 82 | + | // Skia rendering thread settings (from resolved profile) | |
| 83 | + | SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads, | |
| 84 | + | SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads, | |
| 85 | + | SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering, | |
| 61 | 86 | } | |
| 62 | 87 | if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil { | |
| 63 | 88 | logger.Warn().Err(err).Msg("failed to apply rendering environment") | |
| @@ -67,7 +92,35 @@ func BuildWebKitStack( | |||
| 67 | 92 | Interface("vars", renderEnv.GetAppliedVars()). | |
| 68 | 93 | Msg("rendering environment configured") | |
| 69 | 94 | ||
| 70 | - | wkCtx, err := webkit.NewWebKitContext(ctx, dataDir, cacheDir) | |
| 95 | + | // Build WebKitContext options with memory pressure settings | |
| 96 | + | wkOpts := port.WebKitContextOptions{ | |
| 97 | + | DataDir: dataDir, | |
| 98 | + | CacheDir: cacheDir, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Configure web process memory pressure if any setting is configured | |
| 102 | + | if hasWebProcessMemoryConfig(&perfSettings) { | |
| 103 | + | wkOpts.WebProcessMemory = &port.MemoryPressureConfig{ | |
| 104 | + | MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB, | |
| 105 | + | PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec, | |
| 106 | + | ConservativeThreshold: perfSettings.WebProcessMemoryConservativeThreshold, | |
| 107 | + | StrictThreshold: perfSettings.WebProcessMemoryStrictThreshold, | |
| 108 | + | KillThreshold: perfSettings.WebProcessMemoryKillThreshold, | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Configure network process memory pressure if any setting is configured | |
| 113 | + | if hasNetworkProcessMemoryConfig(&perfSettings) { | |
| 114 | + | wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{ | |
| 115 | + | MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB, | |
| 116 | + | PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec, | |
| 117 | + | ConservativeThreshold: perfSettings.NetworkProcessMemoryConservativeThreshold, | |
| 118 | + | StrictThreshold: perfSettings.NetworkProcessMemoryStrictThreshold, | |
| 119 | + | KillThreshold: perfSettings.NetworkProcessMemoryKillThreshold, | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | |
| 71 | 124 | if err != nil { | |
| 72 | 125 | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |
| 73 | 126 | } | |
| @@ -106,9 +159,9 @@ func BuildWebKitStack( | |||
| 106 | 159 | ||
| 107 | 160 | messageRouter := webkit.NewMessageRouter(ctx) | |
| 108 | 161 | poolCfg := webkit.DefaultPoolConfig() | |
| 109 | - | // Override prewarm count from config if set | |
| 110 | - | if cfg.Performance.WebViewPoolPrewarmCount > 0 { | |
| 111 | - | poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount | |
| 162 | + | // Override prewarm count from resolved profile settings | |
| 163 | + | if perfSettings.WebViewPoolPrewarmCount > 0 { | |
| 164 | + | poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount | |
| 112 | 165 | } | |
| 113 | 166 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | |
| 114 | 167 | // Ensure prewarmed WebViews pick up the theme background color. | |
| @@ -135,3 +188,21 @@ func BuildWebKitStack( | |||
| 135 | 188 | FilterManager: filterManager, | |
| 136 | 189 | } | |
| 137 | 190 | } | |
| 191 | + | ||
| 192 | + | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | |
| 193 | + | func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | |
| 194 | + | return p.WebProcessMemoryLimitMB > 0 || | |
| 195 | + | p.WebProcessMemoryPollIntervalSec > 0 || | |
| 196 | + | p.WebProcessMemoryConservativeThreshold > 0 || | |
| 197 | + | p.WebProcessMemoryStrictThreshold > 0 || | |
| 198 | + | p.WebProcessMemoryKillThreshold >= 0 | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // hasNetworkProcessMemoryConfig returns true if any network process memory setting is configured. | |
| 202 | + | func hasNetworkProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | |
| 203 | + | return p.NetworkProcessMemoryLimitMB > 0 || | |
| 204 | + | p.NetworkProcessMemoryPollIntervalSec > 0 || | |
| 205 | + | p.NetworkProcessMemoryConservativeThreshold > 0 || | |
| 206 | + | p.NetworkProcessMemoryStrictThreshold > 0 || | |
| 207 | + | p.NetworkProcessMemoryKillThreshold >= 0 | |
| 208 | + | } | |
| @@ -32,6 +32,26 @@ func BuildWebKitStack( | |||||
| 32 | themeManager *theme.Manager, | 32 | themeManager *theme.Manager, | ||
| 33 | logger zerolog.Logger, | 33 | logger zerolog.Logger, | ||
| 34 | ) WebKitStack { | 34 | ) WebKitStack { | ||
| 35 | + | // Detect hardware for performance profile scaling | |||
| 36 | + | hwSurveyor := env.NewHardwareSurveyor() | |||
| 37 | + | hwInfo := hwSurveyor.Survey(ctx) | |||
| 38 | + | logger.Info(). | |||
| 39 | + | Int("cpu_cores", hwInfo.CPUCores). | |||
| 40 | + | Int("cpu_threads", hwInfo.CPUThreads). | |||
| 41 | + | Uint64("ram_mb", hwInfo.TotalRAM/(1024*1024)). | |||
| 42 | + | Str("gpu_vendor", string(hwInfo.GPUVendor)). | |||
| 43 | + | Uint64("vram_mb", hwInfo.VRAM/(1024*1024)). | |||
| 44 | + | Msg("hardware survey completed") | |||
| 45 | + | ||||
| 46 | + | // Resolve performance profile to get actual settings | |||
| 47 | + | perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo) | |||
| 48 | + | logger.Info(). | |||
| 49 | + | Str("profile", string(cfg.Performance.Profile)). | |||
| 50 | + | Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads). | |||
| 51 | + | Int("skia_gpu_threads", perfSettings.SkiaGPUPaintingThreads). | |||
| 52 | + | Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount). | |||
| 53 | + | Msg("resolved performance profile") | |||
| 54 | + | ||||
| 35 | // CRITICAL: Configure rendering environment BEFORE WebKit/GTK initialization. | 55 | // CRITICAL: Configure rendering environment BEFORE WebKit/GTK initialization. | ||
| 36 | // Environment variables must be set before GTK/WebKit/GStreamer initializes. | 56 | // Environment variables must be set before GTK/WebKit/GStreamer initializes. | ||
| 37 | renderEnv := env.NewManager() | 57 | renderEnv := env.NewManager() | ||
| @@ -58,6 +78,11 @@ func BuildWebKitStack( | |||||
| 58 | ShowFPS: cfg.Rendering.ShowFPS, | 78 | ShowFPS: cfg.Rendering.ShowFPS, | ||
| 59 | SampleMemory: cfg.Rendering.SampleMemory, | 79 | SampleMemory: cfg.Rendering.SampleMemory, | ||
| 60 | DebugFrames: cfg.Rendering.DebugFrames, | 80 | DebugFrames: cfg.Rendering.DebugFrames, | ||
| 81 | + | ||||
| 82 | + | // Skia rendering thread settings (from resolved profile) | |||
| 83 | + | SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads, | |||
| 84 | + | SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads, | |||
| 85 | + | SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering, | |||
| 61 | } | 86 | } | ||
| 62 | if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil { | 87 | if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil { | ||
| 63 | logger.Warn().Err(err).Msg("failed to apply rendering environment") | 88 | logger.Warn().Err(err).Msg("failed to apply rendering environment") | ||
| @@ -67,7 +92,35 @@ func BuildWebKitStack( | |||||
| 67 | Interface("vars", renderEnv.GetAppliedVars()). | 92 | Interface("vars", renderEnv.GetAppliedVars()). | ||
| 68 | Msg("rendering environment configured") | 93 | Msg("rendering environment configured") | ||
| 69 | 94 | ||||
| 70 | - | wkCtx, err := webkit.NewWebKitContext(ctx, dataDir, cacheDir) | 95 | + | // Build WebKitContext options with memory pressure settings |
| 96 | + | wkOpts := port.WebKitContextOptions{ | |||
| 97 | + | DataDir: dataDir, | |||
| 98 | + | CacheDir: cacheDir, | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Configure web process memory pressure if any setting is configured | |||
| 102 | + | if hasWebProcessMemoryConfig(&perfSettings) { | |||
| 103 | + | wkOpts.WebProcessMemory = &port.MemoryPressureConfig{ | |||
| 104 | + | MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB, | |||
| 105 | + | PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec, | |||
| 106 | + | ConservativeThreshold: perfSettings.WebProcessMemoryConservativeThreshold, | |||
| 107 | + | StrictThreshold: perfSettings.WebProcessMemoryStrictThreshold, | |||
| 108 | + | KillThreshold: perfSettings.WebProcessMemoryKillThreshold, | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Configure network process memory pressure if any setting is configured | |||
| 113 | + | if hasNetworkProcessMemoryConfig(&perfSettings) { | |||
| 114 | + | wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{ | |||
| 115 | + | MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB, | |||
| 116 | + | PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec, | |||
| 117 | + | ConservativeThreshold: perfSettings.NetworkProcessMemoryConservativeThreshold, | |||
| 118 | + | StrictThreshold: perfSettings.NetworkProcessMemoryStrictThreshold, | |||
| 119 | + | KillThreshold: perfSettings.NetworkProcessMemoryKillThreshold, | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | |||
| 71 | if err != nil { | 124 | if err != nil { | ||
| 72 | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | 125 | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | ||
| 73 | } | 126 | } | ||
| @@ -106,9 +159,9 @@ func BuildWebKitStack( | |||||
| 106 | 159 | ||||
| 107 | messageRouter := webkit.NewMessageRouter(ctx) | 160 | messageRouter := webkit.NewMessageRouter(ctx) | ||
| 108 | poolCfg := webkit.DefaultPoolConfig() | 161 | poolCfg := webkit.DefaultPoolConfig() | ||
| 109 | - | // Override prewarm count from config if set | 162 | + | // Override prewarm count from resolved profile settings |
| 110 | - | if cfg.Performance.WebViewPoolPrewarmCount > 0 { | 163 | + | if perfSettings.WebViewPoolPrewarmCount > 0 { |
| 111 | - | poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount | 164 | + | poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount |
| 112 | } | 165 | } | ||
| 113 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | 166 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | ||
| 114 | // Ensure prewarmed WebViews pick up the theme background color. | 167 | // Ensure prewarmed WebViews pick up the theme background color. | ||
| @@ -135,3 +188,21 @@ func BuildWebKitStack( | |||||
| 135 | FilterManager: filterManager, | 188 | FilterManager: filterManager, | ||
| 136 | } | 189 | } | ||
| 137 | } | 190 | } | ||
| 191 | + | ||||
| 192 | + | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | |||
| 193 | + | func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | |||
| 194 | + | return p.WebProcessMemoryLimitMB > 0 || | |||
| 195 | + | p.WebProcessMemoryPollIntervalSec > 0 || | |||
| 196 | + | p.WebProcessMemoryConservativeThreshold > 0 || | |||
| 197 | + | p.WebProcessMemoryStrictThreshold > 0 || | |||
| 198 | + | p.WebProcessMemoryKillThreshold >= 0 | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // hasNetworkProcessMemoryConfig returns true if any network process memory setting is configured. | |||
| 202 | + | func hasNetworkProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | |||
| 203 | + | return p.NetworkProcessMemoryLimitMB > 0 || | |||
| 204 | + | p.NetworkProcessMemoryPollIntervalSec > 0 || | |||
| 205 | + | p.NetworkProcessMemoryConservativeThreshold > 0 || | |||
| 206 | + | p.NetworkProcessMemoryStrictThreshold > 0 || | |||
| 207 | + | p.NetworkProcessMemoryKillThreshold >= 0 | |||
| 208 | + | } | |||
M internal/infrastructure/config/defaults.go +25 -0
| @@ -65,6 +65,13 @@ const ( | |||
| 65 | 65 | // Performance defaults | |
| 66 | 66 | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 67 | 67 | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 68 | + | ||
| 69 | + | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| 70 | + | defaultSkiaCPUPaintingThreads = 0 | |
| 71 | + | defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting | |
| 72 | + | ||
| 73 | + | // Memory pressure defaults (-1 = unset for kill threshold, 0 = unset for others) | |
| 74 | + | defaultMemoryPressureKillThreshold = -1.0 | |
| 68 | 75 | ) | |
| 69 | 76 | ||
| 70 | 77 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | |
| @@ -282,8 +289,26 @@ func DefaultConfig() *Config { | |||
| 282 | 289 | Prefix: "", | |
| 283 | 290 | }, | |
| 284 | 291 | Performance: PerformanceConfig{ | |
| 292 | + | Profile: ProfileDefault, // Use WebKit defaults, no tuning | |
| 285 | 293 | ZoomCacheSize: defaultZoomCacheSize, | |
| 286 | 294 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | |
| 295 | + | // Skia threading - balanced defaults (unset = use WebKit defaults) | |
| 296 | + | // These only apply when Profile is "custom" | |
| 297 | + | SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads, | |
| 298 | + | SkiaGPUPaintingThreads: defaultSkiaGPUPaintingThreads, | |
| 299 | + | SkiaEnableCPURendering: false, | |
| 300 | + | // Web process memory pressure - all unset by default | |
| 301 | + | WebProcessMemoryLimitMB: 0, | |
| 302 | + | WebProcessMemoryPollIntervalSec: 0, | |
| 303 | + | WebProcessMemoryConservativeThreshold: 0, | |
| 304 | + | WebProcessMemoryStrictThreshold: 0, | |
| 305 | + | WebProcessMemoryKillThreshold: defaultMemoryPressureKillThreshold, | |
| 306 | + | // Network process memory pressure - all unset by default | |
| 307 | + | NetworkProcessMemoryLimitMB: 0, | |
| 308 | + | NetworkProcessMemoryPollIntervalSec: 0, | |
| 309 | + | NetworkProcessMemoryConservativeThreshold: 0, | |
| 310 | + | NetworkProcessMemoryStrictThreshold: 0, | |
| 311 | + | NetworkProcessMemoryKillThreshold: defaultMemoryPressureKillThreshold, | |
| 287 | 312 | }, | |
| 288 | 313 | Update: UpdateConfig{ | |
| 289 | 314 | EnableOnStartup: true, // Check for updates on startup by default | |
| @@ -65,6 +65,13 @@ const ( | |||||
| 65 | // Performance defaults | 65 | // Performance defaults | ||
| 66 | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | 66 | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | ||
| 67 | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | 67 | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | ||
| 68 | + | ||||
| 69 | + | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |||
| 70 | + | defaultSkiaCPUPaintingThreads = 0 | |||
| 71 | + | defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting | |||
| 72 | + | ||||
| 73 | + | // Memory pressure defaults (-1 = unset for kill threshold, 0 = unset for others) | |||
| 74 | + | defaultMemoryPressureKillThreshold = -1.0 | |||
| 68 | ) | 75 | ) | ||
| 69 | 76 | ||||
| 70 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | 77 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | ||
| @@ -282,8 +289,26 @@ func DefaultConfig() *Config { | |||||
| 282 | Prefix: "", | 289 | Prefix: "", | ||
| 283 | }, | 290 | }, | ||
| 284 | Performance: PerformanceConfig{ | 291 | Performance: PerformanceConfig{ | ||
| 292 | + | Profile: ProfileDefault, // Use WebKit defaults, no tuning | |||
| 285 | ZoomCacheSize: defaultZoomCacheSize, | 293 | ZoomCacheSize: defaultZoomCacheSize, | ||
| 286 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | 294 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | ||
| 295 | + | // Skia threading - balanced defaults (unset = use WebKit defaults) | |||
| 296 | + | // These only apply when Profile is "custom" | |||
| 297 | + | SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads, | |||
| 298 | + | SkiaGPUPaintingThreads: defaultSkiaGPUPaintingThreads, | |||
| 299 | + | SkiaEnableCPURendering: false, | |||
| 300 | + | // Web process memory pressure - all unset by default | |||
| 301 | + | WebProcessMemoryLimitMB: 0, | |||
| 302 | + | WebProcessMemoryPollIntervalSec: 0, | |||
| 303 | + | WebProcessMemoryConservativeThreshold: 0, | |||
| 304 | + | WebProcessMemoryStrictThreshold: 0, | |||
| 305 | + | WebProcessMemoryKillThreshold: defaultMemoryPressureKillThreshold, | |||
| 306 | + | // Network process memory pressure - all unset by default | |||
| 307 | + | NetworkProcessMemoryLimitMB: 0, | |||
| 308 | + | NetworkProcessMemoryPollIntervalSec: 0, | |||
| 309 | + | NetworkProcessMemoryConservativeThreshold: 0, | |||
| 310 | + | NetworkProcessMemoryStrictThreshold: 0, | |||
| 311 | + | NetworkProcessMemoryKillThreshold: defaultMemoryPressureKillThreshold, | |||
| 287 | }, | 312 | }, | ||
| 288 | Update: UpdateConfig{ | 313 | Update: UpdateConfig{ | ||
| 289 | EnableOnStartup: true, // Check for updates on startup by default | 314 | EnableOnStartup: true, // Check for updates on startup by default | ||
M internal/infrastructure/config/loader.go +41 -0
| @@ -212,6 +212,20 @@ func normalizeConfig(config *Config) { | |||
| 212 | 212 | } | |
| 213 | 213 | ||
| 214 | 214 | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | |
| 215 | + | normalizePerformanceProfile(config) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func normalizePerformanceProfile(config *Config) { | |
| 219 | + | switch strings.ToLower(string(config.Performance.Profile)) { | |
| 220 | + | case "", string(ProfileDefault): | |
| 221 | + | config.Performance.Profile = ProfileDefault | |
| 222 | + | case string(ProfileLite): | |
| 223 | + | config.Performance.Profile = ProfileLite | |
| 224 | + | case string(ProfileMax): | |
| 225 | + | config.Performance.Profile = ProfileMax | |
| 226 | + | case string(ProfileCustom): | |
| 227 | + | config.Performance.Profile = ProfileCustom | |
| 228 | + | } | |
| 215 | 229 | } | |
| 216 | 230 | ||
| 217 | 231 | // Get returns the current configuration (thread-safe). | |
| @@ -252,6 +266,9 @@ func (m *Manager) Save(cfg *Config) error { | |||
| 252 | 266 | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | |
| 253 | 267 | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | |
| 254 | 268 | ||
| 269 | + | // Performance profile (requires browser restart to take effect) | |
| 270 | + | m.viper.Set("performance.profile", string(cfg.Performance.Profile)) | |
| 271 | + | ||
| 255 | 272 | if err := m.viper.WriteConfig(); err != nil { | |
| 256 | 273 | return fmt.Errorf("failed to write config: %w", err) | |
| 257 | 274 | } | |
| @@ -316,6 +333,7 @@ func (m *Manager) setDefaults() { | |||
| 316 | 333 | m.setRuntimeDefaults(defaults) | |
| 317 | 334 | m.setSessionDefaults(defaults) | |
| 318 | 335 | m.setUpdateDefaults(defaults) | |
| 336 | + | m.setPerformanceDefaults(defaults) | |
| 319 | 337 | } | |
| 320 | 338 | ||
| 321 | 339 | func (m *Manager) setHistoryDefaults(defaults *Config) { | |
| @@ -461,6 +479,29 @@ func (m *Manager) setUpdateDefaults(defaults *Config) { | |||
| 461 | 479 | m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings) | |
| 462 | 480 | } | |
| 463 | 481 | ||
| 482 | + | func (m *Manager) setPerformanceDefaults(defaults *Config) { | |
| 483 | + | m.viper.SetDefault("performance.profile", string(defaults.Performance.Profile)) | |
| 484 | + | m.viper.SetDefault("performance.zoom_cache_size", defaults.Performance.ZoomCacheSize) | |
| 485 | + | m.viper.SetDefault("performance.webview_pool_prewarm_count", defaults.Performance.WebViewPoolPrewarmCount) | |
| 486 | + | // Skia threading (only used when profile = "custom") | |
| 487 | + | m.viper.SetDefault("performance.skia_cpu_painting_threads", defaults.Performance.SkiaCPUPaintingThreads) | |
| 488 | + | m.viper.SetDefault("performance.skia_gpu_painting_threads", defaults.Performance.SkiaGPUPaintingThreads) | |
| 489 | + | m.viper.SetDefault("performance.skia_enable_cpu_rendering", defaults.Performance.SkiaEnableCPURendering) | |
| 490 | + | // Web process memory pressure | |
| 491 | + | m.viper.SetDefault("performance.web_process_memory_limit_mb", defaults.Performance.WebProcessMemoryLimitMB) | |
| 492 | + | m.viper.SetDefault("performance.web_process_memory_poll_interval_sec", defaults.Performance.WebProcessMemoryPollIntervalSec) | |
| 493 | + | m.viper.SetDefault("performance.web_process_memory_conservative_threshold", defaults.Performance.WebProcessMemoryConservativeThreshold) | |
| 494 | + | m.viper.SetDefault("performance.web_process_memory_strict_threshold", defaults.Performance.WebProcessMemoryStrictThreshold) | |
| 495 | + | m.viper.SetDefault("performance.web_process_memory_kill_threshold", defaults.Performance.WebProcessMemoryKillThreshold) | |
| 496 | + | // Network process memory pressure | |
| 497 | + | m.viper.SetDefault("performance.network_process_memory_limit_mb", defaults.Performance.NetworkProcessMemoryLimitMB) | |
| 498 | + | m.viper.SetDefault("performance.network_process_memory_poll_interval_sec", defaults.Performance.NetworkProcessMemoryPollIntervalSec) | |
| 499 | + | netConservativeThreshold := defaults.Performance.NetworkProcessMemoryConservativeThreshold | |
| 500 | + | m.viper.SetDefault("performance.network_process_memory_conservative_threshold", netConservativeThreshold) | |
| 501 | + | m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold) | |
| 502 | + | m.viper.SetDefault("performance.network_process_memory_kill_threshold", defaults.Performance.NetworkProcessMemoryKillThreshold) | |
| 503 | + | } | |
| 504 | + | ||
| 464 | 505 | // New returns a new default configuration instance. | |
| 465 | 506 | // This is a convenience function for getting default config without the full manager. | |
| 466 | 507 | func New() *Config { | |
| @@ -212,6 +212,20 @@ func normalizeConfig(config *Config) { | |||||
| 212 | } | 212 | } | ||
| 213 | 213 | ||||
| 214 | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | 214 | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | ||
| 215 | + | normalizePerformanceProfile(config) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func normalizePerformanceProfile(config *Config) { | |||
| 219 | + | switch strings.ToLower(string(config.Performance.Profile)) { | |||
| 220 | + | case "", string(ProfileDefault): | |||
| 221 | + | config.Performance.Profile = ProfileDefault | |||
| 222 | + | case string(ProfileLite): | |||
| 223 | + | config.Performance.Profile = ProfileLite | |||
| 224 | + | case string(ProfileMax): | |||
| 225 | + | config.Performance.Profile = ProfileMax | |||
| 226 | + | case string(ProfileCustom): | |||
| 227 | + | config.Performance.Profile = ProfileCustom | |||
| 228 | + | } | |||
| 215 | } | 229 | } | ||
| 216 | 230 | ||||
| 217 | // Get returns the current configuration (thread-safe). | 231 | // Get returns the current configuration (thread-safe). | ||
| @@ -252,6 +266,9 @@ func (m *Manager) Save(cfg *Config) error { | |||||
| 252 | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | 266 | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | ||
| 253 | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | 267 | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | ||
| 254 | 268 | ||||
| 269 | + | // Performance profile (requires browser restart to take effect) | |||
| 270 | + | m.viper.Set("performance.profile", string(cfg.Performance.Profile)) | |||
| 271 | + | ||||
| 255 | if err := m.viper.WriteConfig(); err != nil { | 272 | if err := m.viper.WriteConfig(); err != nil { | ||
| 256 | return fmt.Errorf("failed to write config: %w", err) | 273 | return fmt.Errorf("failed to write config: %w", err) | ||
| 257 | } | 274 | } | ||
| @@ -316,6 +333,7 @@ func (m *Manager) setDefaults() { | |||||
| 316 | m.setRuntimeDefaults(defaults) | 333 | m.setRuntimeDefaults(defaults) | ||
| 317 | m.setSessionDefaults(defaults) | 334 | m.setSessionDefaults(defaults) | ||
| 318 | m.setUpdateDefaults(defaults) | 335 | m.setUpdateDefaults(defaults) | ||
| 336 | + | m.setPerformanceDefaults(defaults) | |||
| 319 | } | 337 | } | ||
| 320 | 338 | ||||
| 321 | func (m *Manager) setHistoryDefaults(defaults *Config) { | 339 | func (m *Manager) setHistoryDefaults(defaults *Config) { | ||
| @@ -461,6 +479,29 @@ func (m *Manager) setUpdateDefaults(defaults *Config) { | |||||
| 461 | m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings) | 479 | m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings) | ||
| 462 | } | 480 | } | ||
| 463 | 481 | ||||
| 482 | + | func (m *Manager) setPerformanceDefaults(defaults *Config) { | |||
| 483 | + | m.viper.SetDefault("performance.profile", string(defaults.Performance.Profile)) | |||
| 484 | + | m.viper.SetDefault("performance.zoom_cache_size", defaults.Performance.ZoomCacheSize) | |||
| 485 | + | m.viper.SetDefault("performance.webview_pool_prewarm_count", defaults.Performance.WebViewPoolPrewarmCount) | |||
| 486 | + | // Skia threading (only used when profile = "custom") | |||
| 487 | + | m.viper.SetDefault("performance.skia_cpu_painting_threads", defaults.Performance.SkiaCPUPaintingThreads) | |||
| 488 | + | m.viper.SetDefault("performance.skia_gpu_painting_threads", defaults.Performance.SkiaGPUPaintingThreads) | |||
| 489 | + | m.viper.SetDefault("performance.skia_enable_cpu_rendering", defaults.Performance.SkiaEnableCPURendering) | |||
| 490 | + | // Web process memory pressure | |||
| 491 | + | m.viper.SetDefault("performance.web_process_memory_limit_mb", defaults.Performance.WebProcessMemoryLimitMB) | |||
| 492 | + | m.viper.SetDefault("performance.web_process_memory_poll_interval_sec", defaults.Performance.WebProcessMemoryPollIntervalSec) | |||
| 493 | + | m.viper.SetDefault("performance.web_process_memory_conservative_threshold", defaults.Performance.WebProcessMemoryConservativeThreshold) | |||
| 494 | + | m.viper.SetDefault("performance.web_process_memory_strict_threshold", defaults.Performance.WebProcessMemoryStrictThreshold) | |||
| 495 | + | m.viper.SetDefault("performance.web_process_memory_kill_threshold", defaults.Performance.WebProcessMemoryKillThreshold) | |||
| 496 | + | // Network process memory pressure | |||
| 497 | + | m.viper.SetDefault("performance.network_process_memory_limit_mb", defaults.Performance.NetworkProcessMemoryLimitMB) | |||
| 498 | + | m.viper.SetDefault("performance.network_process_memory_poll_interval_sec", defaults.Performance.NetworkProcessMemoryPollIntervalSec) | |||
| 499 | + | netConservativeThreshold := defaults.Performance.NetworkProcessMemoryConservativeThreshold | |||
| 500 | + | m.viper.SetDefault("performance.network_process_memory_conservative_threshold", netConservativeThreshold) | |||
| 501 | + | m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold) | |||
| 502 | + | m.viper.SetDefault("performance.network_process_memory_kill_threshold", defaults.Performance.NetworkProcessMemoryKillThreshold) | |||
| 503 | + | } | |||
| 504 | + | ||||
| 464 | // New returns a new default configuration instance. | 505 | // New returns a new default configuration instance. | ||
| 465 | // This is a convenience function for getting default config without the full manager. | 506 | // This is a convenience function for getting default config without the full manager. | ||
| 466 | func New() *Config { | 507 | func New() *Config { | ||
A internal/infrastructure/config/profile.go +321 -0
| @@ -0,0 +1,321 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "runtime" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | // maxSkiaCPUThreads is the maximum number of Skia CPU painting threads | |
| 11 | + | // supported by WebKitGTK (0-8 range). | |
| 12 | + | maxSkiaCPUThreads = 8 | |
| 13 | + | ||
| 14 | + | // maxSkiaGPUThreads is the maximum number of Skia GPU painting threads | |
| 15 | + | // supported by WebKitGTK (-1 to 8 range, where -1 means "let WebKit decide"). | |
| 16 | + | maxSkiaGPUThreads = 8 | |
| 17 | + | ||
| 18 | + | // Memory tier thresholds (in bytes) | |
| 19 | + | // Explicitly typed as uint64 to prevent overflow on 32-bit systems. | |
| 20 | + | ramTierLow uint64 = 8 * 1024 * 1024 * 1024 // 8 GB | |
| 21 | + | ramTierMedium uint64 = 16 * 1024 * 1024 * 1024 // 16 GB | |
| 22 | + | ramTierHigh uint64 = 32 * 1024 * 1024 * 1024 // 32 GB | |
| 23 | + | ||
| 24 | + | // VRAM tier thresholds (in bytes) | |
| 25 | + | vramTierLow uint64 = 4 * 1024 * 1024 * 1024 // 4 GB (low-end discrete / integrated) | |
| 26 | + | vramTierMid uint64 = 8 * 1024 * 1024 * 1024 // 8 GB (mid-range discrete) | |
| 27 | + | vramTierHigh uint64 = 16 * 1024 * 1024 * 1024 // 16 GB (high-end discrete) | |
| 28 | + | ||
| 29 | + | // GPU thread scaling values | |
| 30 | + | gpuThreadsHigh = 8 // For 16GB+ VRAM | |
| 31 | + | gpuThreadsMid = 6 // For 8-16GB VRAM | |
| 32 | + | gpuThreadsLow = 4 // For 4-8GB VRAM | |
| 33 | + | gpuThreadsMin = 2 // For <4GB VRAM or unknown | |
| 34 | + | ||
| 35 | + | // Memory limit values (in MB) | |
| 36 | + | webMemHigh = 4096 // For 32GB+ RAM | |
| 37 | + | webMemMedium = 3072 // For 16-32GB RAM | |
| 38 | + | webMemLow = 2048 // For 8-16GB RAM | |
| 39 | + | webMemMin = 1024 // For <8GB RAM | |
| 40 | + | netMemHigh = 1024 // For 32GB+ RAM | |
| 41 | + | netMemMedium = 768 // For 16-32GB RAM | |
| 42 | + | netMemLow = 512 // For 8-16GB RAM | |
| 43 | + | netMemMin = 256 // For <8GB RAM | |
| 44 | + | webMemFallback = 2048 // When RAM unknown | |
| 45 | + | netMemFallback = 512 // When RAM unknown | |
| 46 | + | ||
| 47 | + | // WebView pool prewarm counts | |
| 48 | + | poolPrewarmHigh = 12 // For 32GB+ RAM | |
| 49 | + | poolPrewarmMedium = 8 // For 16-32GB RAM | |
| 50 | + | poolPrewarmLow = 6 // For 8-16GB RAM | |
| 51 | + | poolPrewarmMin = 4 // For <8GB RAM | |
| 52 | + | poolPrewarmFallback = 8 // When RAM unknown | |
| 53 | + | ) | |
| 54 | + | ||
| 55 | + | // ResolvedPerformanceSettings contains the computed performance settings | |
| 56 | + | // after profile resolution. These are the actual values to apply. | |
| 57 | + | type ResolvedPerformanceSettings struct { | |
| 58 | + | // Skia threading | |
| 59 | + | SkiaCPUPaintingThreads int | |
| 60 | + | SkiaGPUPaintingThreads int | |
| 61 | + | SkiaEnableCPURendering bool | |
| 62 | + | ||
| 63 | + | // Web process memory pressure | |
| 64 | + | WebProcessMemoryLimitMB int | |
| 65 | + | WebProcessMemoryPollIntervalSec float64 | |
| 66 | + | WebProcessMemoryConservativeThreshold float64 | |
| 67 | + | WebProcessMemoryStrictThreshold float64 | |
| 68 | + | WebProcessMemoryKillThreshold float64 | |
| 69 | + | ||
| 70 | + | // Network process memory pressure | |
| 71 | + | NetworkProcessMemoryLimitMB int | |
| 72 | + | NetworkProcessMemoryPollIntervalSec float64 | |
| 73 | + | NetworkProcessMemoryConservativeThreshold float64 | |
| 74 | + | NetworkProcessMemoryStrictThreshold float64 | |
| 75 | + | NetworkProcessMemoryKillThreshold float64 | |
| 76 | + | ||
| 77 | + | // WebView pool | |
| 78 | + | WebViewPoolPrewarmCount int | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // ResolvePerformanceProfile computes the actual performance settings based on | |
| 82 | + | // the selected profile. For ProfileCustom, it returns the user's configured values. | |
| 83 | + | // For other profiles, it computes appropriate values based on detected hardware. | |
| 84 | + | // | |
| 85 | + | // The hw parameter is optional - if nil, fallback values are used. | |
| 86 | + | func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) ResolvedPerformanceSettings { | |
| 87 | + | switch cfg.Profile { | |
| 88 | + | case ProfileLite: | |
| 89 | + | return resolveLiteProfile(hw) | |
| 90 | + | case ProfileMax: | |
| 91 | + | return resolveMaxProfile(hw) | |
| 92 | + | case ProfileCustom: | |
| 93 | + | return resolveCustomProfile(cfg) | |
| 94 | + | default: // ProfileDefault or empty | |
| 95 | + | return resolveDefaultProfile() | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // resolveDefaultProfile returns WebKit defaults (no tuning). | |
| 100 | + | func resolveDefaultProfile() ResolvedPerformanceSettings { | |
| 101 | + | return ResolvedPerformanceSettings{ | |
| 102 | + | // All zeros/negatives mean "unset, use WebKit defaults" | |
| 103 | + | SkiaCPUPaintingThreads: 0, | |
| 104 | + | SkiaGPUPaintingThreads: -1, | |
| 105 | + | SkiaEnableCPURendering: false, | |
| 106 | + | ||
| 107 | + | WebProcessMemoryLimitMB: 0, | |
| 108 | + | WebProcessMemoryPollIntervalSec: 0, | |
| 109 | + | WebProcessMemoryConservativeThreshold: 0, | |
| 110 | + | WebProcessMemoryStrictThreshold: 0, | |
| 111 | + | WebProcessMemoryKillThreshold: -1, | |
| 112 | + | ||
| 113 | + | NetworkProcessMemoryLimitMB: 0, | |
| 114 | + | NetworkProcessMemoryPollIntervalSec: 0, | |
| 115 | + | NetworkProcessMemoryConservativeThreshold: 0, | |
| 116 | + | NetworkProcessMemoryStrictThreshold: 0, | |
| 117 | + | NetworkProcessMemoryKillThreshold: -1, | |
| 118 | + | ||
| 119 | + | WebViewPoolPrewarmCount: 4, | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // resolveLiteProfile returns settings optimized for low-RAM systems. | |
| 124 | + | // Uses hardware info to scale CPU threads if available. | |
| 125 | + | func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | |
| 126 | + | // Use 2 CPU threads, or scale down on very low-core systems | |
| 127 | + | cpuThreads := 2 | |
| 128 | + | if hw != nil && hw.CPUCores > 0 && hw.CPUCores < 4 { | |
| 129 | + | cpuThreads = 1 | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | return ResolvedPerformanceSettings{ | |
| 133 | + | SkiaCPUPaintingThreads: cpuThreads, | |
| 134 | + | SkiaGPUPaintingThreads: -1, // unset | |
| 135 | + | SkiaEnableCPURendering: false, | |
| 136 | + | ||
| 137 | + | WebProcessMemoryLimitMB: 512, | |
| 138 | + | WebProcessMemoryPollIntervalSec: 0, // use WebKit default (30s) | |
| 139 | + | WebProcessMemoryConservativeThreshold: 0.25, | |
| 140 | + | WebProcessMemoryStrictThreshold: 0.4, | |
| 141 | + | WebProcessMemoryKillThreshold: 0.8, | |
| 142 | + | ||
| 143 | + | NetworkProcessMemoryLimitMB: 256, | |
| 144 | + | NetworkProcessMemoryPollIntervalSec: 0, | |
| 145 | + | NetworkProcessMemoryConservativeThreshold: 0.25, | |
| 146 | + | NetworkProcessMemoryStrictThreshold: 0.4, | |
| 147 | + | NetworkProcessMemoryKillThreshold: 0.8, | |
| 148 | + | ||
| 149 | + | WebViewPoolPrewarmCount: 2, | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // resolveMaxProfile returns settings optimized for maximum responsiveness. | |
| 154 | + | // Scales values based on detected hardware capabilities. | |
| 155 | + | func resolveMaxProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | |
| 156 | + | // CPU threads: use physical cores (not threads), capped at WebKitGTK limit | |
| 157 | + | cpuThreads := computeMaxCPUThreads(hw) | |
| 158 | + | ||
| 159 | + | // GPU threads: scale based on VRAM | |
| 160 | + | gpuThreads := computeMaxGPUThreads(hw) | |
| 161 | + | ||
| 162 | + | // Memory limits: scale based on system RAM | |
| 163 | + | webMemMB, netMemMB := computeMaxMemoryLimits(hw) | |
| 164 | + | ||
| 165 | + | // WebView pool: scale based on RAM | |
| 166 | + | poolPrewarm := computeMaxPoolPrewarm(hw) | |
| 167 | + | ||
| 168 | + | return ResolvedPerformanceSettings{ | |
| 169 | + | SkiaCPUPaintingThreads: cpuThreads, | |
| 170 | + | SkiaGPUPaintingThreads: gpuThreads, | |
| 171 | + | SkiaEnableCPURendering: false, | |
| 172 | + | ||
| 173 | + | WebProcessMemoryLimitMB: webMemMB, | |
| 174 | + | WebProcessMemoryPollIntervalSec: 0, // use WebKit default | |
| 175 | + | WebProcessMemoryConservativeThreshold: 0.5, | |
| 176 | + | WebProcessMemoryStrictThreshold: 0.7, | |
| 177 | + | WebProcessMemoryKillThreshold: -1, // never kill | |
| 178 | + | ||
| 179 | + | NetworkProcessMemoryLimitMB: netMemMB, | |
| 180 | + | NetworkProcessMemoryPollIntervalSec: 0, | |
| 181 | + | NetworkProcessMemoryConservativeThreshold: 0.5, | |
| 182 | + | NetworkProcessMemoryStrictThreshold: 0.7, | |
| 183 | + | NetworkProcessMemoryKillThreshold: -1, | |
| 184 | + | ||
| 185 | + | WebViewPoolPrewarmCount: poolPrewarm, | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // computeMaxCPUThreads calculates Skia CPU threads for max profile. | |
| 190 | + | // Uses physical cores (not hyperthreaded) capped at WebKitGTK's limit. | |
| 191 | + | func computeMaxCPUThreads(hw *port.HardwareInfo) int { | |
| 192 | + | var cpuThreads int | |
| 193 | + | ||
| 194 | + | if hw != nil && hw.CPUCores > 0 { | |
| 195 | + | // Use physical cores directly - they're better for parallel painting | |
| 196 | + | cpuThreads = hw.CPUCores | |
| 197 | + | } else { | |
| 198 | + | // Fallback: assume hyperthreading, use half of logical CPUs | |
| 199 | + | cpuThreads = runtime.NumCPU() / 2 | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // Minimum 4 for meaningful parallelism | |
| 203 | + | if cpuThreads < 4 { | |
| 204 | + | cpuThreads = 4 | |
| 205 | + | } | |
| 206 | + | // Cap at WebKitGTK Skia limit | |
| 207 | + | if cpuThreads > maxSkiaCPUThreads { | |
| 208 | + | cpuThreads = maxSkiaCPUThreads | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | return cpuThreads | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // computeMaxGPUThreads calculates Skia GPU threads based on VRAM. | |
| 215 | + | func computeMaxGPUThreads(hw *port.HardwareInfo) int { | |
| 216 | + | if hw == nil || hw.VRAM == 0 { | |
| 217 | + | // No VRAM info - use conservative default | |
| 218 | + | return gpuThreadsMin | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Scale GPU threads based on VRAM tiers | |
| 222 | + | switch { | |
| 223 | + | case hw.VRAM >= vramTierHigh: // 16GB+ VRAM (high-end: RX 7900, RTX 4080+) | |
| 224 | + | return gpuThreadsHigh | |
| 225 | + | case hw.VRAM >= vramTierMid: // 8-16GB VRAM (mid-range: RX 7800, RTX 4070) | |
| 226 | + | return gpuThreadsMid | |
| 227 | + | case hw.VRAM >= vramTierLow: // 4-8GB VRAM (entry discrete) | |
| 228 | + | return gpuThreadsLow | |
| 229 | + | default: // <4GB VRAM (integrated / old discrete) | |
| 230 | + | return gpuThreadsMin | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // computeMaxMemoryLimits returns web process and network process memory limits. | |
| 235 | + | func computeMaxMemoryLimits(hw *port.HardwareInfo) (webMB, netMB int) { | |
| 236 | + | if hw == nil || hw.TotalRAM == 0 { | |
| 237 | + | // Fallback: assume 16GB system | |
| 238 | + | return webMemFallback, netMemFallback | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // Scale memory limits based on system RAM | |
| 242 | + | switch { | |
| 243 | + | case hw.TotalRAM >= ramTierHigh: // 32GB+ RAM | |
| 244 | + | return webMemHigh, netMemHigh | |
| 245 | + | case hw.TotalRAM >= ramTierMedium: // 16-32GB RAM | |
| 246 | + | return webMemMedium, netMemMedium | |
| 247 | + | case hw.TotalRAM >= ramTierLow: // 8-16GB RAM | |
| 248 | + | return webMemLow, netMemLow | |
| 249 | + | default: // <8GB RAM | |
| 250 | + | return webMemMin, netMemMin | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // computeMaxPoolPrewarm returns WebView pool prewarm count based on RAM. | |
| 255 | + | func computeMaxPoolPrewarm(hw *port.HardwareInfo) int { | |
| 256 | + | if hw == nil || hw.TotalRAM == 0 { | |
| 257 | + | return poolPrewarmFallback | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | switch { | |
| 261 | + | case hw.TotalRAM >= ramTierHigh: // 32GB+ | |
| 262 | + | return poolPrewarmHigh | |
| 263 | + | case hw.TotalRAM >= ramTierMedium: // 16-32GB | |
| 264 | + | return poolPrewarmMedium | |
| 265 | + | case hw.TotalRAM >= ramTierLow: // 8-16GB | |
| 266 | + | return poolPrewarmLow | |
| 267 | + | default: // <8GB | |
| 268 | + | return poolPrewarmMin | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // resolveCustomProfile returns the user's configured values directly. | |
| 273 | + | func resolveCustomProfile(cfg *PerformanceConfig) ResolvedPerformanceSettings { | |
| 274 | + | return ResolvedPerformanceSettings{ | |
| 275 | + | SkiaCPUPaintingThreads: cfg.SkiaCPUPaintingThreads, | |
| 276 | + | SkiaGPUPaintingThreads: cfg.SkiaGPUPaintingThreads, | |
| 277 | + | SkiaEnableCPURendering: cfg.SkiaEnableCPURendering, | |
| 278 | + | ||
| 279 | + | WebProcessMemoryLimitMB: cfg.WebProcessMemoryLimitMB, | |
| 280 | + | WebProcessMemoryPollIntervalSec: cfg.WebProcessMemoryPollIntervalSec, | |
| 281 | + | WebProcessMemoryConservativeThreshold: cfg.WebProcessMemoryConservativeThreshold, | |
| 282 | + | WebProcessMemoryStrictThreshold: cfg.WebProcessMemoryStrictThreshold, | |
| 283 | + | WebProcessMemoryKillThreshold: cfg.WebProcessMemoryKillThreshold, | |
| 284 | + | ||
| 285 | + | NetworkProcessMemoryLimitMB: cfg.NetworkProcessMemoryLimitMB, | |
| 286 | + | NetworkProcessMemoryPollIntervalSec: cfg.NetworkProcessMemoryPollIntervalSec, | |
| 287 | + | NetworkProcessMemoryConservativeThreshold: cfg.NetworkProcessMemoryConservativeThreshold, | |
| 288 | + | NetworkProcessMemoryStrictThreshold: cfg.NetworkProcessMemoryStrictThreshold, | |
| 289 | + | NetworkProcessMemoryKillThreshold: cfg.NetworkProcessMemoryKillThreshold, | |
| 290 | + | ||
| 291 | + | WebViewPoolPrewarmCount: cfg.WebViewPoolPrewarmCount, | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // HasCustomPerformanceFields returns true if any individual performance tuning | |
| 296 | + | // fields are set to non-default values. Used for validation. | |
| 297 | + | func HasCustomPerformanceFields(cfg *PerformanceConfig) bool { | |
| 298 | + | return cfg.SkiaCPUPaintingThreads != 0 || | |
| 299 | + | cfg.SkiaGPUPaintingThreads != -1 || | |
| 300 | + | cfg.SkiaEnableCPURendering || | |
| 301 | + | cfg.WebProcessMemoryLimitMB != 0 || | |
| 302 | + | cfg.WebProcessMemoryPollIntervalSec != 0 || | |
| 303 | + | cfg.WebProcessMemoryConservativeThreshold != 0 || | |
| 304 | + | cfg.WebProcessMemoryStrictThreshold != 0 || | |
| 305 | + | cfg.WebProcessMemoryKillThreshold != -1 || | |
| 306 | + | cfg.NetworkProcessMemoryLimitMB != 0 || | |
| 307 | + | cfg.NetworkProcessMemoryPollIntervalSec != 0 || | |
| 308 | + | cfg.NetworkProcessMemoryConservativeThreshold != 0 || | |
| 309 | + | cfg.NetworkProcessMemoryStrictThreshold != 0 || | |
| 310 | + | cfg.NetworkProcessMemoryKillThreshold != -1 | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // IsValidPerformanceProfile returns true if the profile name is recognized. | |
| 314 | + | func IsValidPerformanceProfile(profile PerformanceProfile) bool { | |
| 315 | + | switch profile { | |
| 316 | + | case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "": | |
| 317 | + | return true | |
| 318 | + | default: | |
| 319 | + | return false | |
| 320 | + | } | |
| 321 | + | } | |
| @@ -0,0 +1,321 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "runtime" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | // maxSkiaCPUThreads is the maximum number of Skia CPU painting threads | |||
| 11 | + | // supported by WebKitGTK (0-8 range). | |||
| 12 | + | maxSkiaCPUThreads = 8 | |||
| 13 | + | ||||
| 14 | + | // maxSkiaGPUThreads is the maximum number of Skia GPU painting threads | |||
| 15 | + | // supported by WebKitGTK (-1 to 8 range, where -1 means "let WebKit decide"). | |||
| 16 | + | maxSkiaGPUThreads = 8 | |||
| 17 | + | ||||
| 18 | + | // Memory tier thresholds (in bytes) | |||
| 19 | + | // Explicitly typed as uint64 to prevent overflow on 32-bit systems. | |||
| 20 | + | ramTierLow uint64 = 8 * 1024 * 1024 * 1024 // 8 GB | |||
| 21 | + | ramTierMedium uint64 = 16 * 1024 * 1024 * 1024 // 16 GB | |||
| 22 | + | ramTierHigh uint64 = 32 * 1024 * 1024 * 1024 // 32 GB | |||
| 23 | + | ||||
| 24 | + | // VRAM tier thresholds (in bytes) | |||
| 25 | + | vramTierLow uint64 = 4 * 1024 * 1024 * 1024 // 4 GB (low-end discrete / integrated) | |||
| 26 | + | vramTierMid uint64 = 8 * 1024 * 1024 * 1024 // 8 GB (mid-range discrete) | |||
| 27 | + | vramTierHigh uint64 = 16 * 1024 * 1024 * 1024 // 16 GB (high-end discrete) | |||
| 28 | + | ||||
| 29 | + | // GPU thread scaling values | |||
| 30 | + | gpuThreadsHigh = 8 // For 16GB+ VRAM | |||
| 31 | + | gpuThreadsMid = 6 // For 8-16GB VRAM | |||
| 32 | + | gpuThreadsLow = 4 // For 4-8GB VRAM | |||
| 33 | + | gpuThreadsMin = 2 // For <4GB VRAM or unknown | |||
| 34 | + | ||||
| 35 | + | // Memory limit values (in MB) | |||
| 36 | + | webMemHigh = 4096 // For 32GB+ RAM | |||
| 37 | + | webMemMedium = 3072 // For 16-32GB RAM | |||
| 38 | + | webMemLow = 2048 // For 8-16GB RAM | |||
| 39 | + | webMemMin = 1024 // For <8GB RAM | |||
| 40 | + | netMemHigh = 1024 // For 32GB+ RAM | |||
| 41 | + | netMemMedium = 768 // For 16-32GB RAM | |||
| 42 | + | netMemLow = 512 // For 8-16GB RAM | |||
| 43 | + | netMemMin = 256 // For <8GB RAM | |||
| 44 | + | webMemFallback = 2048 // When RAM unknown | |||
| 45 | + | netMemFallback = 512 // When RAM unknown | |||
| 46 | + | ||||
| 47 | + | // WebView pool prewarm counts | |||
| 48 | + | poolPrewarmHigh = 12 // For 32GB+ RAM | |||
| 49 | + | poolPrewarmMedium = 8 // For 16-32GB RAM | |||
| 50 | + | poolPrewarmLow = 6 // For 8-16GB RAM | |||
| 51 | + | poolPrewarmMin = 4 // For <8GB RAM | |||
| 52 | + | poolPrewarmFallback = 8 // When RAM unknown | |||
| 53 | + | ) | |||
| 54 | + | ||||
| 55 | + | // ResolvedPerformanceSettings contains the computed performance settings | |||
| 56 | + | // after profile resolution. These are the actual values to apply. | |||
| 57 | + | type ResolvedPerformanceSettings struct { | |||
| 58 | + | // Skia threading | |||
| 59 | + | SkiaCPUPaintingThreads int | |||
| 60 | + | SkiaGPUPaintingThreads int | |||
| 61 | + | SkiaEnableCPURendering bool | |||
| 62 | + | ||||
| 63 | + | // Web process memory pressure | |||
| 64 | + | WebProcessMemoryLimitMB int | |||
| 65 | + | WebProcessMemoryPollIntervalSec float64 | |||
| 66 | + | WebProcessMemoryConservativeThreshold float64 | |||
| 67 | + | WebProcessMemoryStrictThreshold float64 | |||
| 68 | + | WebProcessMemoryKillThreshold float64 | |||
| 69 | + | ||||
| 70 | + | // Network process memory pressure | |||
| 71 | + | NetworkProcessMemoryLimitMB int | |||
| 72 | + | NetworkProcessMemoryPollIntervalSec float64 | |||
| 73 | + | NetworkProcessMemoryConservativeThreshold float64 | |||
| 74 | + | NetworkProcessMemoryStrictThreshold float64 | |||
| 75 | + | NetworkProcessMemoryKillThreshold float64 | |||
| 76 | + | ||||
| 77 | + | // WebView pool | |||
| 78 | + | WebViewPoolPrewarmCount int | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // ResolvePerformanceProfile computes the actual performance settings based on | |||
| 82 | + | // the selected profile. For ProfileCustom, it returns the user's configured values. | |||
| 83 | + | // For other profiles, it computes appropriate values based on detected hardware. | |||
| 84 | + | // | |||
| 85 | + | // The hw parameter is optional - if nil, fallback values are used. | |||
| 86 | + | func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) ResolvedPerformanceSettings { | |||
| 87 | + | switch cfg.Profile { | |||
| 88 | + | case ProfileLite: | |||
| 89 | + | return resolveLiteProfile(hw) | |||
| 90 | + | case ProfileMax: | |||
| 91 | + | return resolveMaxProfile(hw) | |||
| 92 | + | case ProfileCustom: | |||
| 93 | + | return resolveCustomProfile(cfg) | |||
| 94 | + | default: // ProfileDefault or empty | |||
| 95 | + | return resolveDefaultProfile() | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // resolveDefaultProfile returns WebKit defaults (no tuning). | |||
| 100 | + | func resolveDefaultProfile() ResolvedPerformanceSettings { | |||
| 101 | + | return ResolvedPerformanceSettings{ | |||
| 102 | + | // All zeros/negatives mean "unset, use WebKit defaults" | |||
| 103 | + | SkiaCPUPaintingThreads: 0, | |||
| 104 | + | SkiaGPUPaintingThreads: -1, | |||
| 105 | + | SkiaEnableCPURendering: false, | |||
| 106 | + | ||||
| 107 | + | WebProcessMemoryLimitMB: 0, | |||
| 108 | + | WebProcessMemoryPollIntervalSec: 0, | |||
| 109 | + | WebProcessMemoryConservativeThreshold: 0, | |||
| 110 | + | WebProcessMemoryStrictThreshold: 0, | |||
| 111 | + | WebProcessMemoryKillThreshold: -1, | |||
| 112 | + | ||||
| 113 | + | NetworkProcessMemoryLimitMB: 0, | |||
| 114 | + | NetworkProcessMemoryPollIntervalSec: 0, | |||
| 115 | + | NetworkProcessMemoryConservativeThreshold: 0, | |||
| 116 | + | NetworkProcessMemoryStrictThreshold: 0, | |||
| 117 | + | NetworkProcessMemoryKillThreshold: -1, | |||
| 118 | + | ||||
| 119 | + | WebViewPoolPrewarmCount: 4, | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // resolveLiteProfile returns settings optimized for low-RAM systems. | |||
| 124 | + | // Uses hardware info to scale CPU threads if available. | |||
| 125 | + | func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | |||
| 126 | + | // Use 2 CPU threads, or scale down on very low-core systems | |||
| 127 | + | cpuThreads := 2 | |||
| 128 | + | if hw != nil && hw.CPUCores > 0 && hw.CPUCores < 4 { | |||
| 129 | + | cpuThreads = 1 | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | return ResolvedPerformanceSettings{ | |||
| 133 | + | SkiaCPUPaintingThreads: cpuThreads, | |||
| 134 | + | SkiaGPUPaintingThreads: -1, // unset | |||
| 135 | + | SkiaEnableCPURendering: false, | |||
| 136 | + | ||||
| 137 | + | WebProcessMemoryLimitMB: 512, | |||
| 138 | + | WebProcessMemoryPollIntervalSec: 0, // use WebKit default (30s) | |||
| 139 | + | WebProcessMemoryConservativeThreshold: 0.25, | |||
| 140 | + | WebProcessMemoryStrictThreshold: 0.4, | |||
| 141 | + | WebProcessMemoryKillThreshold: 0.8, | |||
| 142 | + | ||||
| 143 | + | NetworkProcessMemoryLimitMB: 256, | |||
| 144 | + | NetworkProcessMemoryPollIntervalSec: 0, | |||
| 145 | + | NetworkProcessMemoryConservativeThreshold: 0.25, | |||
| 146 | + | NetworkProcessMemoryStrictThreshold: 0.4, | |||
| 147 | + | NetworkProcessMemoryKillThreshold: 0.8, | |||
| 148 | + | ||||
| 149 | + | WebViewPoolPrewarmCount: 2, | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // resolveMaxProfile returns settings optimized for maximum responsiveness. | |||
| 154 | + | // Scales values based on detected hardware capabilities. | |||
| 155 | + | func resolveMaxProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | |||
| 156 | + | // CPU threads: use physical cores (not threads), capped at WebKitGTK limit | |||
| 157 | + | cpuThreads := computeMaxCPUThreads(hw) | |||
| 158 | + | ||||
| 159 | + | // GPU threads: scale based on VRAM | |||
| 160 | + | gpuThreads := computeMaxGPUThreads(hw) | |||
| 161 | + | ||||
| 162 | + | // Memory limits: scale based on system RAM | |||
| 163 | + | webMemMB, netMemMB := computeMaxMemoryLimits(hw) | |||
| 164 | + | ||||
| 165 | + | // WebView pool: scale based on RAM | |||
| 166 | + | poolPrewarm := computeMaxPoolPrewarm(hw) | |||
| 167 | + | ||||
| 168 | + | return ResolvedPerformanceSettings{ | |||
| 169 | + | SkiaCPUPaintingThreads: cpuThreads, | |||
| 170 | + | SkiaGPUPaintingThreads: gpuThreads, | |||
| 171 | + | SkiaEnableCPURendering: false, | |||
| 172 | + | ||||
| 173 | + | WebProcessMemoryLimitMB: webMemMB, | |||
| 174 | + | WebProcessMemoryPollIntervalSec: 0, // use WebKit default | |||
| 175 | + | WebProcessMemoryConservativeThreshold: 0.5, | |||
| 176 | + | WebProcessMemoryStrictThreshold: 0.7, | |||
| 177 | + | WebProcessMemoryKillThreshold: -1, // never kill | |||
| 178 | + | ||||
| 179 | + | NetworkProcessMemoryLimitMB: netMemMB, | |||
| 180 | + | NetworkProcessMemoryPollIntervalSec: 0, | |||
| 181 | + | NetworkProcessMemoryConservativeThreshold: 0.5, | |||
| 182 | + | NetworkProcessMemoryStrictThreshold: 0.7, | |||
| 183 | + | NetworkProcessMemoryKillThreshold: -1, | |||
| 184 | + | ||||
| 185 | + | WebViewPoolPrewarmCount: poolPrewarm, | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // computeMaxCPUThreads calculates Skia CPU threads for max profile. | |||
| 190 | + | // Uses physical cores (not hyperthreaded) capped at WebKitGTK's limit. | |||
| 191 | + | func computeMaxCPUThreads(hw *port.HardwareInfo) int { | |||
| 192 | + | var cpuThreads int | |||
| 193 | + | ||||
| 194 | + | if hw != nil && hw.CPUCores > 0 { | |||
| 195 | + | // Use physical cores directly - they're better for parallel painting | |||
| 196 | + | cpuThreads = hw.CPUCores | |||
| 197 | + | } else { | |||
| 198 | + | // Fallback: assume hyperthreading, use half of logical CPUs | |||
| 199 | + | cpuThreads = runtime.NumCPU() / 2 | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // Minimum 4 for meaningful parallelism | |||
| 203 | + | if cpuThreads < 4 { | |||
| 204 | + | cpuThreads = 4 | |||
| 205 | + | } | |||
| 206 | + | // Cap at WebKitGTK Skia limit | |||
| 207 | + | if cpuThreads > maxSkiaCPUThreads { | |||
| 208 | + | cpuThreads = maxSkiaCPUThreads | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | return cpuThreads | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // computeMaxGPUThreads calculates Skia GPU threads based on VRAM. | |||
| 215 | + | func computeMaxGPUThreads(hw *port.HardwareInfo) int { | |||
| 216 | + | if hw == nil || hw.VRAM == 0 { | |||
| 217 | + | // No VRAM info - use conservative default | |||
| 218 | + | return gpuThreadsMin | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // Scale GPU threads based on VRAM tiers | |||
| 222 | + | switch { | |||
| 223 | + | case hw.VRAM >= vramTierHigh: // 16GB+ VRAM (high-end: RX 7900, RTX 4080+) | |||
| 224 | + | return gpuThreadsHigh | |||
| 225 | + | case hw.VRAM >= vramTierMid: // 8-16GB VRAM (mid-range: RX 7800, RTX 4070) | |||
| 226 | + | return gpuThreadsMid | |||
| 227 | + | case hw.VRAM >= vramTierLow: // 4-8GB VRAM (entry discrete) | |||
| 228 | + | return gpuThreadsLow | |||
| 229 | + | default: // <4GB VRAM (integrated / old discrete) | |||
| 230 | + | return gpuThreadsMin | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // computeMaxMemoryLimits returns web process and network process memory limits. | |||
| 235 | + | func computeMaxMemoryLimits(hw *port.HardwareInfo) (webMB, netMB int) { | |||
| 236 | + | if hw == nil || hw.TotalRAM == 0 { | |||
| 237 | + | // Fallback: assume 16GB system | |||
| 238 | + | return webMemFallback, netMemFallback | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // Scale memory limits based on system RAM | |||
| 242 | + | switch { | |||
| 243 | + | case hw.TotalRAM >= ramTierHigh: // 32GB+ RAM | |||
| 244 | + | return webMemHigh, netMemHigh | |||
| 245 | + | case hw.TotalRAM >= ramTierMedium: // 16-32GB RAM | |||
| 246 | + | return webMemMedium, netMemMedium | |||
| 247 | + | case hw.TotalRAM >= ramTierLow: // 8-16GB RAM | |||
| 248 | + | return webMemLow, netMemLow | |||
| 249 | + | default: // <8GB RAM | |||
| 250 | + | return webMemMin, netMemMin | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // computeMaxPoolPrewarm returns WebView pool prewarm count based on RAM. | |||
| 255 | + | func computeMaxPoolPrewarm(hw *port.HardwareInfo) int { | |||
| 256 | + | if hw == nil || hw.TotalRAM == 0 { | |||
| 257 | + | return poolPrewarmFallback | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | switch { | |||
| 261 | + | case hw.TotalRAM >= ramTierHigh: // 32GB+ | |||
| 262 | + | return poolPrewarmHigh | |||
| 263 | + | case hw.TotalRAM >= ramTierMedium: // 16-32GB | |||
| 264 | + | return poolPrewarmMedium | |||
| 265 | + | case hw.TotalRAM >= ramTierLow: // 8-16GB | |||
| 266 | + | return poolPrewarmLow | |||
| 267 | + | default: // <8GB | |||
| 268 | + | return poolPrewarmMin | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // resolveCustomProfile returns the user's configured values directly. | |||
| 273 | + | func resolveCustomProfile(cfg *PerformanceConfig) ResolvedPerformanceSettings { | |||
| 274 | + | return ResolvedPerformanceSettings{ | |||
| 275 | + | SkiaCPUPaintingThreads: cfg.SkiaCPUPaintingThreads, | |||
| 276 | + | SkiaGPUPaintingThreads: cfg.SkiaGPUPaintingThreads, | |||
| 277 | + | SkiaEnableCPURendering: cfg.SkiaEnableCPURendering, | |||
| 278 | + | ||||
| 279 | + | WebProcessMemoryLimitMB: cfg.WebProcessMemoryLimitMB, | |||
| 280 | + | WebProcessMemoryPollIntervalSec: cfg.WebProcessMemoryPollIntervalSec, | |||
| 281 | + | WebProcessMemoryConservativeThreshold: cfg.WebProcessMemoryConservativeThreshold, | |||
| 282 | + | WebProcessMemoryStrictThreshold: cfg.WebProcessMemoryStrictThreshold, | |||
| 283 | + | WebProcessMemoryKillThreshold: cfg.WebProcessMemoryKillThreshold, | |||
| 284 | + | ||||
| 285 | + | NetworkProcessMemoryLimitMB: cfg.NetworkProcessMemoryLimitMB, | |||
| 286 | + | NetworkProcessMemoryPollIntervalSec: cfg.NetworkProcessMemoryPollIntervalSec, | |||
| 287 | + | NetworkProcessMemoryConservativeThreshold: cfg.NetworkProcessMemoryConservativeThreshold, | |||
| 288 | + | NetworkProcessMemoryStrictThreshold: cfg.NetworkProcessMemoryStrictThreshold, | |||
| 289 | + | NetworkProcessMemoryKillThreshold: cfg.NetworkProcessMemoryKillThreshold, | |||
| 290 | + | ||||
| 291 | + | WebViewPoolPrewarmCount: cfg.WebViewPoolPrewarmCount, | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | // HasCustomPerformanceFields returns true if any individual performance tuning | |||
| 296 | + | // fields are set to non-default values. Used for validation. | |||
| 297 | + | func HasCustomPerformanceFields(cfg *PerformanceConfig) bool { | |||
| 298 | + | return cfg.SkiaCPUPaintingThreads != 0 || | |||
| 299 | + | cfg.SkiaGPUPaintingThreads != -1 || | |||
| 300 | + | cfg.SkiaEnableCPURendering || | |||
| 301 | + | cfg.WebProcessMemoryLimitMB != 0 || | |||
| 302 | + | cfg.WebProcessMemoryPollIntervalSec != 0 || | |||
| 303 | + | cfg.WebProcessMemoryConservativeThreshold != 0 || | |||
| 304 | + | cfg.WebProcessMemoryStrictThreshold != 0 || | |||
| 305 | + | cfg.WebProcessMemoryKillThreshold != -1 || | |||
| 306 | + | cfg.NetworkProcessMemoryLimitMB != 0 || | |||
| 307 | + | cfg.NetworkProcessMemoryPollIntervalSec != 0 || | |||
| 308 | + | cfg.NetworkProcessMemoryConservativeThreshold != 0 || | |||
| 309 | + | cfg.NetworkProcessMemoryStrictThreshold != 0 || | |||
| 310 | + | cfg.NetworkProcessMemoryKillThreshold != -1 | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // IsValidPerformanceProfile returns true if the profile name is recognized. | |||
| 314 | + | func IsValidPerformanceProfile(profile PerformanceProfile) bool { | |||
| 315 | + | switch profile { | |||
| 316 | + | case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "": | |||
| 317 | + | return true | |||
| 318 | + | default: | |||
| 319 | + | return false | |||
| 320 | + | } | |||
| 321 | + | } | |||
A internal/infrastructure/config/profile_test.go +414 -0
| @@ -0,0 +1,414 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "runtime" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolvePerformanceProfile_Default(t *testing.T) { | |
| 11 | + | cfg := &PerformanceConfig{ | |
| 12 | + | Profile: ProfileDefault, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 16 | + | ||
| 17 | + | // Default should have no tuning (unset values) | |
| 18 | + | if result.SkiaCPUPaintingThreads != 0 { | |
| 19 | + | t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads) | |
| 20 | + | } | |
| 21 | + | if result.SkiaGPUPaintingThreads != -1 { | |
| 22 | + | t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads) | |
| 23 | + | } | |
| 24 | + | if result.WebProcessMemoryLimitMB != 0 { | |
| 25 | + | t.Errorf("expected WebProcessMemoryLimitMB=0, got %d", result.WebProcessMemoryLimitMB) | |
| 26 | + | } | |
| 27 | + | if result.WebViewPoolPrewarmCount != 4 { | |
| 28 | + | t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestResolvePerformanceProfile_Empty(t *testing.T) { | |
| 33 | + | cfg := &PerformanceConfig{ | |
| 34 | + | Profile: "", // Empty should behave like default | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 38 | + | ||
| 39 | + | if result.SkiaCPUPaintingThreads != 0 { | |
| 40 | + | t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads) | |
| 41 | + | } | |
| 42 | + | if result.WebViewPoolPrewarmCount != 4 { | |
| 43 | + | t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) { | |
| 48 | + | cfg := &PerformanceConfig{ | |
| 49 | + | Profile: ProfileLite, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 53 | + | ||
| 54 | + | // Lite without hardware info should use default lite values | |
| 55 | + | if result.SkiaCPUPaintingThreads != 2 { | |
| 56 | + | t.Errorf("expected SkiaCPUPaintingThreads=2, got %d", result.SkiaCPUPaintingThreads) | |
| 57 | + | } | |
| 58 | + | if result.WebProcessMemoryLimitMB != 512 { | |
| 59 | + | t.Errorf("expected WebProcessMemoryLimitMB=512, got %d", result.WebProcessMemoryLimitMB) | |
| 60 | + | } | |
| 61 | + | if result.NetworkProcessMemoryLimitMB != 256 { | |
| 62 | + | t.Errorf("expected NetworkProcessMemoryLimitMB=256, got %d", result.NetworkProcessMemoryLimitMB) | |
| 63 | + | } | |
| 64 | + | if result.WebViewPoolPrewarmCount != 2 { | |
| 65 | + | t.Errorf("expected WebViewPoolPrewarmCount=2, got %d", result.WebViewPoolPrewarmCount) | |
| 66 | + | } | |
| 67 | + | if result.WebProcessMemoryKillThreshold != 0.8 { | |
| 68 | + | t.Errorf("expected WebProcessMemoryKillThreshold=0.8, got %f", result.WebProcessMemoryKillThreshold) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestResolvePerformanceProfile_Lite_LowCoreSystem(t *testing.T) { | |
| 73 | + | cfg := &PerformanceConfig{ | |
| 74 | + | Profile: ProfileLite, | |
| 75 | + | } | |
| 76 | + | hw := &port.HardwareInfo{ | |
| 77 | + | CPUCores: 2, // Very low core count | |
| 78 | + | CPUThreads: 4, | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 82 | + | ||
| 83 | + | // Lite on low-core system should scale down | |
| 84 | + | if result.SkiaCPUPaintingThreads != 1 { | |
| 85 | + | t.Errorf("expected SkiaCPUPaintingThreads=1 for 2-core system, got %d", result.SkiaCPUPaintingThreads) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestResolvePerformanceProfile_Max_NoHardware(t *testing.T) { | |
| 90 | + | cfg := &PerformanceConfig{ | |
| 91 | + | Profile: ProfileMax, | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 95 | + | ||
| 96 | + | // Max without hardware info should use fallback values | |
| 97 | + | // CPU threads: NumCPU/2 (assuming hyperthreading), clamped to [4, 8] | |
| 98 | + | expectedCPUThreads := runtime.NumCPU() / 2 | |
| 99 | + | if expectedCPUThreads < 4 { | |
| 100 | + | expectedCPUThreads = 4 | |
| 101 | + | } | |
| 102 | + | if expectedCPUThreads > 8 { | |
| 103 | + | expectedCPUThreads = 8 | |
| 104 | + | } | |
| 105 | + | if result.SkiaCPUPaintingThreads != expectedCPUThreads { | |
| 106 | + | t.Errorf("expected SkiaCPUPaintingThreads=%d, got %d", expectedCPUThreads, result.SkiaCPUPaintingThreads) | |
| 107 | + | } | |
| 108 | + | // GPU threads: conservative default when no VRAM info | |
| 109 | + | if result.SkiaGPUPaintingThreads != 2 { | |
| 110 | + | t.Errorf("expected SkiaGPUPaintingThreads=2 (fallback), got %d", result.SkiaGPUPaintingThreads) | |
| 111 | + | } | |
| 112 | + | // Memory: fallback assumes 16GB system | |
| 113 | + | if result.WebProcessMemoryLimitMB != 2048 { | |
| 114 | + | t.Errorf("expected WebProcessMemoryLimitMB=2048, got %d", result.WebProcessMemoryLimitMB) | |
| 115 | + | } | |
| 116 | + | if result.WebViewPoolPrewarmCount != 8 { | |
| 117 | + | t.Errorf("expected WebViewPoolPrewarmCount=8, got %d", result.WebViewPoolPrewarmCount) | |
| 118 | + | } | |
| 119 | + | if result.WebProcessMemoryKillThreshold != -1 { | |
| 120 | + | t.Errorf("expected WebProcessMemoryKillThreshold=-1 (never kill), got %f", result.WebProcessMemoryKillThreshold) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestResolvePerformanceProfile_Max_HighEndSystem(t *testing.T) { | |
| 125 | + | cfg := &PerformanceConfig{ | |
| 126 | + | Profile: ProfileMax, | |
| 127 | + | } | |
| 128 | + | hw := &port.HardwareInfo{ | |
| 129 | + | CPUCores: 16, | |
| 130 | + | CPUThreads: 32, | |
| 131 | + | TotalRAM: 64 * 1024 * 1024 * 1024, // 64 GB | |
| 132 | + | VRAM: 16 * 1024 * 1024 * 1024, // 16 GB (high-end GPU) | |
| 133 | + | GPUVendor: port.GPUVendorAMD, | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 137 | + | ||
| 138 | + | // CPU threads: use physical cores capped at 8 (WebKitGTK limit) | |
| 139 | + | if result.SkiaCPUPaintingThreads != 8 { | |
| 140 | + | t.Errorf("expected SkiaCPUPaintingThreads=8 (capped), got %d", result.SkiaCPUPaintingThreads) | |
| 141 | + | } | |
| 142 | + | // GPU threads: high-end tier (16GB+ VRAM) | |
| 143 | + | if result.SkiaGPUPaintingThreads != 8 { | |
| 144 | + | t.Errorf("expected SkiaGPUPaintingThreads=8 for 16GB VRAM, got %d", result.SkiaGPUPaintingThreads) | |
| 145 | + | } | |
| 146 | + | // Memory: high-end tier (32GB+ RAM) | |
| 147 | + | if result.WebProcessMemoryLimitMB != 4096 { | |
| 148 | + | t.Errorf("expected WebProcessMemoryLimitMB=4096 for 64GB RAM, got %d", result.WebProcessMemoryLimitMB) | |
| 149 | + | } | |
| 150 | + | if result.NetworkProcessMemoryLimitMB != 1024 { | |
| 151 | + | t.Errorf("expected NetworkProcessMemoryLimitMB=1024 for 64GB RAM, got %d", result.NetworkProcessMemoryLimitMB) | |
| 152 | + | } | |
| 153 | + | // Pool prewarm: high-end tier | |
| 154 | + | if result.WebViewPoolPrewarmCount != 12 { | |
| 155 | + | t.Errorf("expected WebViewPoolPrewarmCount=12 for 64GB RAM, got %d", result.WebViewPoolPrewarmCount) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestResolvePerformanceProfile_Max_MidRangeSystem(t *testing.T) { | |
| 160 | + | cfg := &PerformanceConfig{ | |
| 161 | + | Profile: ProfileMax, | |
| 162 | + | } | |
| 163 | + | hw := &port.HardwareInfo{ | |
| 164 | + | CPUCores: 8, | |
| 165 | + | CPUThreads: 16, | |
| 166 | + | TotalRAM: 16 * 1024 * 1024 * 1024, // 16 GB | |
| 167 | + | VRAM: 8 * 1024 * 1024 * 1024, // 8 GB (mid-range GPU) | |
| 168 | + | GPUVendor: port.GPUVendorNVIDIA, | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 172 | + | ||
| 173 | + | // CPU threads: use physical cores (8) | |
| 174 | + | if result.SkiaCPUPaintingThreads != 8 { | |
| 175 | + | t.Errorf("expected SkiaCPUPaintingThreads=8, got %d", result.SkiaCPUPaintingThreads) | |
| 176 | + | } | |
| 177 | + | // GPU threads: mid-range tier (8-16GB VRAM) | |
| 178 | + | if result.SkiaGPUPaintingThreads != 6 { | |
| 179 | + | t.Errorf("expected SkiaGPUPaintingThreads=6 for 8GB VRAM, got %d", result.SkiaGPUPaintingThreads) | |
| 180 | + | } | |
| 181 | + | // Memory: mid-range tier (16-32GB RAM) | |
| 182 | + | if result.WebProcessMemoryLimitMB != 3072 { | |
| 183 | + | t.Errorf("expected WebProcessMemoryLimitMB=3072 for 16GB RAM, got %d", result.WebProcessMemoryLimitMB) | |
| 184 | + | } | |
| 185 | + | // Pool prewarm: mid-range tier | |
| 186 | + | if result.WebViewPoolPrewarmCount != 8 { | |
| 187 | + | t.Errorf("expected WebViewPoolPrewarmCount=8 for 16GB RAM, got %d", result.WebViewPoolPrewarmCount) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestResolvePerformanceProfile_Max_LowEndSystem(t *testing.T) { | |
| 192 | + | cfg := &PerformanceConfig{ | |
| 193 | + | Profile: ProfileMax, | |
| 194 | + | } | |
| 195 | + | hw := &port.HardwareInfo{ | |
| 196 | + | CPUCores: 4, | |
| 197 | + | CPUThreads: 4, | |
| 198 | + | TotalRAM: 4 * 1024 * 1024 * 1024, // 4 GB | |
| 199 | + | VRAM: 2 * 1024 * 1024 * 1024, // 2 GB (integrated/old discrete) | |
| 200 | + | GPUVendor: port.GPUVendorIntel, | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 204 | + | ||
| 205 | + | // CPU threads: use physical cores (4) | |
| 206 | + | if result.SkiaCPUPaintingThreads != 4 { | |
| 207 | + | t.Errorf("expected SkiaCPUPaintingThreads=4, got %d", result.SkiaCPUPaintingThreads) | |
| 208 | + | } | |
| 209 | + | // GPU threads: low tier (<4GB VRAM) | |
| 210 | + | if result.SkiaGPUPaintingThreads != 2 { | |
| 211 | + | t.Errorf("expected SkiaGPUPaintingThreads=2 for 2GB VRAM, got %d", result.SkiaGPUPaintingThreads) | |
| 212 | + | } | |
| 213 | + | // Memory: low tier (<8GB RAM) | |
| 214 | + | if result.WebProcessMemoryLimitMB != 1024 { | |
| 215 | + | t.Errorf("expected WebProcessMemoryLimitMB=1024 for 4GB RAM, got %d", result.WebProcessMemoryLimitMB) | |
| 216 | + | } | |
| 217 | + | // Pool prewarm: low tier | |
| 218 | + | if result.WebViewPoolPrewarmCount != 4 { | |
| 219 | + | t.Errorf("expected WebViewPoolPrewarmCount=4 for 4GB RAM, got %d", result.WebViewPoolPrewarmCount) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func TestResolvePerformanceProfile_Custom(t *testing.T) { | |
| 224 | + | cfg := &PerformanceConfig{ | |
| 225 | + | Profile: ProfileCustom, | |
| 226 | + | SkiaCPUPaintingThreads: 6, | |
| 227 | + | SkiaGPUPaintingThreads: 3, | |
| 228 | + | SkiaEnableCPURendering: true, | |
| 229 | + | WebProcessMemoryLimitMB: 1024, | |
| 230 | + | WebProcessMemoryConservativeThreshold: 0.4, | |
| 231 | + | WebViewPoolPrewarmCount: 5, | |
| 232 | + | } | |
| 233 | + | // Hardware info should be ignored for custom profile | |
| 234 | + | hw := &port.HardwareInfo{ | |
| 235 | + | CPUCores: 16, | |
| 236 | + | CPUThreads: 32, | |
| 237 | + | TotalRAM: 64 * 1024 * 1024 * 1024, | |
| 238 | + | VRAM: 16 * 1024 * 1024 * 1024, | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 242 | + | ||
| 243 | + | // Custom should use user-specified values, ignoring hardware | |
| 244 | + | if result.SkiaCPUPaintingThreads != 6 { | |
| 245 | + | t.Errorf("expected SkiaCPUPaintingThreads=6, got %d", result.SkiaCPUPaintingThreads) | |
| 246 | + | } | |
| 247 | + | if result.SkiaGPUPaintingThreads != 3 { | |
| 248 | + | t.Errorf("expected SkiaGPUPaintingThreads=3, got %d", result.SkiaGPUPaintingThreads) | |
| 249 | + | } | |
| 250 | + | if !result.SkiaEnableCPURendering { | |
| 251 | + | t.Error("expected SkiaEnableCPURendering=true") | |
| 252 | + | } | |
| 253 | + | if result.WebProcessMemoryLimitMB != 1024 { | |
| 254 | + | t.Errorf("expected WebProcessMemoryLimitMB=1024, got %d", result.WebProcessMemoryLimitMB) | |
| 255 | + | } | |
| 256 | + | if result.WebViewPoolPrewarmCount != 5 { | |
| 257 | + | t.Errorf("expected WebViewPoolPrewarmCount=5, got %d", result.WebViewPoolPrewarmCount) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestHasCustomPerformanceFields(t *testing.T) { | |
| 262 | + | tests := []struct { | |
| 263 | + | name string | |
| 264 | + | cfg PerformanceConfig | |
| 265 | + | expected bool | |
| 266 | + | }{ | |
| 267 | + | { | |
| 268 | + | name: "all defaults - no custom fields", | |
| 269 | + | cfg: PerformanceConfig{ | |
| 270 | + | SkiaCPUPaintingThreads: 0, | |
| 271 | + | SkiaGPUPaintingThreads: -1, | |
| 272 | + | SkiaEnableCPURendering: false, | |
| 273 | + | WebProcessMemoryKillThreshold: -1, | |
| 274 | + | NetworkProcessMemoryKillThreshold: -1, | |
| 275 | + | }, | |
| 276 | + | expected: false, | |
| 277 | + | }, | |
| 278 | + | { | |
| 279 | + | name: "skia cpu threads set", | |
| 280 | + | cfg: PerformanceConfig{ | |
| 281 | + | SkiaCPUPaintingThreads: 4, | |
| 282 | + | SkiaGPUPaintingThreads: -1, | |
| 283 | + | WebProcessMemoryKillThreshold: -1, | |
| 284 | + | NetworkProcessMemoryKillThreshold: -1, | |
| 285 | + | }, | |
| 286 | + | expected: true, | |
| 287 | + | }, | |
| 288 | + | { | |
| 289 | + | name: "memory limit set", | |
| 290 | + | cfg: PerformanceConfig{ | |
| 291 | + | SkiaGPUPaintingThreads: -1, | |
| 292 | + | WebProcessMemoryLimitMB: 1024, | |
| 293 | + | WebProcessMemoryKillThreshold: -1, | |
| 294 | + | NetworkProcessMemoryKillThreshold: -1, | |
| 295 | + | }, | |
| 296 | + | expected: true, | |
| 297 | + | }, | |
| 298 | + | { | |
| 299 | + | name: "kill threshold changed from -1", | |
| 300 | + | cfg: PerformanceConfig{ | |
| 301 | + | SkiaGPUPaintingThreads: -1, | |
| 302 | + | WebProcessMemoryKillThreshold: 0.9, | |
| 303 | + | NetworkProcessMemoryKillThreshold: -1, | |
| 304 | + | }, | |
| 305 | + | expected: true, | |
| 306 | + | }, | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | for _, tt := range tests { | |
| 310 | + | t.Run(tt.name, func(t *testing.T) { | |
| 311 | + | result := HasCustomPerformanceFields(&tt.cfg) | |
| 312 | + | if result != tt.expected { | |
| 313 | + | t.Errorf("expected %v, got %v", tt.expected, result) | |
| 314 | + | } | |
| 315 | + | }) | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func TestIsValidPerformanceProfile(t *testing.T) { | |
| 320 | + | tests := []struct { | |
| 321 | + | profile PerformanceProfile | |
| 322 | + | expected bool | |
| 323 | + | }{ | |
| 324 | + | {ProfileDefault, true}, | |
| 325 | + | {ProfileLite, true}, | |
| 326 | + | {ProfileMax, true}, | |
| 327 | + | {ProfileCustom, true}, | |
| 328 | + | {"", true}, // Empty is valid (treated as default) | |
| 329 | + | {"invalid", false}, | |
| 330 | + | {"LITE", false}, // Case sensitive | |
| 331 | + | {"Maximum", false}, | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | for _, tt := range tests { | |
| 335 | + | t.Run(string(tt.profile), func(t *testing.T) { | |
| 336 | + | result := IsValidPerformanceProfile(tt.profile) | |
| 337 | + | if result != tt.expected { | |
| 338 | + | t.Errorf("IsValidPerformanceProfile(%q): expected %v, got %v", tt.profile, tt.expected, result) | |
| 339 | + | } | |
| 340 | + | }) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func TestComputeMaxGPUThreads_VRAMTiers(t *testing.T) { | |
| 345 | + | tests := []struct { | |
| 346 | + | name string | |
| 347 | + | vramGB int | |
| 348 | + | expected int | |
| 349 | + | }{ | |
| 350 | + | {"no VRAM info", 0, 2}, | |
| 351 | + | {"2GB VRAM (integrated)", 2, 2}, | |
| 352 | + | {"4GB VRAM (entry discrete)", 4, 4}, | |
| 353 | + | {"6GB VRAM (low-mid discrete)", 6, 4}, | |
| 354 | + | {"8GB VRAM (mid discrete)", 8, 6}, | |
| 355 | + | {"12GB VRAM (mid-high discrete)", 12, 6}, | |
| 356 | + | {"16GB VRAM (high-end)", 16, 8}, | |
| 357 | + | {"24GB VRAM (workstation)", 24, 8}, | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | for _, tt := range tests { | |
| 361 | + | t.Run(tt.name, func(t *testing.T) { | |
| 362 | + | hw := &port.HardwareInfo{ | |
| 363 | + | VRAM: uint64(tt.vramGB) * 1024 * 1024 * 1024, | |
| 364 | + | } | |
| 365 | + | if tt.vramGB == 0 { | |
| 366 | + | hw = nil | |
| 367 | + | } | |
| 368 | + | result := computeMaxGPUThreads(hw) | |
| 369 | + | if result != tt.expected { | |
| 370 | + | t.Errorf("computeMaxGPUThreads(%dGB VRAM): expected %d, got %d", tt.vramGB, tt.expected, result) | |
| 371 | + | } | |
| 372 | + | }) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func TestComputeMaxMemoryLimits_RAMTiers(t *testing.T) { | |
| 377 | + | tests := []struct { | |
| 378 | + | name string | |
| 379 | + | ramGB int | |
| 380 | + | expectedWeb int | |
| 381 | + | expectedNet int | |
| 382 | + | expectedPool int | |
| 383 | + | }{ | |
| 384 | + | {"no RAM info", 0, 2048, 512, 8}, | |
| 385 | + | {"4GB RAM", 4, 1024, 256, 4}, | |
| 386 | + | {"8GB RAM", 8, 2048, 512, 6}, | |
| 387 | + | {"16GB RAM", 16, 3072, 768, 8}, | |
| 388 | + | {"32GB RAM", 32, 4096, 1024, 12}, | |
| 389 | + | {"64GB RAM", 64, 4096, 1024, 12}, | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | for _, tt := range tests { | |
| 393 | + | t.Run(tt.name, func(t *testing.T) { | |
| 394 | + | hw := &port.HardwareInfo{ | |
| 395 | + | TotalRAM: uint64(tt.ramGB) * 1024 * 1024 * 1024, | |
| 396 | + | } | |
| 397 | + | if tt.ramGB == 0 { | |
| 398 | + | hw = nil | |
| 399 | + | } | |
| 400 | + | webMB, netMB := computeMaxMemoryLimits(hw) | |
| 401 | + | poolPrewarm := computeMaxPoolPrewarm(hw) | |
| 402 | + | ||
| 403 | + | if webMB != tt.expectedWeb { | |
| 404 | + | t.Errorf("computeMaxMemoryLimits(%dGB RAM) web: expected %d, got %d", tt.ramGB, tt.expectedWeb, webMB) | |
| 405 | + | } | |
| 406 | + | if netMB != tt.expectedNet { | |
| 407 | + | t.Errorf("computeMaxMemoryLimits(%dGB RAM) net: expected %d, got %d", tt.ramGB, tt.expectedNet, netMB) | |
| 408 | + | } | |
| 409 | + | if poolPrewarm != tt.expectedPool { | |
| 410 | + | t.Errorf("computeMaxPoolPrewarm(%dGB RAM): expected %d, got %d", tt.ramGB, tt.expectedPool, poolPrewarm) | |
| 411 | + | } | |
| 412 | + | }) | |
| 413 | + | } | |
| 414 | + | } | |
| @@ -0,0 +1,414 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "runtime" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolvePerformanceProfile_Default(t *testing.T) { | |||
| 11 | + | cfg := &PerformanceConfig{ | |||
| 12 | + | Profile: ProfileDefault, | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | result := ResolvePerformanceProfile(cfg, nil) | |||
| 16 | + | ||||
| 17 | + | // Default should have no tuning (unset values) | |||
| 18 | + | if result.SkiaCPUPaintingThreads != 0 { | |||
| 19 | + | t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads) | |||
| 20 | + | } | |||
| 21 | + | if result.SkiaGPUPaintingThreads != -1 { | |||
| 22 | + | t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads) | |||
| 23 | + | } | |||
| 24 | + | if result.WebProcessMemoryLimitMB != 0 { | |||
| 25 | + | t.Errorf("expected WebProcessMemoryLimitMB=0, got %d", result.WebProcessMemoryLimitMB) | |||
| 26 | + | } | |||
| 27 | + | if result.WebViewPoolPrewarmCount != 4 { | |||
| 28 | + | t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestResolvePerformanceProfile_Empty(t *testing.T) { | |||
| 33 | + | cfg := &PerformanceConfig{ | |||
| 34 | + | Profile: "", // Empty should behave like default | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | result := ResolvePerformanceProfile(cfg, nil) | |||
| 38 | + | ||||
| 39 | + | if result.SkiaCPUPaintingThreads != 0 { | |||
| 40 | + | t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads) | |||
| 41 | + | } | |||
| 42 | + | if result.WebViewPoolPrewarmCount != 4 { | |||
| 43 | + | t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) { | |||
| 48 | + | cfg := &PerformanceConfig{ | |||
| 49 | + | Profile: ProfileLite, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | result := ResolvePerformanceProfile(cfg, nil) | |||
| 53 | + | ||||
| 54 | + | // Lite without hardware info should use default lite values | |||
| 55 | + | if result.SkiaCPUPaintingThreads != 2 { | |||
| 56 | + | t.Errorf("expected SkiaCPUPaintingThreads=2, got %d", result.SkiaCPUPaintingThreads) | |||
| 57 | + | } | |||
| 58 | + | if result.WebProcessMemoryLimitMB != 512 { | |||
| 59 | + | t.Errorf("expected WebProcessMemoryLimitMB=512, got %d", result.WebProcessMemoryLimitMB) | |||
| 60 | + | } | |||
| 61 | + | if result.NetworkProcessMemoryLimitMB != 256 { | |||
| 62 | + | t.Errorf("expected NetworkProcessMemoryLimitMB=256, got %d", result.NetworkProcessMemoryLimitMB) | |||
| 63 | + | } | |||
| 64 | + | if result.WebViewPoolPrewarmCount != 2 { | |||
| 65 | + | t.Errorf("expected WebViewPoolPrewarmCount=2, got %d", result.WebViewPoolPrewarmCount) | |||
| 66 | + | } | |||
| 67 | + | if result.WebProcessMemoryKillThreshold != 0.8 { | |||
| 68 | + | t.Errorf("expected WebProcessMemoryKillThreshold=0.8, got %f", result.WebProcessMemoryKillThreshold) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestResolvePerformanceProfile_Lite_LowCoreSystem(t *testing.T) { | |||
| 73 | + | cfg := &PerformanceConfig{ | |||
| 74 | + | Profile: ProfileLite, | |||
| 75 | + | } | |||
| 76 | + | hw := &port.HardwareInfo{ | |||
| 77 | + | CPUCores: 2, // Very low core count | |||
| 78 | + | CPUThreads: 4, | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | result := ResolvePerformanceProfile(cfg, hw) | |||
| 82 | + | ||||
| 83 | + | // Lite on low-core system should scale down | |||
| 84 | + | if result.SkiaCPUPaintingThreads != 1 { | |||
| 85 | + | t.Errorf("expected SkiaCPUPaintingThreads=1 for 2-core system, got %d", result.SkiaCPUPaintingThreads) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestResolvePerformanceProfile_Max_NoHardware(t *testing.T) { | |||
| 90 | + | cfg := &PerformanceConfig{ | |||
| 91 | + | Profile: ProfileMax, | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | result := ResolvePerformanceProfile(cfg, nil) | |||
| 95 | + | ||||
| 96 | + | // Max without hardware info should use fallback values | |||
| 97 | + | // CPU threads: NumCPU/2 (assuming hyperthreading), clamped to [4, 8] | |||
| 98 | + | expectedCPUThreads := runtime.NumCPU() / 2 | |||
| 99 | + | if expectedCPUThreads < 4 { | |||
| 100 | + | expectedCPUThreads = 4 | |||
| 101 | + | } | |||
| 102 | + | if expectedCPUThreads > 8 { | |||
| 103 | + | expectedCPUThreads = 8 | |||
| 104 | + | } | |||
| 105 | + | if result.SkiaCPUPaintingThreads != expectedCPUThreads { | |||
| 106 | + | t.Errorf("expected SkiaCPUPaintingThreads=%d, got %d", expectedCPUThreads, result.SkiaCPUPaintingThreads) | |||
| 107 | + | } | |||
| 108 | + | // GPU threads: conservative default when no VRAM info | |||
| 109 | + | if result.SkiaGPUPaintingThreads != 2 { | |||
| 110 | + | t.Errorf("expected SkiaGPUPaintingThreads=2 (fallback), got %d", result.SkiaGPUPaintingThreads) | |||
| 111 | + | } | |||
| 112 | + | // Memory: fallback assumes 16GB system | |||
| 113 | + | if result.WebProcessMemoryLimitMB != 2048 { | |||
| 114 | + | t.Errorf("expected WebProcessMemoryLimitMB=2048, got %d", result.WebProcessMemoryLimitMB) | |||
| 115 | + | } | |||
| 116 | + | if result.WebViewPoolPrewarmCount != 8 { | |||
| 117 | + | t.Errorf("expected WebViewPoolPrewarmCount=8, got %d", result.WebViewPoolPrewarmCount) | |||
| 118 | + | } | |||
| 119 | + | if result.WebProcessMemoryKillThreshold != -1 { | |||
| 120 | + | t.Errorf("expected WebProcessMemoryKillThreshold=-1 (never kill), got %f", result.WebProcessMemoryKillThreshold) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestResolvePerformanceProfile_Max_HighEndSystem(t *testing.T) { | |||
| 125 | + | cfg := &PerformanceConfig{ | |||
| 126 | + | Profile: ProfileMax, | |||
| 127 | + | } | |||
| 128 | + | hw := &port.HardwareInfo{ | |||
| 129 | + | CPUCores: 16, | |||
| 130 | + | CPUThreads: 32, | |||
| 131 | + | TotalRAM: 64 * 1024 * 1024 * 1024, // 64 GB | |||
| 132 | + | VRAM: 16 * 1024 * 1024 * 1024, // 16 GB (high-end GPU) | |||
| 133 | + | GPUVendor: port.GPUVendorAMD, | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | result := ResolvePerformanceProfile(cfg, hw) | |||
| 137 | + | ||||
| 138 | + | // CPU threads: use physical cores capped at 8 (WebKitGTK limit) | |||
| 139 | + | if result.SkiaCPUPaintingThreads != 8 { | |||
| 140 | + | t.Errorf("expected SkiaCPUPaintingThreads=8 (capped), got %d", result.SkiaCPUPaintingThreads) | |||
| 141 | + | } | |||
| 142 | + | // GPU threads: high-end tier (16GB+ VRAM) | |||
| 143 | + | if result.SkiaGPUPaintingThreads != 8 { | |||
| 144 | + | t.Errorf("expected SkiaGPUPaintingThreads=8 for 16GB VRAM, got %d", result.SkiaGPUPaintingThreads) | |||
| 145 | + | } | |||
| 146 | + | // Memory: high-end tier (32GB+ RAM) | |||
| 147 | + | if result.WebProcessMemoryLimitMB != 4096 { | |||
| 148 | + | t.Errorf("expected WebProcessMemoryLimitMB=4096 for 64GB RAM, got %d", result.WebProcessMemoryLimitMB) | |||
| 149 | + | } | |||
| 150 | + | if result.NetworkProcessMemoryLimitMB != 1024 { | |||
| 151 | + | t.Errorf("expected NetworkProcessMemoryLimitMB=1024 for 64GB RAM, got %d", result.NetworkProcessMemoryLimitMB) | |||
| 152 | + | } | |||
| 153 | + | // Pool prewarm: high-end tier | |||
| 154 | + | if result.WebViewPoolPrewarmCount != 12 { | |||
| 155 | + | t.Errorf("expected WebViewPoolPrewarmCount=12 for 64GB RAM, got %d", result.WebViewPoolPrewarmCount) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestResolvePerformanceProfile_Max_MidRangeSystem(t *testing.T) { | |||
| 160 | + | cfg := &PerformanceConfig{ | |||
| 161 | + | Profile: ProfileMax, | |||
| 162 | + | } | |||
| 163 | + | hw := &port.HardwareInfo{ | |||
| 164 | + | CPUCores: 8, | |||
| 165 | + | CPUThreads: 16, | |||
| 166 | + | TotalRAM: 16 * 1024 * 1024 * 1024, // 16 GB | |||
| 167 | + | VRAM: 8 * 1024 * 1024 * 1024, // 8 GB (mid-range GPU) | |||
| 168 | + | GPUVendor: port.GPUVendorNVIDIA, | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | result := ResolvePerformanceProfile(cfg, hw) | |||
| 172 | + | ||||
| 173 | + | // CPU threads: use physical cores (8) | |||
| 174 | + | if result.SkiaCPUPaintingThreads != 8 { | |||
| 175 | + | t.Errorf("expected SkiaCPUPaintingThreads=8, got %d", result.SkiaCPUPaintingThreads) | |||
| 176 | + | } | |||
| 177 | + | // GPU threads: mid-range tier (8-16GB VRAM) | |||
| 178 | + | if result.SkiaGPUPaintingThreads != 6 { | |||
| 179 | + | t.Errorf("expected SkiaGPUPaintingThreads=6 for 8GB VRAM, got %d", result.SkiaGPUPaintingThreads) | |||
| 180 | + | } | |||
| 181 | + | // Memory: mid-range tier (16-32GB RAM) | |||
| 182 | + | if result.WebProcessMemoryLimitMB != 3072 { | |||
| 183 | + | t.Errorf("expected WebProcessMemoryLimitMB=3072 for 16GB RAM, got %d", result.WebProcessMemoryLimitMB) | |||
| 184 | + | } | |||
| 185 | + | // Pool prewarm: mid-range tier | |||
| 186 | + | if result.WebViewPoolPrewarmCount != 8 { | |||
| 187 | + | t.Errorf("expected WebViewPoolPrewarmCount=8 for 16GB RAM, got %d", result.WebViewPoolPrewarmCount) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestResolvePerformanceProfile_Max_LowEndSystem(t *testing.T) { | |||
| 192 | + | cfg := &PerformanceConfig{ | |||
| 193 | + | Profile: ProfileMax, | |||
| 194 | + | } | |||
| 195 | + | hw := &port.HardwareInfo{ | |||
| 196 | + | CPUCores: 4, | |||
| 197 | + | CPUThreads: 4, | |||
| 198 | + | TotalRAM: 4 * 1024 * 1024 * 1024, // 4 GB | |||
| 199 | + | VRAM: 2 * 1024 * 1024 * 1024, // 2 GB (integrated/old discrete) | |||
| 200 | + | GPUVendor: port.GPUVendorIntel, | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | result := ResolvePerformanceProfile(cfg, hw) | |||
| 204 | + | ||||
| 205 | + | // CPU threads: use physical cores (4) | |||
| 206 | + | if result.SkiaCPUPaintingThreads != 4 { | |||
| 207 | + | t.Errorf("expected SkiaCPUPaintingThreads=4, got %d", result.SkiaCPUPaintingThreads) | |||
| 208 | + | } | |||
| 209 | + | // GPU threads: low tier (<4GB VRAM) | |||
| 210 | + | if result.SkiaGPUPaintingThreads != 2 { | |||
| 211 | + | t.Errorf("expected SkiaGPUPaintingThreads=2 for 2GB VRAM, got %d", result.SkiaGPUPaintingThreads) | |||
| 212 | + | } | |||
| 213 | + | // Memory: low tier (<8GB RAM) | |||
| 214 | + | if result.WebProcessMemoryLimitMB != 1024 { | |||
| 215 | + | t.Errorf("expected WebProcessMemoryLimitMB=1024 for 4GB RAM, got %d", result.WebProcessMemoryLimitMB) | |||
| 216 | + | } | |||
| 217 | + | // Pool prewarm: low tier | |||
| 218 | + | if result.WebViewPoolPrewarmCount != 4 { | |||
| 219 | + | t.Errorf("expected WebViewPoolPrewarmCount=4 for 4GB RAM, got %d", result.WebViewPoolPrewarmCount) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func TestResolvePerformanceProfile_Custom(t *testing.T) { | |||
| 224 | + | cfg := &PerformanceConfig{ | |||
| 225 | + | Profile: ProfileCustom, | |||
| 226 | + | SkiaCPUPaintingThreads: 6, | |||
| 227 | + | SkiaGPUPaintingThreads: 3, | |||
| 228 | + | SkiaEnableCPURendering: true, | |||
| 229 | + | WebProcessMemoryLimitMB: 1024, | |||
| 230 | + | WebProcessMemoryConservativeThreshold: 0.4, | |||
| 231 | + | WebViewPoolPrewarmCount: 5, | |||
| 232 | + | } | |||
| 233 | + | // Hardware info should be ignored for custom profile | |||
| 234 | + | hw := &port.HardwareInfo{ | |||
| 235 | + | CPUCores: 16, | |||
| 236 | + | CPUThreads: 32, | |||
| 237 | + | TotalRAM: 64 * 1024 * 1024 * 1024, | |||
| 238 | + | VRAM: 16 * 1024 * 1024 * 1024, | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | result := ResolvePerformanceProfile(cfg, hw) | |||
| 242 | + | ||||
| 243 | + | // Custom should use user-specified values, ignoring hardware | |||
| 244 | + | if result.SkiaCPUPaintingThreads != 6 { | |||
| 245 | + | t.Errorf("expected SkiaCPUPaintingThreads=6, got %d", result.SkiaCPUPaintingThreads) | |||
| 246 | + | } | |||
| 247 | + | if result.SkiaGPUPaintingThreads != 3 { | |||
| 248 | + | t.Errorf("expected SkiaGPUPaintingThreads=3, got %d", result.SkiaGPUPaintingThreads) | |||
| 249 | + | } | |||
| 250 | + | if !result.SkiaEnableCPURendering { | |||
| 251 | + | t.Error("expected SkiaEnableCPURendering=true") | |||
| 252 | + | } | |||
| 253 | + | if result.WebProcessMemoryLimitMB != 1024 { | |||
| 254 | + | t.Errorf("expected WebProcessMemoryLimitMB=1024, got %d", result.WebProcessMemoryLimitMB) | |||
| 255 | + | } | |||
| 256 | + | if result.WebViewPoolPrewarmCount != 5 { | |||
| 257 | + | t.Errorf("expected WebViewPoolPrewarmCount=5, got %d", result.WebViewPoolPrewarmCount) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func TestHasCustomPerformanceFields(t *testing.T) { | |||
| 262 | + | tests := []struct { | |||
| 263 | + | name string | |||
| 264 | + | cfg PerformanceConfig | |||
| 265 | + | expected bool | |||
| 266 | + | }{ | |||
| 267 | + | { | |||
| 268 | + | name: "all defaults - no custom fields", | |||
| 269 | + | cfg: PerformanceConfig{ | |||
| 270 | + | SkiaCPUPaintingThreads: 0, | |||
| 271 | + | SkiaGPUPaintingThreads: -1, | |||
| 272 | + | SkiaEnableCPURendering: false, | |||
| 273 | + | WebProcessMemoryKillThreshold: -1, | |||
| 274 | + | NetworkProcessMemoryKillThreshold: -1, | |||
| 275 | + | }, | |||
| 276 | + | expected: false, | |||
| 277 | + | }, | |||
| 278 | + | { | |||
| 279 | + | name: "skia cpu threads set", | |||
| 280 | + | cfg: PerformanceConfig{ | |||
| 281 | + | SkiaCPUPaintingThreads: 4, | |||
| 282 | + | SkiaGPUPaintingThreads: -1, | |||
| 283 | + | WebProcessMemoryKillThreshold: -1, | |||
| 284 | + | NetworkProcessMemoryKillThreshold: -1, | |||
| 285 | + | }, | |||
| 286 | + | expected: true, | |||
| 287 | + | }, | |||
| 288 | + | { | |||
| 289 | + | name: "memory limit set", | |||
| 290 | + | cfg: PerformanceConfig{ | |||
| 291 | + | SkiaGPUPaintingThreads: -1, | |||
| 292 | + | WebProcessMemoryLimitMB: 1024, | |||
| 293 | + | WebProcessMemoryKillThreshold: -1, | |||
| 294 | + | NetworkProcessMemoryKillThreshold: -1, | |||
| 295 | + | }, | |||
| 296 | + | expected: true, | |||
| 297 | + | }, | |||
| 298 | + | { | |||
| 299 | + | name: "kill threshold changed from -1", | |||
| 300 | + | cfg: PerformanceConfig{ | |||
| 301 | + | SkiaGPUPaintingThreads: -1, | |||
| 302 | + | WebProcessMemoryKillThreshold: 0.9, | |||
| 303 | + | NetworkProcessMemoryKillThreshold: -1, | |||
| 304 | + | }, | |||
| 305 | + | expected: true, | |||
| 306 | + | }, | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | for _, tt := range tests { | |||
| 310 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 311 | + | result := HasCustomPerformanceFields(&tt.cfg) | |||
| 312 | + | if result != tt.expected { | |||
| 313 | + | t.Errorf("expected %v, got %v", tt.expected, result) | |||
| 314 | + | } | |||
| 315 | + | }) | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func TestIsValidPerformanceProfile(t *testing.T) { | |||
| 320 | + | tests := []struct { | |||
| 321 | + | profile PerformanceProfile | |||
| 322 | + | expected bool | |||
| 323 | + | }{ | |||
| 324 | + | {ProfileDefault, true}, | |||
| 325 | + | {ProfileLite, true}, | |||
| 326 | + | {ProfileMax, true}, | |||
| 327 | + | {ProfileCustom, true}, | |||
| 328 | + | {"", true}, // Empty is valid (treated as default) | |||
| 329 | + | {"invalid", false}, | |||
| 330 | + | {"LITE", false}, // Case sensitive | |||
| 331 | + | {"Maximum", false}, | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | for _, tt := range tests { | |||
| 335 | + | t.Run(string(tt.profile), func(t *testing.T) { | |||
| 336 | + | result := IsValidPerformanceProfile(tt.profile) | |||
| 337 | + | if result != tt.expected { | |||
| 338 | + | t.Errorf("IsValidPerformanceProfile(%q): expected %v, got %v", tt.profile, tt.expected, result) | |||
| 339 | + | } | |||
| 340 | + | }) | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func TestComputeMaxGPUThreads_VRAMTiers(t *testing.T) { | |||
| 345 | + | tests := []struct { | |||
| 346 | + | name string | |||
| 347 | + | vramGB int | |||
| 348 | + | expected int | |||
| 349 | + | }{ | |||
| 350 | + | {"no VRAM info", 0, 2}, | |||
| 351 | + | {"2GB VRAM (integrated)", 2, 2}, | |||
| 352 | + | {"4GB VRAM (entry discrete)", 4, 4}, | |||
| 353 | + | {"6GB VRAM (low-mid discrete)", 6, 4}, | |||
| 354 | + | {"8GB VRAM (mid discrete)", 8, 6}, | |||
| 355 | + | {"12GB VRAM (mid-high discrete)", 12, 6}, | |||
| 356 | + | {"16GB VRAM (high-end)", 16, 8}, | |||
| 357 | + | {"24GB VRAM (workstation)", 24, 8}, | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | for _, tt := range tests { | |||
| 361 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 362 | + | hw := &port.HardwareInfo{ | |||
| 363 | + | VRAM: uint64(tt.vramGB) * 1024 * 1024 * 1024, | |||
| 364 | + | } | |||
| 365 | + | if tt.vramGB == 0 { | |||
| 366 | + | hw = nil | |||
| 367 | + | } | |||
| 368 | + | result := computeMaxGPUThreads(hw) | |||
| 369 | + | if result != tt.expected { | |||
| 370 | + | t.Errorf("computeMaxGPUThreads(%dGB VRAM): expected %d, got %d", tt.vramGB, tt.expected, result) | |||
| 371 | + | } | |||
| 372 | + | }) | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func TestComputeMaxMemoryLimits_RAMTiers(t *testing.T) { | |||
| 377 | + | tests := []struct { | |||
| 378 | + | name string | |||
| 379 | + | ramGB int | |||
| 380 | + | expectedWeb int | |||
| 381 | + | expectedNet int | |||
| 382 | + | expectedPool int | |||
| 383 | + | }{ | |||
| 384 | + | {"no RAM info", 0, 2048, 512, 8}, | |||
| 385 | + | {"4GB RAM", 4, 1024, 256, 4}, | |||
| 386 | + | {"8GB RAM", 8, 2048, 512, 6}, | |||
| 387 | + | {"16GB RAM", 16, 3072, 768, 8}, | |||
| 388 | + | {"32GB RAM", 32, 4096, 1024, 12}, | |||
| 389 | + | {"64GB RAM", 64, 4096, 1024, 12}, | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | for _, tt := range tests { | |||
| 393 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 394 | + | hw := &port.HardwareInfo{ | |||
| 395 | + | TotalRAM: uint64(tt.ramGB) * 1024 * 1024 * 1024, | |||
| 396 | + | } | |||
| 397 | + | if tt.ramGB == 0 { | |||
| 398 | + | hw = nil | |||
| 399 | + | } | |||
| 400 | + | webMB, netMB := computeMaxMemoryLimits(hw) | |||
| 401 | + | poolPrewarm := computeMaxPoolPrewarm(hw) | |||
| 402 | + | ||||
| 403 | + | if webMB != tt.expectedWeb { | |||
| 404 | + | t.Errorf("computeMaxMemoryLimits(%dGB RAM) web: expected %d, got %d", tt.ramGB, tt.expectedWeb, webMB) | |||
| 405 | + | } | |||
| 406 | + | if netMB != tt.expectedNet { | |||
| 407 | + | t.Errorf("computeMaxMemoryLimits(%dGB RAM) net: expected %d, got %d", tt.ramGB, tt.expectedNet, netMB) | |||
| 408 | + | } | |||
| 409 | + | if poolPrewarm != tt.expectedPool { | |||
| 410 | + | t.Errorf("computeMaxPoolPrewarm(%dGB RAM): expected %d, got %d", tt.ramGB, tt.expectedPool, poolPrewarm) | |||
| 411 | + | } | |||
| 412 | + | }) | |||
| 413 | + | } | |||
| 414 | + | } | |||
M internal/infrastructure/config/schema.go +77 -1
| @@ -125,6 +125,20 @@ const ( | |||
| 125 | 125 | GLRenderingModeNone GLRenderingMode = "none" | |
| 126 | 126 | ) | |
| 127 | 127 | ||
| 128 | + | // PerformanceProfile selects preset performance tuning settings. | |
| 129 | + | type PerformanceProfile string | |
| 130 | + | ||
| 131 | + | const ( | |
| 132 | + | // ProfileDefault uses WebKit defaults (no tuning applied). | |
| 133 | + | ProfileDefault PerformanceProfile = "default" | |
| 134 | + | // ProfileLite reduces resource usage for low-RAM systems. | |
| 135 | + | ProfileLite PerformanceProfile = "lite" | |
| 136 | + | // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc). | |
| 137 | + | ProfileMax PerformanceProfile = "max" | |
| 138 | + | // ProfileCustom allows full control over individual performance settings. | |
| 139 | + | ProfileCustom PerformanceProfile = "custom" | |
| 140 | + | ) | |
| 141 | + | ||
| 128 | 142 | // ThemeDefault is the default theme setting (follows system). | |
| 129 | 143 | const ThemeDefault = "default" | |
| 130 | 144 | ||
| @@ -181,6 +195,12 @@ type HistoryConfig struct { | |||
| 181 | 195 | // PerformanceConfig holds internal performance tuning options. | |
| 182 | 196 | // These are not exposed in dumb://config UI but can be set in config file. | |
| 183 | 197 | type PerformanceConfig struct { | |
| 198 | + | // Profile selects a preset performance configuration. | |
| 199 | + | // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control) | |
| 200 | + | // When profile is not "custom", individual tuning fields below are ignored and computed from the profile. | |
| 201 | + | // Default: "default" | |
| 202 | + | Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"` | |
| 203 | + | ||
| 184 | 204 | // ZoomCacheSize is the number of domain zoom levels to cache in memory. | |
| 185 | 205 | // Higher values reduce database queries but use more memory. | |
| 186 | 206 | // Default: 256 domains (~20KB memory) | |
| @@ -188,8 +208,64 @@ type PerformanceConfig struct { | |||
| 188 | 208 | ||
| 189 | 209 | // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup. | |
| 190 | 210 | // Higher values speed up initial tab creation but use more memory. | |
| 191 | - | // Default: 4 | |
| 211 | + | // Default: 4 (custom mode), computed from profile otherwise | |
| 192 | 212 | WebViewPoolPrewarmCount int `mapstructure:"webview_pool_prewarm_count" yaml:"webview_pool_prewarm_count" toml:"webview_pool_prewarm_count"` //nolint:lll // struct tags must stay on one line | |
| 213 | + | ||
| 214 | + | // --- Skia rendering threads (env vars) --- | |
| 215 | + | // NOTE: Fields below only apply when Profile is "custom". | |
| 216 | + | // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS. | |
| 217 | + | // Default: 0 (unset, uses WebKit default) | |
| 218 | + | SkiaCPUPaintingThreads int `mapstructure:"skia_cpu_painting_threads" yaml:"skia_cpu_painting_threads" toml:"skia_cpu_painting_threads"` //nolint:lll // struct tags must stay on one line | |
| 219 | + | ||
| 220 | + | // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS. | |
| 221 | + | // Default: -1 (unset). Value 0 disables GPU tile painting. | |
| 222 | + | SkiaGPUPaintingThreads int `mapstructure:"skia_gpu_painting_threads" yaml:"skia_gpu_painting_threads" toml:"skia_gpu_painting_threads"` //nolint:lll // struct tags must stay on one line | |
| 223 | + | ||
| 224 | + | // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1. | |
| 225 | + | // Default: false | |
| 226 | + | SkiaEnableCPURendering bool `mapstructure:"skia_enable_cpu_rendering" yaml:"skia_enable_cpu_rendering" toml:"skia_enable_cpu_rendering"` //nolint:lll // struct tags must stay on one line | |
| 227 | + | ||
| 228 | + | // --- Web process memory pressure --- | |
| 229 | + | // WebProcessMemoryLimitMB sets memory limit in MB for web processes. | |
| 230 | + | // Default: 0 (unset, uses WebKit default: system RAM capped at 3GB) | |
| 231 | + | WebProcessMemoryLimitMB int `mapstructure:"web_process_memory_limit_mb" yaml:"web_process_memory_limit_mb" toml:"web_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line | |
| 232 | + | ||
| 233 | + | // WebProcessMemoryPollIntervalSec sets poll interval for memory checks. | |
| 234 | + | // Default: 0 (unset, uses WebKit default: 30 seconds) | |
| 235 | + | WebProcessMemoryPollIntervalSec float64 `mapstructure:"web_process_memory_poll_interval_sec" yaml:"web_process_memory_poll_interval_sec" toml:"web_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line | |
| 236 | + | ||
| 237 | + | // WebProcessMemoryConservativeThreshold sets threshold for conservative memory release. | |
| 238 | + | // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.33) | |
| 239 | + | WebProcessMemoryConservativeThreshold float64 `mapstructure:"web_process_memory_conservative_threshold" yaml:"web_process_memory_conservative_threshold" toml:"web_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line | |
| 240 | + | ||
| 241 | + | // WebProcessMemoryStrictThreshold sets threshold for strict memory release. | |
| 242 | + | // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.5) | |
| 243 | + | WebProcessMemoryStrictThreshold float64 `mapstructure:"web_process_memory_strict_threshold" yaml:"web_process_memory_strict_threshold" toml:"web_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line | |
| 244 | + | ||
| 245 | + | // WebProcessMemoryKillThreshold sets threshold for killing processes. | |
| 246 | + | // Default: -1 (unset). Value 0 means never kill. | |
| 247 | + | WebProcessMemoryKillThreshold float64 `mapstructure:"web_process_memory_kill_threshold" yaml:"web_process_memory_kill_threshold" toml:"web_process_memory_kill_threshold"` //nolint:lll // struct tags must stay on one line | |
| 248 | + | ||
| 249 | + | // --- Network process memory pressure --- | |
| 250 | + | // NetworkProcessMemoryLimitMB sets memory limit in MB for network process. | |
| 251 | + | // Default: 0 (unset) | |
| 252 | + | NetworkProcessMemoryLimitMB int `mapstructure:"network_process_memory_limit_mb" yaml:"network_process_memory_limit_mb" toml:"network_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line | |
| 253 | + | ||
| 254 | + | // NetworkProcessMemoryPollIntervalSec sets poll interval for memory checks. | |
| 255 | + | // Default: 0 (unset) | |
| 256 | + | NetworkProcessMemoryPollIntervalSec float64 `mapstructure:"network_process_memory_poll_interval_sec" yaml:"network_process_memory_poll_interval_sec" toml:"network_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line | |
| 257 | + | ||
| 258 | + | // NetworkProcessMemoryConservativeThreshold sets threshold for conservative memory release. | |
| 259 | + | // Valid: (0, 1). Default: 0 (unset) | |
| 260 | + | NetworkProcessMemoryConservativeThreshold float64 `mapstructure:"network_process_memory_conservative_threshold" yaml:"network_process_memory_conservative_threshold" toml:"network_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line | |
| 261 | + | ||
| 262 | + | // NetworkProcessMemoryStrictThreshold sets threshold for strict memory release. | |
| 263 | + | // Valid: (0, 1). Default: 0 (unset) | |
| 264 | + | NetworkProcessMemoryStrictThreshold float64 `mapstructure:"network_process_memory_strict_threshold" yaml:"network_process_memory_strict_threshold" toml:"network_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line | |
| 265 | + | ||
| 266 | + | // NetworkProcessMemoryKillThreshold sets threshold for killing network process. | |
| 267 | + | // Default: -1 (unset). Value 0 means never kill. | |
| 268 | + | NetworkProcessMemoryKillThreshold float64 `mapstructure:"network_process_memory_kill_threshold" yaml:"network_process_memory_kill_threshold" toml:"network_process_memory_kill_threshold"` //nolint:lll // struct tags must stay on one line | |
| 193 | 269 | } | |
| 194 | 270 | ||
| 195 | 271 | // SearchShortcut represents a search shortcut configuration. | |
| @@ -125,6 +125,20 @@ const ( | |||||
| 125 | GLRenderingModeNone GLRenderingMode = "none" | 125 | GLRenderingModeNone GLRenderingMode = "none" | ||
| 126 | ) | 126 | ) | ||
| 127 | 127 | ||||
| 128 | + | // PerformanceProfile selects preset performance tuning settings. | |||
| 129 | + | type PerformanceProfile string | |||
| 130 | + | ||||
| 131 | + | const ( | |||
| 132 | + | // ProfileDefault uses WebKit defaults (no tuning applied). | |||
| 133 | + | ProfileDefault PerformanceProfile = "default" | |||
| 134 | + | // ProfileLite reduces resource usage for low-RAM systems. | |||
| 135 | + | ProfileLite PerformanceProfile = "lite" | |||
| 136 | + | // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc). | |||
| 137 | + | ProfileMax PerformanceProfile = "max" | |||
| 138 | + | // ProfileCustom allows full control over individual performance settings. | |||
| 139 | + | ProfileCustom PerformanceProfile = "custom" | |||
| 140 | + | ) | |||
| 141 | + | ||||
| 128 | // ThemeDefault is the default theme setting (follows system). | 142 | // ThemeDefault is the default theme setting (follows system). | ||
| 129 | const ThemeDefault = "default" | 143 | const ThemeDefault = "default" | ||
| 130 | 144 | ||||
| @@ -181,6 +195,12 @@ type HistoryConfig struct { | |||||
| 181 | // PerformanceConfig holds internal performance tuning options. | 195 | // PerformanceConfig holds internal performance tuning options. | ||
| 182 | // These are not exposed in dumb://config UI but can be set in config file. | 196 | // These are not exposed in dumb://config UI but can be set in config file. | ||
| 183 | type PerformanceConfig struct { | 197 | type PerformanceConfig struct { | ||
| 198 | + | // Profile selects a preset performance configuration. | |||
| 199 | + | // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control) | |||
| 200 | + | // When profile is not "custom", individual tuning fields below are ignored and computed from the profile. | |||
| 201 | + | // Default: "default" | |||
| 202 | + | Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"` | |||
| 203 | + | ||||
| 184 | // ZoomCacheSize is the number of domain zoom levels to cache in memory. | 204 | // ZoomCacheSize is the number of domain zoom levels to cache in memory. | ||
| 185 | // Higher values reduce database queries but use more memory. | 205 | // Higher values reduce database queries but use more memory. | ||
| 186 | // Default: 256 domains (~20KB memory) | 206 | // Default: 256 domains (~20KB memory) | ||
| @@ -188,8 +208,64 @@ type PerformanceConfig struct { | |||||
| 188 | 208 | ||||
| 189 | // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup. | 209 | // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup. | ||
| 190 | // Higher values speed up initial tab creation but use more memory. | 210 | // Higher values speed up initial tab creation but use more memory. | ||
| 191 | - | // Default: 4 | 211 | + | // Default: 4 (custom mode), computed from profile otherwise |
| 192 | WebViewPoolPrewarmCount int `mapstructure:"webview_pool_prewarm_count" yaml:"webview_pool_prewarm_count" toml:"webview_pool_prewarm_count"` //nolint:lll // struct tags must stay on one line | 212 | WebViewPoolPrewarmCount int `mapstructure:"webview_pool_prewarm_count" yaml:"webview_pool_prewarm_count" toml:"webview_pool_prewarm_count"` //nolint:lll // struct tags must stay on one line | ||
| 213 | + | ||||
| 214 | + | // --- Skia rendering threads (env vars) --- | |||
| 215 | + | // NOTE: Fields below only apply when Profile is "custom". | |||
| 216 | + | // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS. | |||
| 217 | + | // Default: 0 (unset, uses WebKit default) | |||
| 218 | + | SkiaCPUPaintingThreads int `mapstructure:"skia_cpu_painting_threads" yaml:"skia_cpu_painting_threads" toml:"skia_cpu_painting_threads"` //nolint:lll // struct tags must stay on one line | |||
| 219 | + | ||||
| 220 | + | // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS. | |||
| 221 | + | // Default: -1 (unset). Value 0 disables GPU tile painting. | |||
| 222 | + | SkiaGPUPaintingThreads int `mapstructure:"skia_gpu_painting_threads" yaml:"skia_gpu_painting_threads" toml:"skia_gpu_painting_threads"` //nolint:lll // struct tags must stay on one line | |||
| 223 | + | ||||
| 224 | + | // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1. | |||
| 225 | + | // Default: false | |||
| 226 | + | SkiaEnableCPURendering bool `mapstructure:"skia_enable_cpu_rendering" yaml:"skia_enable_cpu_rendering" toml:"skia_enable_cpu_rendering"` //nolint:lll // struct tags must stay on one line | |||
| 227 | + | ||||
| 228 | + | // --- Web process memory pressure --- | |||
| 229 | + | // WebProcessMemoryLimitMB sets memory limit in MB for web processes. | |||
| 230 | + | // Default: 0 (unset, uses WebKit default: system RAM capped at 3GB) | |||
| 231 | + | WebProcessMemoryLimitMB int `mapstructure:"web_process_memory_limit_mb" yaml:"web_process_memory_limit_mb" toml:"web_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line | |||
| 232 | + | ||||
| 233 | + | // WebProcessMemoryPollIntervalSec sets poll interval for memory checks. | |||
| 234 | + | // Default: 0 (unset, uses WebKit default: 30 seconds) | |||
| 235 | + | WebProcessMemoryPollIntervalSec float64 `mapstructure:"web_process_memory_poll_interval_sec" yaml:"web_process_memory_poll_interval_sec" toml:"web_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line | |||
| 236 | + | ||||
| 237 | + | // WebProcessMemoryConservativeThreshold sets threshold for conservative memory release. | |||
| 238 | + | // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.33) | |||
| 239 | + | WebProcessMemoryConservativeThreshold float64 `mapstructure:"web_process_memory_conservative_threshold" yaml:"web_process_memory_conservative_threshold" toml:"web_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line | |||
| 240 | + | ||||
| 241 | + | // WebProcessMemoryStrictThreshold sets threshold for strict memory release. | |||
| 242 | + | // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.5) | |||
| 243 | + | WebProcessMemoryStrictThreshold float64 `mapstructure:"web_process_memory_strict_threshold" yaml:"web_process_memory_strict_threshold" toml:"web_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line | |||
| 244 | + | ||||
| 245 | + | // WebProcessMemoryKillThreshold sets threshold for killing processes. | |||
| 246 | + | // Default: -1 (unset). Value 0 means never kill. | |||
| 247 | + | WebProcessMemoryKillThreshold float64 `mapstructure:"web_process_memory_kill_threshold" yaml:"web_process_memory_kill_threshold" toml:"web_process_memory_kill_threshold"` //nolint:lll // struct tags must stay on one line | |||
| 248 | + | ||||
| 249 | + | // --- Network process memory pressure --- | |||
| 250 | + | // NetworkProcessMemoryLimitMB sets memory limit in MB for network process. | |||
| 251 | + | // Default: 0 (unset) | |||
| 252 | + | NetworkProcessMemoryLimitMB int `mapstructure:"network_process_memory_limit_mb" yaml:"network_process_memory_limit_mb" toml:"network_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line | |||
| 253 | + | ||||
| 254 | + | // NetworkProcessMemoryPollIntervalSec sets poll interval for memory checks. | |||
| 255 | + | // Default: 0 (unset) | |||
| 256 | + | NetworkProcessMemoryPollIntervalSec float64 `mapstructure:"network_process_memory_poll_interval_sec" yaml:"network_process_memory_poll_interval_sec" toml:"network_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line | |||
| 257 | + | ||||
| 258 | + | // NetworkProcessMemoryConservativeThreshold sets threshold for conservative memory release. | |||
| 259 | + | // Valid: (0, 1). Default: 0 (unset) | |||
| 260 | + | NetworkProcessMemoryConservativeThreshold float64 `mapstructure:"network_process_memory_conservative_threshold" yaml:"network_process_memory_conservative_threshold" toml:"network_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line | |||
| 261 | + | ||||
| 262 | + | // NetworkProcessMemoryStrictThreshold sets threshold for strict memory release. | |||
| 263 | + | // Valid: (0, 1). Default: 0 (unset) | |||
| 264 | + | NetworkProcessMemoryStrictThreshold float64 `mapstructure:"network_process_memory_strict_threshold" yaml:"network_process_memory_strict_threshold" toml:"network_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line | |||
| 265 | + | ||||
| 266 | + | // NetworkProcessMemoryKillThreshold sets threshold for killing network process. | |||
| 267 | + | // Default: -1 (unset). Value 0 means never kill. | |||
| 268 | + | NetworkProcessMemoryKillThreshold float64 `mapstructure:"network_process_memory_kill_threshold" yaml:"network_process_memory_kill_threshold" toml:"network_process_memory_kill_threshold"` //nolint:lll // struct tags must stay on one line | |||
| 193 | } | 269 | } | ||
| 194 | 270 | ||||
| 195 | // SearchShortcut represents a search shortcut configuration. | 271 | // SearchShortcut represents a search shortcut configuration. | ||
M internal/infrastructure/config/validation.go +45 -0
| @@ -28,6 +28,7 @@ func validateConfig(config *Config) error { | |||
| 28 | 28 | validationErrors = append(validationErrors, validateRendering(config)...) | |
| 29 | 29 | validationErrors = append(validationErrors, validateColorScheme(config)...) | |
| 30 | 30 | validationErrors = append(validationErrors, validateSession(config)...) | |
| 31 | + | validationErrors = append(validationErrors, validatePerformanceProfile(config)...) | |
| 31 | 32 | ||
| 32 | 33 | // If there are validation errors, return them | |
| 33 | 34 | if len(validationErrors) > 0 { | |
| @@ -347,3 +348,47 @@ func validateSession(config *Config) []string { | |||
| 347 | 348 | } | |
| 348 | 349 | return validationErrors | |
| 349 | 350 | } | |
| 351 | + | ||
| 352 | + | func validatePerformanceProfile(config *Config) []string { | |
| 353 | + | var validationErrors []string | |
| 354 | + | ||
| 355 | + | // Validate profile name | |
| 356 | + | if !IsValidPerformanceProfile(config.Performance.Profile) { | |
| 357 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 358 | + | "performance.profile must be one of: default, lite, max, custom (got: %s)", | |
| 359 | + | config.Performance.Profile, | |
| 360 | + | )) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // When profile is not "custom", warn if individual fields are set | |
| 364 | + | // (they will be ignored in favor of profile-computed values) | |
| 365 | + | if config.Performance.Profile != ProfileCustom && config.Performance.Profile != "" { | |
| 366 | + | if HasCustomPerformanceFields(&config.Performance) { | |
| 367 | + | validationErrors = append(validationErrors, | |
| 368 | + | "performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+ | |
| 369 | + | "set profile = \"custom\" to use individual field values", | |
| 370 | + | ) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // Validate memory pressure threshold ordering: WebKit requires conservative < strict | |
| 375 | + | webCons := config.Performance.WebProcessMemoryConservativeThreshold | |
| 376 | + | webStrict := config.Performance.WebProcessMemoryStrictThreshold | |
| 377 | + | if webCons > 0 && webStrict > 0 && webCons >= webStrict { | |
| 378 | + | validationErrors = append(validationErrors, | |
| 379 | + | "performance.web_process_memory_conservative_threshold must be less than "+ | |
| 380 | + | "web_process_memory_strict_threshold when both are set", | |
| 381 | + | ) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | netCons := config.Performance.NetworkProcessMemoryConservativeThreshold | |
| 385 | + | netStrict := config.Performance.NetworkProcessMemoryStrictThreshold | |
| 386 | + | if netCons > 0 && netStrict > 0 && netCons >= netStrict { | |
| 387 | + | validationErrors = append(validationErrors, | |
| 388 | + | "performance.network_process_memory_conservative_threshold must be less than "+ | |
| 389 | + | "network_process_memory_strict_threshold when both are set", | |
| 390 | + | ) | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | return validationErrors | |
| 394 | + | } | |
| @@ -28,6 +28,7 @@ func validateConfig(config *Config) error { | |||||
| 28 | validationErrors = append(validationErrors, validateRendering(config)...) | 28 | validationErrors = append(validationErrors, validateRendering(config)...) | ||
| 29 | validationErrors = append(validationErrors, validateColorScheme(config)...) | 29 | validationErrors = append(validationErrors, validateColorScheme(config)...) | ||
| 30 | validationErrors = append(validationErrors, validateSession(config)...) | 30 | validationErrors = append(validationErrors, validateSession(config)...) | ||
| 31 | + | validationErrors = append(validationErrors, validatePerformanceProfile(config)...) | |||
| 31 | 32 | ||||
| 32 | // If there are validation errors, return them | 33 | // If there are validation errors, return them | ||
| 33 | if len(validationErrors) > 0 { | 34 | if len(validationErrors) > 0 { | ||
| @@ -347,3 +348,47 @@ func validateSession(config *Config) []string { | |||||
| 347 | } | 348 | } | ||
| 348 | return validationErrors | 349 | return validationErrors | ||
| 349 | } | 350 | } | ||
| 351 | + | ||||
| 352 | + | func validatePerformanceProfile(config *Config) []string { | |||
| 353 | + | var validationErrors []string | |||
| 354 | + | ||||
| 355 | + | // Validate profile name | |||
| 356 | + | if !IsValidPerformanceProfile(config.Performance.Profile) { | |||
| 357 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |||
| 358 | + | "performance.profile must be one of: default, lite, max, custom (got: %s)", | |||
| 359 | + | config.Performance.Profile, | |||
| 360 | + | )) | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | // When profile is not "custom", warn if individual fields are set | |||
| 364 | + | // (they will be ignored in favor of profile-computed values) | |||
| 365 | + | if config.Performance.Profile != ProfileCustom && config.Performance.Profile != "" { | |||
| 366 | + | if HasCustomPerformanceFields(&config.Performance) { | |||
| 367 | + | validationErrors = append(validationErrors, | |||
| 368 | + | "performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+ | |||
| 369 | + | "set profile = \"custom\" to use individual field values", | |||
| 370 | + | ) | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // Validate memory pressure threshold ordering: WebKit requires conservative < strict | |||
| 375 | + | webCons := config.Performance.WebProcessMemoryConservativeThreshold | |||
| 376 | + | webStrict := config.Performance.WebProcessMemoryStrictThreshold | |||
| 377 | + | if webCons > 0 && webStrict > 0 && webCons >= webStrict { | |||
| 378 | + | validationErrors = append(validationErrors, | |||
| 379 | + | "performance.web_process_memory_conservative_threshold must be less than "+ | |||
| 380 | + | "web_process_memory_strict_threshold when both are set", | |||
| 381 | + | ) | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | netCons := config.Performance.NetworkProcessMemoryConservativeThreshold | |||
| 385 | + | netStrict := config.Performance.NetworkProcessMemoryStrictThreshold | |||
| 386 | + | if netCons > 0 && netStrict > 0 && netCons >= netStrict { | |||
| 387 | + | validationErrors = append(validationErrors, | |||
| 388 | + | "performance.network_process_memory_conservative_threshold must be less than "+ | |||
| 389 | + | "network_process_memory_strict_threshold when both are set", | |||
| 390 | + | ) | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | return validationErrors | |||
| 394 | + | } | |||
M internal/infrastructure/config/webui_gateway.go +32 -0
| @@ -15,6 +15,24 @@ func NewWebUIConfigGateway(mgr *Manager) *WebUIConfigGateway { | |||
| 15 | 15 | return &WebUIConfigGateway{mgr: mgr} | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | + | // Validation limits for custom performance fields. | |
| 19 | + | const ( | |
| 20 | + | maxWebProcessMemoryMB = 16384 | |
| 21 | + | maxNetworkProcessMemoryMB = 4096 | |
| 22 | + | maxWebViewPoolPrewarm = 20 | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // clampInt returns v clamped to [lo, hi]. | |
| 26 | + | func clampInt(v, lo, hi int) int { | |
| 27 | + | if v < lo { | |
| 28 | + | return lo | |
| 29 | + | } | |
| 30 | + | if v > hi { | |
| 31 | + | return hi | |
| 32 | + | } | |
| 33 | + | return v | |
| 34 | + | } | |
| 35 | + | ||
| 18 | 36 | func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error { | |
| 19 | 37 | _ = ctx | |
| 20 | 38 | if g == nil || g.mgr == nil { | |
| @@ -60,5 +78,19 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUI | |||
| 60 | 78 | current.SearchShortcuts = shortcuts | |
| 61 | 79 | } | |
| 62 | 80 | ||
| 81 | + | // Performance profile (requires restart to take effect) | |
| 82 | + | if cfg.Performance.Profile != "" { | |
| 83 | + | current.Performance.Profile = PerformanceProfile(cfg.Performance.Profile) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Custom performance fields (only used when profile is "custom") | |
| 87 | + | if cfg.Performance.Profile == string(ProfileCustom) { | |
| 88 | + | current.Performance.SkiaCPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaCPUThreads, 0, maxSkiaCPUThreads) | |
| 89 | + | current.Performance.SkiaGPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaGPUThreads, -1, maxSkiaGPUThreads) | |
| 90 | + | current.Performance.WebProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.WebProcessMemoryMB, 0, maxWebProcessMemoryMB) | |
| 91 | + | current.Performance.NetworkProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.NetworkProcessMemoryMB, 0, maxNetworkProcessMemoryMB) | |
| 92 | + | current.Performance.WebViewPoolPrewarmCount = clampInt(cfg.Performance.Custom.WebViewPoolPrewarm, 0, maxWebViewPoolPrewarm) | |
| 93 | + | } | |
| 94 | + | ||
| 63 | 95 | return g.mgr.Save(current) | |
| 64 | 96 | } | |
| @@ -15,6 +15,24 @@ func NewWebUIConfigGateway(mgr *Manager) *WebUIConfigGateway { | |||||
| 15 | return &WebUIConfigGateway{mgr: mgr} | 15 | return &WebUIConfigGateway{mgr: mgr} | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | + | // Validation limits for custom performance fields. | |||
| 19 | + | const ( | |||
| 20 | + | maxWebProcessMemoryMB = 16384 | |||
| 21 | + | maxNetworkProcessMemoryMB = 4096 | |||
| 22 | + | maxWebViewPoolPrewarm = 20 | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // clampInt returns v clamped to [lo, hi]. | |||
| 26 | + | func clampInt(v, lo, hi int) int { | |||
| 27 | + | if v < lo { | |||
| 28 | + | return lo | |||
| 29 | + | } | |||
| 30 | + | if v > hi { | |||
| 31 | + | return hi | |||
| 32 | + | } | |||
| 33 | + | return v | |||
| 34 | + | } | |||
| 35 | + | ||||
| 18 | func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error { | 36 | func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error { | ||
| 19 | _ = ctx | 37 | _ = ctx | ||
| 20 | if g == nil || g.mgr == nil { | 38 | if g == nil || g.mgr == nil { | ||
| @@ -60,5 +78,19 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUI | |||||
| 60 | current.SearchShortcuts = shortcuts | 78 | current.SearchShortcuts = shortcuts | ||
| 61 | } | 79 | } | ||
| 62 | 80 | ||||
| 81 | + | // Performance profile (requires restart to take effect) | |||
| 82 | + | if cfg.Performance.Profile != "" { | |||
| 83 | + | current.Performance.Profile = PerformanceProfile(cfg.Performance.Profile) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Custom performance fields (only used when profile is "custom") | |||
| 87 | + | if cfg.Performance.Profile == string(ProfileCustom) { | |||
| 88 | + | current.Performance.SkiaCPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaCPUThreads, 0, maxSkiaCPUThreads) | |||
| 89 | + | current.Performance.SkiaGPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaGPUThreads, -1, maxSkiaGPUThreads) | |||
| 90 | + | current.Performance.WebProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.WebProcessMemoryMB, 0, maxWebProcessMemoryMB) | |||
| 91 | + | current.Performance.NetworkProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.NetworkProcessMemoryMB, 0, maxNetworkProcessMemoryMB) | |||
| 92 | + | current.Performance.WebViewPoolPrewarmCount = clampInt(cfg.Performance.Custom.WebViewPoolPrewarm, 0, maxWebViewPoolPrewarm) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 63 | return g.mgr.Save(current) | 95 | return g.mgr.Save(current) | ||
| 64 | } | 96 | } | ||
A internal/infrastructure/env/hardware_survey.go +349 -0
| @@ -0,0 +1,349 @@ | |||
| 1 | + | package env | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/application/port" | |
| 14 | + | "github.com/bnema/dumber/internal/logging" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | // kibibyte is 1024 bytes (/proc/meminfo reports in kB which is actually KiB) | |
| 19 | + | kibibyte = 1024 | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // HardwareSurveyor implements port.HardwareSurveyor for Linux systems. | |
| 23 | + | // It is safe for concurrent use after creation. | |
| 24 | + | type HardwareSurveyor struct { | |
| 25 | + | once sync.Once | |
| 26 | + | cached port.HardwareInfo | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewHardwareSurveyor creates a new hardware surveyor. | |
| 30 | + | func NewHardwareSurveyor() *HardwareSurveyor { | |
| 31 | + | return &HardwareSurveyor{} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // Survey detects and returns hardware information. | |
| 35 | + | // Results are cached after the first call. Safe for concurrent use. | |
| 36 | + | func (s *HardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo { | |
| 37 | + | s.once.Do(func() { | |
| 38 | + | s.cached = s.doSurvey(ctx) | |
| 39 | + | }) | |
| 40 | + | return s.cached | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // doSurvey performs the actual hardware detection. | |
| 44 | + | func (s *HardwareSurveyor) doSurvey(ctx context.Context) port.HardwareInfo { | |
| 45 | + | log := logging.FromContext(ctx) | |
| 46 | + | ||
| 47 | + | info := port.HardwareInfo{ | |
| 48 | + | CPUThreads: runtime.NumCPU(), // Go's NumCPU returns logical threads | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Detect physical CPU cores | |
| 52 | + | info.CPUCores = s.detectCPUCores(ctx) | |
| 53 | + | if info.CPUCores == 0 { | |
| 54 | + | info.CPUCores = info.CPUThreads // Fallback to threads if cores unknown | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Detect RAM | |
| 58 | + | info.TotalRAM, info.AvailableRAM = s.detectMemory(ctx) | |
| 59 | + | ||
| 60 | + | // Detect GPU | |
| 61 | + | info.GPUVendor, info.GPUName, info.VRAM = s.detectGPU(ctx) | |
| 62 | + | ||
| 63 | + | log.Debug(). | |
| 64 | + | Int("cpu_cores", info.CPUCores). | |
| 65 | + | Int("cpu_threads", info.CPUThreads). | |
| 66 | + | Uint64("total_ram_mb", info.TotalRAM/(1024*1024)). | |
| 67 | + | Uint64("available_ram_mb", info.AvailableRAM/(1024*1024)). | |
| 68 | + | Str("gpu_vendor", string(info.GPUVendor)). | |
| 69 | + | Str("gpu_name", info.GPUName). | |
| 70 | + | Uint64("vram_mb", info.VRAM/(1024*1024)). | |
| 71 | + | Msg("hardware survey completed") | |
| 72 | + | ||
| 73 | + | return info | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // detectCPUCores returns the number of physical CPU cores. | |
| 77 | + | // Reads from /sys/devices/system/cpu/cpu*/topology/core_id to count unique cores. | |
| 78 | + | func (s *HardwareSurveyor) detectCPUCores(ctx context.Context) int { | |
| 79 | + | log := logging.FromContext(ctx) | |
| 80 | + | ||
| 81 | + | // Method 1: Count unique core_ids across all CPUs | |
| 82 | + | coreIDs := make(map[string]struct{}) | |
| 83 | + | cpuPath := "/sys/devices/system/cpu" | |
| 84 | + | ||
| 85 | + | entries, err := os.ReadDir(cpuPath) | |
| 86 | + | if err != nil { | |
| 87 | + | log.Debug().Err(err).Msg("cannot read /sys/devices/system/cpu") | |
| 88 | + | return 0 | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | for _, entry := range entries { | |
| 92 | + | if !strings.HasPrefix(entry.Name(), "cpu") { | |
| 93 | + | continue | |
| 94 | + | } | |
| 95 | + | // Skip non-numeric cpu entries (like cpufreq, cpuidle) | |
| 96 | + | cpuNum := strings.TrimPrefix(entry.Name(), "cpu") | |
| 97 | + | if _, err := strconv.Atoi(cpuNum); err != nil { | |
| 98 | + | continue | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | coreIDPath := filepath.Join(cpuPath, entry.Name(), "topology", "core_id") | |
| 102 | + | data, err := os.ReadFile(coreIDPath) | |
| 103 | + | if err != nil { | |
| 104 | + | continue | |
| 105 | + | } | |
| 106 | + | coreID := strings.TrimSpace(string(data)) | |
| 107 | + | coreIDs[coreID] = struct{}{} | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | if len(coreIDs) > 0 { | |
| 111 | + | return len(coreIDs) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Method 2: Parse /proc/cpuinfo for "cpu cores" field | |
| 115 | + | return s.detectCPUCoresFromProcCPUInfo(ctx) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // detectCPUCoresFromProcCPUInfo parses /proc/cpuinfo to get core count. | |
| 119 | + | func (*HardwareSurveyor) detectCPUCoresFromProcCPUInfo(ctx context.Context) int { | |
| 120 | + | log := logging.FromContext(ctx) | |
| 121 | + | ||
| 122 | + | file, err := os.Open("/proc/cpuinfo") | |
| 123 | + | if err != nil { | |
| 124 | + | log.Debug().Err(err).Msg("cannot read /proc/cpuinfo") | |
| 125 | + | return 0 | |
| 126 | + | } | |
| 127 | + | defer func() { _ = file.Close() }() | |
| 128 | + | ||
| 129 | + | scanner := bufio.NewScanner(file) | |
| 130 | + | for scanner.Scan() { | |
| 131 | + | line := scanner.Text() | |
| 132 | + | if strings.HasPrefix(line, "cpu cores") { | |
| 133 | + | parts := strings.Split(line, ":") | |
| 134 | + | if len(parts) == 2 { | |
| 135 | + | cores, err := strconv.Atoi(strings.TrimSpace(parts[1])) | |
| 136 | + | if err == nil { | |
| 137 | + | return cores | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | return 0 | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // detectMemory reads memory info from /proc/meminfo. | |
| 146 | + | func (s *HardwareSurveyor) detectMemory(ctx context.Context) (total, available uint64) { | |
| 147 | + | log := logging.FromContext(ctx) | |
| 148 | + | ||
| 149 | + | file, err := os.Open("/proc/meminfo") | |
| 150 | + | if err != nil { | |
| 151 | + | log.Debug().Err(err).Msg("cannot read /proc/meminfo") | |
| 152 | + | return 0, 0 | |
| 153 | + | } | |
| 154 | + | defer func() { _ = file.Close() }() | |
| 155 | + | ||
| 156 | + | scanner := bufio.NewScanner(file) | |
| 157 | + | for scanner.Scan() { | |
| 158 | + | line := scanner.Text() | |
| 159 | + | if strings.HasPrefix(line, "MemTotal:") { | |
| 160 | + | total = s.parseMemInfoLine(line) | |
| 161 | + | } else if strings.HasPrefix(line, "MemAvailable:") { | |
| 162 | + | available = s.parseMemInfoLine(line) | |
| 163 | + | } | |
| 164 | + | if total > 0 && available > 0 { | |
| 165 | + | break | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | return total, available | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // parseMemInfoLine extracts bytes from a /proc/meminfo line like "MemTotal: 12345 kB". | |
| 172 | + | func (*HardwareSurveyor) parseMemInfoLine(line string) uint64 { | |
| 173 | + | parts := strings.Fields(line) | |
| 174 | + | if len(parts) < 2 { | |
| 175 | + | return 0 | |
| 176 | + | } | |
| 177 | + | value, err := strconv.ParseUint(parts[1], 10, 64) | |
| 178 | + | if err != nil { | |
| 179 | + | return 0 | |
| 180 | + | } | |
| 181 | + | // /proc/meminfo reports in kB (actually KiB) | |
| 182 | + | return value * kibibyte | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // detectGPU detects GPU vendor, name, and VRAM from DRM subsystem. | |
| 186 | + | // When multiple GPUs are present (e.g., iGPU + discrete), prefers the one with more VRAM. | |
| 187 | + | func (s *HardwareSurveyor) detectGPU(ctx context.Context) (port.GPUVendor, string, uint64) { | |
| 188 | + | log := logging.FromContext(ctx) | |
| 189 | + | ||
| 190 | + | const drmBase = "/sys/class/drm" | |
| 191 | + | cards := []string{"card0", "card1", "card2", "card3"} | |
| 192 | + | ||
| 193 | + | var bestVendor port.GPUVendor | |
| 194 | + | var bestName string | |
| 195 | + | var bestVRAM uint64 | |
| 196 | + | var bestCard string | |
| 197 | + | ||
| 198 | + | for _, card := range cards { | |
| 199 | + | cardPath := filepath.Join(drmBase, card) | |
| 200 | + | if _, err := os.Stat(cardPath); os.IsNotExist(err) { | |
| 201 | + | continue | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | vendor := s.detectGPUVendor(cardPath) | |
| 205 | + | if vendor == port.GPUVendorUnknown { | |
| 206 | + | continue | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | name := s.detectGPUName(ctx, cardPath) | |
| 210 | + | vram := s.detectVRAM(ctx, cardPath) | |
| 211 | + | ||
| 212 | + | log.Debug(). | |
| 213 | + | Str("card", card). | |
| 214 | + | Str("vendor", string(vendor)). | |
| 215 | + | Str("name", name). | |
| 216 | + | Uint64("vram_mb", vram/(1024*1024)). | |
| 217 | + | Msg("found GPU") | |
| 218 | + | ||
| 219 | + | // Prefer GPU with more VRAM (discrete > integrated) | |
| 220 | + | if vram > bestVRAM { | |
| 221 | + | bestVendor = vendor | |
| 222 | + | bestName = name | |
| 223 | + | bestVRAM = vram | |
| 224 | + | bestCard = card | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | if bestVendor != port.GPUVendorUnknown { | |
| 229 | + | log.Debug(). | |
| 230 | + | Str("card", bestCard). | |
| 231 | + | Str("vendor", string(bestVendor)). | |
| 232 | + | Uint64("vram_mb", bestVRAM/(1024*1024)). | |
| 233 | + | Msg("selected GPU (highest VRAM)") | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | return bestVendor, bestName, bestVRAM | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // detectGPUVendor reads the PCI vendor ID. | |
| 240 | + | func (*HardwareSurveyor) detectGPUVendor(cardPath string) port.GPUVendor { | |
| 241 | + | vendorPath := filepath.Join(cardPath, "device", "vendor") | |
| 242 | + | data, err := os.ReadFile(vendorPath) | |
| 243 | + | if err != nil { | |
| 244 | + | return port.GPUVendorUnknown | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | vendorID := strings.TrimSpace(string(data)) | |
| 248 | + | switch vendorID { | |
| 249 | + | case "0x1002": | |
| 250 | + | return port.GPUVendorAMD | |
| 251 | + | case "0x8086": | |
| 252 | + | return port.GPUVendorIntel | |
| 253 | + | case "0x10de": | |
| 254 | + | return port.GPUVendorNVIDIA | |
| 255 | + | default: | |
| 256 | + | return port.GPUVendorUnknown | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // detectGPUName reads the GPU product name from various sources. | |
| 261 | + | func (*HardwareSurveyor) detectGPUName(ctx context.Context, cardPath string) string { | |
| 262 | + | log := logging.FromContext(ctx) | |
| 263 | + | ||
| 264 | + | // Try reading from device/label first (some drivers provide this) | |
| 265 | + | labelPath := filepath.Join(cardPath, "device", "label") | |
| 266 | + | if data, err := os.ReadFile(labelPath); err == nil { | |
| 267 | + | name := strings.TrimSpace(string(data)) | |
| 268 | + | if name != "" { | |
| 269 | + | return name | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // Try reading product name from device subsystem (uevent) | |
| 274 | + | ueventPath := filepath.Join(cardPath, "device", "uevent") | |
| 275 | + | if file, err := os.Open(ueventPath); err == nil { | |
| 276 | + | scanner := bufio.NewScanner(file) | |
| 277 | + | for scanner.Scan() { | |
| 278 | + | line := scanner.Text() | |
| 279 | + | if strings.HasPrefix(line, "PCI_SLOT_NAME=") { | |
| 280 | + | // This gives us the PCI address, useful for debugging | |
| 281 | + | log.Debug().Str("pci_slot", strings.TrimPrefix(line, "PCI_SLOT_NAME=")).Msg("GPU PCI slot") | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | _ = file.Close() | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // For AMD, try reading from /sys/kernel/debug/dri/*/name (requires root usually) | |
| 288 | + | // For now, return empty and rely on vendor detection | |
| 289 | + | return "" | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // detectVRAM reads VRAM size from DRM memory info. | |
| 293 | + | func (s *HardwareSurveyor) detectVRAM(ctx context.Context, cardPath string) uint64 { | |
| 294 | + | log := logging.FromContext(ctx) | |
| 295 | + | ||
| 296 | + | // Method 1: AMD uses mem_info_vram_total in device directory | |
| 297 | + | amdVRAMPath := filepath.Join(cardPath, "device", "mem_info_vram_total") | |
| 298 | + | if data, err := os.ReadFile(amdVRAMPath); err == nil { | |
| 299 | + | vram, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64) | |
| 300 | + | if err == nil && vram > 0 { | |
| 301 | + | log.Debug().Uint64("vram_bytes", vram).Msg("detected VRAM from AMD mem_info_vram_total") | |
| 302 | + | return vram | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // Method 2: Try reading from resource file (PCI BAR sizes) | |
| 307 | + | // The first non-zero memory region is usually VRAM | |
| 308 | + | if vram := s.detectVRAMFromPCIResource(ctx, cardPath); vram > 0 { | |
| 309 | + | return vram | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // Method 3: For Intel integrated, check i915 specific paths | |
| 313 | + | // Intel iGPU uses system RAM, so VRAM is usually 0 or shared | |
| 314 | + | return 0 | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // detectVRAMFromPCIResource tries to detect VRAM from PCI BAR sizes. | |
| 318 | + | func (*HardwareSurveyor) detectVRAMFromPCIResource(ctx context.Context, cardPath string) uint64 { | |
| 319 | + | log := logging.FromContext(ctx) | |
| 320 | + | ||
| 321 | + | resourcePath := filepath.Join(cardPath, "device", "resource") | |
| 322 | + | file, err := os.Open(resourcePath) | |
| 323 | + | if err != nil { | |
| 324 | + | return 0 | |
| 325 | + | } | |
| 326 | + | defer func() { _ = file.Close() }() | |
| 327 | + | ||
| 328 | + | const minVRAMSize = 256 * 1024 * 1024 // 256MB - skip smaller I/O regions | |
| 329 | + | scanner := bufio.NewScanner(file) | |
| 330 | + | for scanner.Scan() { | |
| 331 | + | line := scanner.Text() | |
| 332 | + | parts := strings.Fields(line) | |
| 333 | + | if len(parts) >= 2 { | |
| 334 | + | start, err1 := strconv.ParseUint(strings.TrimPrefix(parts[0], "0x"), 16, 64) | |
| 335 | + | end, err2 := strconv.ParseUint(strings.TrimPrefix(parts[1], "0x"), 16, 64) | |
| 336 | + | if err1 == nil && err2 == nil && start > 0 && end > start { | |
| 337 | + | size := end - start + 1 | |
| 338 | + | // VRAM is typically > 256MB, skip smaller regions (I/O, config) | |
| 339 | + | if size > minVRAMSize { | |
| 340 | + | log.Debug().Uint64("vram_bytes", size).Msg("detected VRAM from PCI resource") | |
| 341 | + | return size | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | return 0 | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | var _ port.HardwareSurveyor = (*HardwareSurveyor)(nil) | |
| @@ -0,0 +1,349 @@ | |||||
| 1 | + | package env | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "runtime" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 14 | + | "github.com/bnema/dumber/internal/logging" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | // kibibyte is 1024 bytes (/proc/meminfo reports in kB which is actually KiB) | |||
| 19 | + | kibibyte = 1024 | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // HardwareSurveyor implements port.HardwareSurveyor for Linux systems. | |||
| 23 | + | // It is safe for concurrent use after creation. | |||
| 24 | + | type HardwareSurveyor struct { | |||
| 25 | + | once sync.Once | |||
| 26 | + | cached port.HardwareInfo | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // NewHardwareSurveyor creates a new hardware surveyor. | |||
| 30 | + | func NewHardwareSurveyor() *HardwareSurveyor { | |||
| 31 | + | return &HardwareSurveyor{} | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // Survey detects and returns hardware information. | |||
| 35 | + | // Results are cached after the first call. Safe for concurrent use. | |||
| 36 | + | func (s *HardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo { | |||
| 37 | + | s.once.Do(func() { | |||
| 38 | + | s.cached = s.doSurvey(ctx) | |||
| 39 | + | }) | |||
| 40 | + | return s.cached | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // doSurvey performs the actual hardware detection. | |||
| 44 | + | func (s *HardwareSurveyor) doSurvey(ctx context.Context) port.HardwareInfo { | |||
| 45 | + | log := logging.FromContext(ctx) | |||
| 46 | + | ||||
| 47 | + | info := port.HardwareInfo{ | |||
| 48 | + | CPUThreads: runtime.NumCPU(), // Go's NumCPU returns logical threads | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Detect physical CPU cores | |||
| 52 | + | info.CPUCores = s.detectCPUCores(ctx) | |||
| 53 | + | if info.CPUCores == 0 { | |||
| 54 | + | info.CPUCores = info.CPUThreads // Fallback to threads if cores unknown | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Detect RAM | |||
| 58 | + | info.TotalRAM, info.AvailableRAM = s.detectMemory(ctx) | |||
| 59 | + | ||||
| 60 | + | // Detect GPU | |||
| 61 | + | info.GPUVendor, info.GPUName, info.VRAM = s.detectGPU(ctx) | |||
| 62 | + | ||||
| 63 | + | log.Debug(). | |||
| 64 | + | Int("cpu_cores", info.CPUCores). | |||
| 65 | + | Int("cpu_threads", info.CPUThreads). | |||
| 66 | + | Uint64("total_ram_mb", info.TotalRAM/(1024*1024)). | |||
| 67 | + | Uint64("available_ram_mb", info.AvailableRAM/(1024*1024)). | |||
| 68 | + | Str("gpu_vendor", string(info.GPUVendor)). | |||
| 69 | + | Str("gpu_name", info.GPUName). | |||
| 70 | + | Uint64("vram_mb", info.VRAM/(1024*1024)). | |||
| 71 | + | Msg("hardware survey completed") | |||
| 72 | + | ||||
| 73 | + | return info | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // detectCPUCores returns the number of physical CPU cores. | |||
| 77 | + | // Reads from /sys/devices/system/cpu/cpu*/topology/core_id to count unique cores. | |||
| 78 | + | func (s *HardwareSurveyor) detectCPUCores(ctx context.Context) int { | |||
| 79 | + | log := logging.FromContext(ctx) | |||
| 80 | + | ||||
| 81 | + | // Method 1: Count unique core_ids across all CPUs | |||
| 82 | + | coreIDs := make(map[string]struct{}) | |||
| 83 | + | cpuPath := "/sys/devices/system/cpu" | |||
| 84 | + | ||||
| 85 | + | entries, err := os.ReadDir(cpuPath) | |||
| 86 | + | if err != nil { | |||
| 87 | + | log.Debug().Err(err).Msg("cannot read /sys/devices/system/cpu") | |||
| 88 | + | return 0 | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | for _, entry := range entries { | |||
| 92 | + | if !strings.HasPrefix(entry.Name(), "cpu") { | |||
| 93 | + | continue | |||
| 94 | + | } | |||
| 95 | + | // Skip non-numeric cpu entries (like cpufreq, cpuidle) | |||
| 96 | + | cpuNum := strings.TrimPrefix(entry.Name(), "cpu") | |||
| 97 | + | if _, err := strconv.Atoi(cpuNum); err != nil { | |||
| 98 | + | continue | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | coreIDPath := filepath.Join(cpuPath, entry.Name(), "topology", "core_id") | |||
| 102 | + | data, err := os.ReadFile(coreIDPath) | |||
| 103 | + | if err != nil { | |||
| 104 | + | continue | |||
| 105 | + | } | |||
| 106 | + | coreID := strings.TrimSpace(string(data)) | |||
| 107 | + | coreIDs[coreID] = struct{}{} | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | if len(coreIDs) > 0 { | |||
| 111 | + | return len(coreIDs) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Method 2: Parse /proc/cpuinfo for "cpu cores" field | |||
| 115 | + | return s.detectCPUCoresFromProcCPUInfo(ctx) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // detectCPUCoresFromProcCPUInfo parses /proc/cpuinfo to get core count. | |||
| 119 | + | func (*HardwareSurveyor) detectCPUCoresFromProcCPUInfo(ctx context.Context) int { | |||
| 120 | + | log := logging.FromContext(ctx) | |||
| 121 | + | ||||
| 122 | + | file, err := os.Open("/proc/cpuinfo") | |||
| 123 | + | if err != nil { | |||
| 124 | + | log.Debug().Err(err).Msg("cannot read /proc/cpuinfo") | |||
| 125 | + | return 0 | |||
| 126 | + | } | |||
| 127 | + | defer func() { _ = file.Close() }() | |||
| 128 | + | ||||
| 129 | + | scanner := bufio.NewScanner(file) | |||
| 130 | + | for scanner.Scan() { | |||
| 131 | + | line := scanner.Text() | |||
| 132 | + | if strings.HasPrefix(line, "cpu cores") { | |||
| 133 | + | parts := strings.Split(line, ":") | |||
| 134 | + | if len(parts) == 2 { | |||
| 135 | + | cores, err := strconv.Atoi(strings.TrimSpace(parts[1])) | |||
| 136 | + | if err == nil { | |||
| 137 | + | return cores | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | return 0 | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // detectMemory reads memory info from /proc/meminfo. | |||
| 146 | + | func (s *HardwareSurveyor) detectMemory(ctx context.Context) (total, available uint64) { | |||
| 147 | + | log := logging.FromContext(ctx) | |||
| 148 | + | ||||
| 149 | + | file, err := os.Open("/proc/meminfo") | |||
| 150 | + | if err != nil { | |||
| 151 | + | log.Debug().Err(err).Msg("cannot read /proc/meminfo") | |||
| 152 | + | return 0, 0 | |||
| 153 | + | } | |||
| 154 | + | defer func() { _ = file.Close() }() | |||
| 155 | + | ||||
| 156 | + | scanner := bufio.NewScanner(file) | |||
| 157 | + | for scanner.Scan() { | |||
| 158 | + | line := scanner.Text() | |||
| 159 | + | if strings.HasPrefix(line, "MemTotal:") { | |||
| 160 | + | total = s.parseMemInfoLine(line) | |||
| 161 | + | } else if strings.HasPrefix(line, "MemAvailable:") { | |||
| 162 | + | available = s.parseMemInfoLine(line) | |||
| 163 | + | } | |||
| 164 | + | if total > 0 && available > 0 { | |||
| 165 | + | break | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | return total, available | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // parseMemInfoLine extracts bytes from a /proc/meminfo line like "MemTotal: 12345 kB". | |||
| 172 | + | func (*HardwareSurveyor) parseMemInfoLine(line string) uint64 { | |||
| 173 | + | parts := strings.Fields(line) | |||
| 174 | + | if len(parts) < 2 { | |||
| 175 | + | return 0 | |||
| 176 | + | } | |||
| 177 | + | value, err := strconv.ParseUint(parts[1], 10, 64) | |||
| 178 | + | if err != nil { | |||
| 179 | + | return 0 | |||
| 180 | + | } | |||
| 181 | + | // /proc/meminfo reports in kB (actually KiB) | |||
| 182 | + | return value * kibibyte | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // detectGPU detects GPU vendor, name, and VRAM from DRM subsystem. | |||
| 186 | + | // When multiple GPUs are present (e.g., iGPU + discrete), prefers the one with more VRAM. | |||
| 187 | + | func (s *HardwareSurveyor) detectGPU(ctx context.Context) (port.GPUVendor, string, uint64) { | |||
| 188 | + | log := logging.FromContext(ctx) | |||
| 189 | + | ||||
| 190 | + | const drmBase = "/sys/class/drm" | |||
| 191 | + | cards := []string{"card0", "card1", "card2", "card3"} | |||
| 192 | + | ||||
| 193 | + | var bestVendor port.GPUVendor | |||
| 194 | + | var bestName string | |||
| 195 | + | var bestVRAM uint64 | |||
| 196 | + | var bestCard string | |||
| 197 | + | ||||
| 198 | + | for _, card := range cards { | |||
| 199 | + | cardPath := filepath.Join(drmBase, card) | |||
| 200 | + | if _, err := os.Stat(cardPath); os.IsNotExist(err) { | |||
| 201 | + | continue | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | vendor := s.detectGPUVendor(cardPath) | |||
| 205 | + | if vendor == port.GPUVendorUnknown { | |||
| 206 | + | continue | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | name := s.detectGPUName(ctx, cardPath) | |||
| 210 | + | vram := s.detectVRAM(ctx, cardPath) | |||
| 211 | + | ||||
| 212 | + | log.Debug(). | |||
| 213 | + | Str("card", card). | |||
| 214 | + | Str("vendor", string(vendor)). | |||
| 215 | + | Str("name", name). | |||
| 216 | + | Uint64("vram_mb", vram/(1024*1024)). | |||
| 217 | + | Msg("found GPU") | |||
| 218 | + | ||||
| 219 | + | // Prefer GPU with more VRAM (discrete > integrated) | |||
| 220 | + | if vram > bestVRAM { | |||
| 221 | + | bestVendor = vendor | |||
| 222 | + | bestName = name | |||
| 223 | + | bestVRAM = vram | |||
| 224 | + | bestCard = card | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | if bestVendor != port.GPUVendorUnknown { | |||
| 229 | + | log.Debug(). | |||
| 230 | + | Str("card", bestCard). | |||
| 231 | + | Str("vendor", string(bestVendor)). | |||
| 232 | + | Uint64("vram_mb", bestVRAM/(1024*1024)). | |||
| 233 | + | Msg("selected GPU (highest VRAM)") | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | return bestVendor, bestName, bestVRAM | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // detectGPUVendor reads the PCI vendor ID. | |||
| 240 | + | func (*HardwareSurveyor) detectGPUVendor(cardPath string) port.GPUVendor { | |||
| 241 | + | vendorPath := filepath.Join(cardPath, "device", "vendor") | |||
| 242 | + | data, err := os.ReadFile(vendorPath) | |||
| 243 | + | if err != nil { | |||
| 244 | + | return port.GPUVendorUnknown | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | vendorID := strings.TrimSpace(string(data)) | |||
| 248 | + | switch vendorID { | |||
| 249 | + | case "0x1002": | |||
| 250 | + | return port.GPUVendorAMD | |||
| 251 | + | case "0x8086": | |||
| 252 | + | return port.GPUVendorIntel | |||
| 253 | + | case "0x10de": | |||
| 254 | + | return port.GPUVendorNVIDIA | |||
| 255 | + | default: | |||
| 256 | + | return port.GPUVendorUnknown | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // detectGPUName reads the GPU product name from various sources. | |||
| 261 | + | func (*HardwareSurveyor) detectGPUName(ctx context.Context, cardPath string) string { | |||
| 262 | + | log := logging.FromContext(ctx) | |||
| 263 | + | ||||
| 264 | + | // Try reading from device/label first (some drivers provide this) | |||
| 265 | + | labelPath := filepath.Join(cardPath, "device", "label") | |||
| 266 | + | if data, err := os.ReadFile(labelPath); err == nil { | |||
| 267 | + | name := strings.TrimSpace(string(data)) | |||
| 268 | + | if name != "" { | |||
| 269 | + | return name | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // Try reading product name from device subsystem (uevent) | |||
| 274 | + | ueventPath := filepath.Join(cardPath, "device", "uevent") | |||
| 275 | + | if file, err := os.Open(ueventPath); err == nil { | |||
| 276 | + | scanner := bufio.NewScanner(file) | |||
| 277 | + | for scanner.Scan() { | |||
| 278 | + | line := scanner.Text() | |||
| 279 | + | if strings.HasPrefix(line, "PCI_SLOT_NAME=") { | |||
| 280 | + | // This gives us the PCI address, useful for debugging | |||
| 281 | + | log.Debug().Str("pci_slot", strings.TrimPrefix(line, "PCI_SLOT_NAME=")).Msg("GPU PCI slot") | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | _ = file.Close() | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // For AMD, try reading from /sys/kernel/debug/dri/*/name (requires root usually) | |||
| 288 | + | // For now, return empty and rely on vendor detection | |||
| 289 | + | return "" | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // detectVRAM reads VRAM size from DRM memory info. | |||
| 293 | + | func (s *HardwareSurveyor) detectVRAM(ctx context.Context, cardPath string) uint64 { | |||
| 294 | + | log := logging.FromContext(ctx) | |||
| 295 | + | ||||
| 296 | + | // Method 1: AMD uses mem_info_vram_total in device directory | |||
| 297 | + | amdVRAMPath := filepath.Join(cardPath, "device", "mem_info_vram_total") | |||
| 298 | + | if data, err := os.ReadFile(amdVRAMPath); err == nil { | |||
| 299 | + | vram, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64) | |||
| 300 | + | if err == nil && vram > 0 { | |||
| 301 | + | log.Debug().Uint64("vram_bytes", vram).Msg("detected VRAM from AMD mem_info_vram_total") | |||
| 302 | + | return vram | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Method 2: Try reading from resource file (PCI BAR sizes) | |||
| 307 | + | // The first non-zero memory region is usually VRAM | |||
| 308 | + | if vram := s.detectVRAMFromPCIResource(ctx, cardPath); vram > 0 { | |||
| 309 | + | return vram | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // Method 3: For Intel integrated, check i915 specific paths | |||
| 313 | + | // Intel iGPU uses system RAM, so VRAM is usually 0 or shared | |||
| 314 | + | return 0 | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // detectVRAMFromPCIResource tries to detect VRAM from PCI BAR sizes. | |||
| 318 | + | func (*HardwareSurveyor) detectVRAMFromPCIResource(ctx context.Context, cardPath string) uint64 { | |||
| 319 | + | log := logging.FromContext(ctx) | |||
| 320 | + | ||||
| 321 | + | resourcePath := filepath.Join(cardPath, "device", "resource") | |||
| 322 | + | file, err := os.Open(resourcePath) | |||
| 323 | + | if err != nil { | |||
| 324 | + | return 0 | |||
| 325 | + | } | |||
| 326 | + | defer func() { _ = file.Close() }() | |||
| 327 | + | ||||
| 328 | + | const minVRAMSize = 256 * 1024 * 1024 // 256MB - skip smaller I/O regions | |||
| 329 | + | scanner := bufio.NewScanner(file) | |||
| 330 | + | for scanner.Scan() { | |||
| 331 | + | line := scanner.Text() | |||
| 332 | + | parts := strings.Fields(line) | |||
| 333 | + | if len(parts) >= 2 { | |||
| 334 | + | start, err1 := strconv.ParseUint(strings.TrimPrefix(parts[0], "0x"), 16, 64) | |||
| 335 | + | end, err2 := strconv.ParseUint(strings.TrimPrefix(parts[1], "0x"), 16, 64) | |||
| 336 | + | if err1 == nil && err2 == nil && start > 0 && end > start { | |||
| 337 | + | size := end - start + 1 | |||
| 338 | + | // VRAM is typically > 256MB, skip smaller regions (I/O, config) | |||
| 339 | + | if size > minVRAMSize { | |||
| 340 | + | log.Debug().Uint64("vram_bytes", size).Msg("detected VRAM from PCI resource") | |||
| 341 | + | return size | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | return 0 | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | var _ port.HardwareSurveyor = (*HardwareSurveyor)(nil) | |||
M internal/infrastructure/env/manager.go +18 -0
| @@ -54,6 +54,7 @@ func (m *Manager) ApplyEnvironment(ctx context.Context, settings port.RenderingE | |||
| 54 | 54 | m.applyWebKitEnv(settings) | |
| 55 | 55 | m.applyGTKEnv(settings) | |
| 56 | 56 | m.applyDebugEnv(settings) | |
| 57 | + | m.applySkiaEnv(settings) | |
| 57 | 58 | ||
| 58 | 59 | log.Debug(). | |
| 59 | 60 | Interface("vars", m.appliedVars). | |
| @@ -162,6 +163,23 @@ func (m *Manager) applyDebugEnv(settings port.RenderingEnvSettings) { | |||
| 162 | 163 | } | |
| 163 | 164 | } | |
| 164 | 165 | ||
| 166 | + | func (m *Manager) applySkiaEnv(settings port.RenderingEnvSettings) { | |
| 167 | + | // CPU painting threads: only set if > 0 and env var not already set | |
| 168 | + | if settings.SkiaCPUPaintingThreads > 0 && os.Getenv("WEBKIT_SKIA_CPU_PAINTING_THREADS") == "" { | |
| 169 | + | m.setEnv("WEBKIT_SKIA_CPU_PAINTING_THREADS", fmt.Sprintf("%d", settings.SkiaCPUPaintingThreads)) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // GPU painting threads: only set if >= 0 (0 is valid: disables GPU tile painting) | |
| 173 | + | if settings.SkiaGPUPaintingThreads >= 0 && os.Getenv("WEBKIT_SKIA_GPU_PAINTING_THREADS") == "" { | |
| 174 | + | m.setEnv("WEBKIT_SKIA_GPU_PAINTING_THREADS", fmt.Sprintf("%d", settings.SkiaGPUPaintingThreads)) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // CPU rendering: only set if true and env var not already set | |
| 178 | + | if settings.SkiaEnableCPURendering && os.Getenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING") == "" { | |
| 179 | + | m.setEnv("WEBKIT_SKIA_ENABLE_CPU_RENDERING", "1") | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 165 | 183 | func (m *Manager) setEnv(key, value string) { | |
| 166 | 184 | _ = os.Setenv(key, value) | |
| 167 | 185 | m.appliedVars[key] = value | |
| @@ -54,6 +54,7 @@ func (m *Manager) ApplyEnvironment(ctx context.Context, settings port.RenderingE | |||||
| 54 | m.applyWebKitEnv(settings) | 54 | m.applyWebKitEnv(settings) | ||
| 55 | m.applyGTKEnv(settings) | 55 | m.applyGTKEnv(settings) | ||
| 56 | m.applyDebugEnv(settings) | 56 | m.applyDebugEnv(settings) | ||
| 57 | + | m.applySkiaEnv(settings) | |||
| 57 | 58 | ||||
| 58 | log.Debug(). | 59 | log.Debug(). | ||
| 59 | Interface("vars", m.appliedVars). | 60 | Interface("vars", m.appliedVars). | ||
| @@ -162,6 +163,23 @@ func (m *Manager) applyDebugEnv(settings port.RenderingEnvSettings) { | |||||
| 162 | } | 163 | } | ||
| 163 | } | 164 | } | ||
| 164 | 165 | ||||
| 166 | + | func (m *Manager) applySkiaEnv(settings port.RenderingEnvSettings) { | |||
| 167 | + | // CPU painting threads: only set if > 0 and env var not already set | |||
| 168 | + | if settings.SkiaCPUPaintingThreads > 0 && os.Getenv("WEBKIT_SKIA_CPU_PAINTING_THREADS") == "" { | |||
| 169 | + | m.setEnv("WEBKIT_SKIA_CPU_PAINTING_THREADS", fmt.Sprintf("%d", settings.SkiaCPUPaintingThreads)) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // GPU painting threads: only set if >= 0 (0 is valid: disables GPU tile painting) | |||
| 173 | + | if settings.SkiaGPUPaintingThreads >= 0 && os.Getenv("WEBKIT_SKIA_GPU_PAINTING_THREADS") == "" { | |||
| 174 | + | m.setEnv("WEBKIT_SKIA_GPU_PAINTING_THREADS", fmt.Sprintf("%d", settings.SkiaGPUPaintingThreads)) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // CPU rendering: only set if true and env var not already set | |||
| 178 | + | if settings.SkiaEnableCPURendering && os.Getenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING") == "" { | |||
| 179 | + | m.setEnv("WEBKIT_SKIA_ENABLE_CPU_RENDERING", "1") | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 165 | func (m *Manager) setEnv(key, value string) { | 183 | func (m *Manager) setEnv(key, value string) { | ||
| 166 | _ = os.Setenv(key, value) | 184 | _ = os.Setenv(key, value) | ||
| 167 | m.appliedVars[key] = value | 185 | m.appliedVars[key] = value | ||
A internal/infrastructure/env/manager_test.go +172 -0
| @@ -0,0 +1,172 @@ | |||
| 1 | + | package env_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |
| 15 | + | // Save original env vars and restore after test | |
| 16 | + | origCPU := os.Getenv("WEBKIT_SKIA_CPU_PAINTING_THREADS") | |
| 17 | + | origGPU := os.Getenv("WEBKIT_SKIA_GPU_PAINTING_THREADS") | |
| 18 | + | origCPURender := os.Getenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING") | |
| 19 | + | t.Cleanup(func() { | |
| 20 | + | restoreEnv("WEBKIT_SKIA_CPU_PAINTING_THREADS", origCPU) | |
| 21 | + | restoreEnv("WEBKIT_SKIA_GPU_PAINTING_THREADS", origGPU) | |
| 22 | + | restoreEnv("WEBKIT_SKIA_ENABLE_CPU_RENDERING", origCPURender) | |
| 23 | + | }) | |
| 24 | + | ||
| 25 | + | tests := []struct { | |
| 26 | + | name string | |
| 27 | + | settings port.RenderingEnvSettings | |
| 28 | + | presetEnv map[string]string | |
| 29 | + | expectedVars map[string]string | |
| 30 | + | unexpectedVars []string | |
| 31 | + | }{ | |
| 32 | + | { | |
| 33 | + | name: "CPU threads set when > 0", | |
| 34 | + | settings: port.RenderingEnvSettings{ | |
| 35 | + | SkiaCPUPaintingThreads: 4, | |
| 36 | + | SkiaGPUPaintingThreads: -1, // unset | |
| 37 | + | }, | |
| 38 | + | expectedVars: map[string]string{ | |
| 39 | + | "WEBKIT_SKIA_CPU_PAINTING_THREADS": "4", | |
| 40 | + | }, | |
| 41 | + | unexpectedVars: []string{"WEBKIT_SKIA_GPU_PAINTING_THREADS"}, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "GPU threads set when >= 0", | |
| 45 | + | settings: port.RenderingEnvSettings{ | |
| 46 | + | SkiaCPUPaintingThreads: 0, // unset | |
| 47 | + | SkiaGPUPaintingThreads: 2, | |
| 48 | + | }, | |
| 49 | + | expectedVars: map[string]string{ | |
| 50 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS": "2", | |
| 51 | + | }, | |
| 52 | + | unexpectedVars: []string{"WEBKIT_SKIA_CPU_PAINTING_THREADS"}, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "GPU threads 0 disables GPU tile painting", | |
| 56 | + | settings: port.RenderingEnvSettings{ | |
| 57 | + | SkiaGPUPaintingThreads: 0, | |
| 58 | + | }, | |
| 59 | + | expectedVars: map[string]string{ | |
| 60 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS": "0", | |
| 61 | + | }, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | name: "CPU rendering enabled", | |
| 65 | + | settings: port.RenderingEnvSettings{ | |
| 66 | + | SkiaEnableCPURendering: true, | |
| 67 | + | }, | |
| 68 | + | expectedVars: map[string]string{ | |
| 69 | + | "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "1", | |
| 70 | + | }, | |
| 71 | + | }, | |
| 72 | + | { | |
| 73 | + | name: "CPU rendering disabled (default)", | |
| 74 | + | settings: port.RenderingEnvSettings{ | |
| 75 | + | SkiaEnableCPURendering: false, | |
| 76 | + | }, | |
| 77 | + | unexpectedVars: []string{"WEBKIT_SKIA_ENABLE_CPU_RENDERING"}, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "does not override existing CPU threads env var", | |
| 81 | + | settings: port.RenderingEnvSettings{ | |
| 82 | + | SkiaCPUPaintingThreads: 8, | |
| 83 | + | }, | |
| 84 | + | presetEnv: map[string]string{ | |
| 85 | + | "WEBKIT_SKIA_CPU_PAINTING_THREADS": "2", | |
| 86 | + | }, | |
| 87 | + | expectedVars: map[string]string{ | |
| 88 | + | "WEBKIT_SKIA_CPU_PAINTING_THREADS": "2", // keeps original | |
| 89 | + | }, | |
| 90 | + | }, | |
| 91 | + | { | |
| 92 | + | name: "does not override existing GPU threads env var", | |
| 93 | + | settings: port.RenderingEnvSettings{ | |
| 94 | + | SkiaGPUPaintingThreads: 4, | |
| 95 | + | }, | |
| 96 | + | presetEnv: map[string]string{ | |
| 97 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS": "1", | |
| 98 | + | }, | |
| 99 | + | expectedVars: map[string]string{ | |
| 100 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS": "1", // keeps original | |
| 101 | + | }, | |
| 102 | + | }, | |
| 103 | + | { | |
| 104 | + | name: "does not override existing CPU rendering env var", | |
| 105 | + | settings: port.RenderingEnvSettings{ | |
| 106 | + | SkiaEnableCPURendering: true, | |
| 107 | + | }, | |
| 108 | + | presetEnv: map[string]string{ | |
| 109 | + | "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "0", | |
| 110 | + | }, | |
| 111 | + | expectedVars: map[string]string{ | |
| 112 | + | "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "0", // keeps original | |
| 113 | + | }, | |
| 114 | + | }, | |
| 115 | + | { | |
| 116 | + | name: "all unset by default", | |
| 117 | + | settings: port.RenderingEnvSettings{ | |
| 118 | + | SkiaCPUPaintingThreads: 0, // unset | |
| 119 | + | SkiaGPUPaintingThreads: -1, // unset | |
| 120 | + | SkiaEnableCPURendering: false, | |
| 121 | + | }, | |
| 122 | + | unexpectedVars: []string{ | |
| 123 | + | "WEBKIT_SKIA_CPU_PAINTING_THREADS", | |
| 124 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS", | |
| 125 | + | "WEBKIT_SKIA_ENABLE_CPU_RENDERING", | |
| 126 | + | }, | |
| 127 | + | }, | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | for _, tt := range tests { | |
| 131 | + | t.Run(tt.name, func(t *testing.T) { | |
| 132 | + | // Clear env vars before each test | |
| 133 | + | os.Unsetenv("WEBKIT_SKIA_CPU_PAINTING_THREADS") | |
| 134 | + | os.Unsetenv("WEBKIT_SKIA_GPU_PAINTING_THREADS") | |
| 135 | + | os.Unsetenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING") | |
| 136 | + | ||
| 137 | + | // Set any preset env vars | |
| 138 | + | for k, v := range tt.presetEnv { | |
| 139 | + | os.Setenv(k, v) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | manager := env.NewManager() | |
| 143 | + | ctx := context.Background() | |
| 144 | + | ||
| 145 | + | err := manager.ApplyEnvironment(ctx, tt.settings) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | ||
| 148 | + | // Check expected vars are set correctly | |
| 149 | + | for k, v := range tt.expectedVars { | |
| 150 | + | actual := os.Getenv(k) | |
| 151 | + | assert.Equal(t, v, actual, "env var %s", k) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Check unexpected vars are not set (or were not set by us) | |
| 155 | + | appliedVars := manager.GetAppliedVars() | |
| 156 | + | for _, k := range tt.unexpectedVars { | |
| 157 | + | _, wasApplied := appliedVars[k] | |
| 158 | + | if tt.presetEnv[k] == "" { | |
| 159 | + | assert.False(t, wasApplied, "env var %s should not be applied", k) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | }) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func restoreEnv(key, value string) { | |
| 167 | + | if value == "" { | |
| 168 | + | os.Unsetenv(key) | |
| 169 | + | } else { | |
| 170 | + | os.Setenv(key, value) | |
| 171 | + | } | |
| 172 | + | } | |
| @@ -0,0 +1,172 @@ | |||||
| 1 | + | package env_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/env" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |||
| 15 | + | // Save original env vars and restore after test | |||
| 16 | + | origCPU := os.Getenv("WEBKIT_SKIA_CPU_PAINTING_THREADS") | |||
| 17 | + | origGPU := os.Getenv("WEBKIT_SKIA_GPU_PAINTING_THREADS") | |||
| 18 | + | origCPURender := os.Getenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING") | |||
| 19 | + | t.Cleanup(func() { | |||
| 20 | + | restoreEnv("WEBKIT_SKIA_CPU_PAINTING_THREADS", origCPU) | |||
| 21 | + | restoreEnv("WEBKIT_SKIA_GPU_PAINTING_THREADS", origGPU) | |||
| 22 | + | restoreEnv("WEBKIT_SKIA_ENABLE_CPU_RENDERING", origCPURender) | |||
| 23 | + | }) | |||
| 24 | + | ||||
| 25 | + | tests := []struct { | |||
| 26 | + | name string | |||
| 27 | + | settings port.RenderingEnvSettings | |||
| 28 | + | presetEnv map[string]string | |||
| 29 | + | expectedVars map[string]string | |||
| 30 | + | unexpectedVars []string | |||
| 31 | + | }{ | |||
| 32 | + | { | |||
| 33 | + | name: "CPU threads set when > 0", | |||
| 34 | + | settings: port.RenderingEnvSettings{ | |||
| 35 | + | SkiaCPUPaintingThreads: 4, | |||
| 36 | + | SkiaGPUPaintingThreads: -1, // unset | |||
| 37 | + | }, | |||
| 38 | + | expectedVars: map[string]string{ | |||
| 39 | + | "WEBKIT_SKIA_CPU_PAINTING_THREADS": "4", | |||
| 40 | + | }, | |||
| 41 | + | unexpectedVars: []string{"WEBKIT_SKIA_GPU_PAINTING_THREADS"}, | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | name: "GPU threads set when >= 0", | |||
| 45 | + | settings: port.RenderingEnvSettings{ | |||
| 46 | + | SkiaCPUPaintingThreads: 0, // unset | |||
| 47 | + | SkiaGPUPaintingThreads: 2, | |||
| 48 | + | }, | |||
| 49 | + | expectedVars: map[string]string{ | |||
| 50 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS": "2", | |||
| 51 | + | }, | |||
| 52 | + | unexpectedVars: []string{"WEBKIT_SKIA_CPU_PAINTING_THREADS"}, | |||
| 53 | + | }, | |||
| 54 | + | { | |||
| 55 | + | name: "GPU threads 0 disables GPU tile painting", | |||
| 56 | + | settings: port.RenderingEnvSettings{ | |||
| 57 | + | SkiaGPUPaintingThreads: 0, | |||
| 58 | + | }, | |||
| 59 | + | expectedVars: map[string]string{ | |||
| 60 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS": "0", | |||
| 61 | + | }, | |||
| 62 | + | }, | |||
| 63 | + | { | |||
| 64 | + | name: "CPU rendering enabled", | |||
| 65 | + | settings: port.RenderingEnvSettings{ | |||
| 66 | + | SkiaEnableCPURendering: true, | |||
| 67 | + | }, | |||
| 68 | + | expectedVars: map[string]string{ | |||
| 69 | + | "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "1", | |||
| 70 | + | }, | |||
| 71 | + | }, | |||
| 72 | + | { | |||
| 73 | + | name: "CPU rendering disabled (default)", | |||
| 74 | + | settings: port.RenderingEnvSettings{ | |||
| 75 | + | SkiaEnableCPURendering: false, | |||
| 76 | + | }, | |||
| 77 | + | unexpectedVars: []string{"WEBKIT_SKIA_ENABLE_CPU_RENDERING"}, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "does not override existing CPU threads env var", | |||
| 81 | + | settings: port.RenderingEnvSettings{ | |||
| 82 | + | SkiaCPUPaintingThreads: 8, | |||
| 83 | + | }, | |||
| 84 | + | presetEnv: map[string]string{ | |||
| 85 | + | "WEBKIT_SKIA_CPU_PAINTING_THREADS": "2", | |||
| 86 | + | }, | |||
| 87 | + | expectedVars: map[string]string{ | |||
| 88 | + | "WEBKIT_SKIA_CPU_PAINTING_THREADS": "2", // keeps original | |||
| 89 | + | }, | |||
| 90 | + | }, | |||
| 91 | + | { | |||
| 92 | + | name: "does not override existing GPU threads env var", | |||
| 93 | + | settings: port.RenderingEnvSettings{ | |||
| 94 | + | SkiaGPUPaintingThreads: 4, | |||
| 95 | + | }, | |||
| 96 | + | presetEnv: map[string]string{ | |||
| 97 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS": "1", | |||
| 98 | + | }, | |||
| 99 | + | expectedVars: map[string]string{ | |||
| 100 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS": "1", // keeps original | |||
| 101 | + | }, | |||
| 102 | + | }, | |||
| 103 | + | { | |||
| 104 | + | name: "does not override existing CPU rendering env var", | |||
| 105 | + | settings: port.RenderingEnvSettings{ | |||
| 106 | + | SkiaEnableCPURendering: true, | |||
| 107 | + | }, | |||
| 108 | + | presetEnv: map[string]string{ | |||
| 109 | + | "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "0", | |||
| 110 | + | }, | |||
| 111 | + | expectedVars: map[string]string{ | |||
| 112 | + | "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "0", // keeps original | |||
| 113 | + | }, | |||
| 114 | + | }, | |||
| 115 | + | { | |||
| 116 | + | name: "all unset by default", | |||
| 117 | + | settings: port.RenderingEnvSettings{ | |||
| 118 | + | SkiaCPUPaintingThreads: 0, // unset | |||
| 119 | + | SkiaGPUPaintingThreads: -1, // unset | |||
| 120 | + | SkiaEnableCPURendering: false, | |||
| 121 | + | }, | |||
| 122 | + | unexpectedVars: []string{ | |||
| 123 | + | "WEBKIT_SKIA_CPU_PAINTING_THREADS", | |||
| 124 | + | "WEBKIT_SKIA_GPU_PAINTING_THREADS", | |||
| 125 | + | "WEBKIT_SKIA_ENABLE_CPU_RENDERING", | |||
| 126 | + | }, | |||
| 127 | + | }, | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | for _, tt := range tests { | |||
| 131 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 132 | + | // Clear env vars before each test | |||
| 133 | + | os.Unsetenv("WEBKIT_SKIA_CPU_PAINTING_THREADS") | |||
| 134 | + | os.Unsetenv("WEBKIT_SKIA_GPU_PAINTING_THREADS") | |||
| 135 | + | os.Unsetenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING") | |||
| 136 | + | ||||
| 137 | + | // Set any preset env vars | |||
| 138 | + | for k, v := range tt.presetEnv { | |||
| 139 | + | os.Setenv(k, v) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | manager := env.NewManager() | |||
| 143 | + | ctx := context.Background() | |||
| 144 | + | ||||
| 145 | + | err := manager.ApplyEnvironment(ctx, tt.settings) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | ||||
| 148 | + | // Check expected vars are set correctly | |||
| 149 | + | for k, v := range tt.expectedVars { | |||
| 150 | + | actual := os.Getenv(k) | |||
| 151 | + | assert.Equal(t, v, actual, "env var %s", k) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // Check unexpected vars are not set (or were not set by us) | |||
| 155 | + | appliedVars := manager.GetAppliedVars() | |||
| 156 | + | for _, k := range tt.unexpectedVars { | |||
| 157 | + | _, wasApplied := appliedVars[k] | |||
| 158 | + | if tt.presetEnv[k] == "" { | |||
| 159 | + | assert.False(t, wasApplied, "env var %s should not be applied", k) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | }) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func restoreEnv(key, value string) { | |||
| 167 | + | if value == "" { | |||
| 168 | + | os.Unsetenv(key) | |||
| 169 | + | } else { | |||
| 170 | + | os.Setenv(key, value) | |||
| 171 | + | } | |||
| 172 | + | } | |||
M internal/infrastructure/webkit/context.go +45 -10
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "path/filepath" | |
| 7 | 7 | "sync" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 11 | "github.com/bnema/puregotk-webkit/webkit" | |
| 11 | 12 | "github.com/rs/zerolog" | |
| @@ -34,34 +35,68 @@ type WebKitContext struct { | |||
| 34 | 35 | // The dataDir and cacheDir are used for cookie storage, cache, and other persistent data. | |
| 35 | 36 | // This MUST be called before creating any WebViews to ensure they use persistent storage. | |
| 36 | 37 | func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) { | |
| 38 | + | return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{ | |
| 39 | + | DataDir: dataDir, | |
| 40 | + | CacheDir: cacheDir, | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // NewWebKitContextWithOptions creates and initializes a WebKitContext with the given options. | |
| 45 | + | // This allows configuring memory pressure settings for web and network processes. | |
| 46 | + | func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOptions) (*WebKitContext, error) { | |
| 37 | 47 | log := logging.FromContext(ctx).With().Str("component", "webkit-context").Logger() | |
| 38 | 48 | ||
| 39 | - | if dataDir == "" { | |
| 49 | + | if opts.DataDir == "" { | |
| 40 | 50 | return nil, fmt.Errorf("data directory cannot be empty") | |
| 41 | 51 | } | |
| 42 | - | if cacheDir == "" { | |
| 52 | + | if opts.CacheDir == "" { | |
| 43 | 53 | return nil, fmt.Errorf("cache directory cannot be empty") | |
| 44 | 54 | } | |
| 45 | 55 | ||
| 46 | 56 | wkCtx := &WebKitContext{ | |
| 47 | - | dataDir: dataDir, | |
| 48 | - | cacheDir: cacheDir, | |
| 57 | + | dataDir: opts.DataDir, | |
| 58 | + | cacheDir: opts.CacheDir, | |
| 49 | 59 | logger: log, | |
| 50 | 60 | } | |
| 51 | 61 | ||
| 62 | + | // Apply network process memory pressure settings BEFORE creating session | |
| 63 | + | // Per WebKitGTK docs: must be called before creating any NetworkSession | |
| 64 | + | if opts.IsNetworkProcessMemoryConfigured() { | |
| 65 | + | applier := NewMemoryPressureApplier() | |
| 66 | + | if err := applier.ApplyNetworkProcessSettings(ctx, opts.NetworkProcessMemory); err != nil { | |
| 67 | + | log.Warn().Err(err).Msg("failed to apply network process memory pressure settings") | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 52 | 71 | // Create persistent network session FIRST | |
| 53 | 72 | // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default" | |
| 54 | 73 | if err := wkCtx.initNetworkSession(); err != nil { | |
| 55 | 74 | return nil, fmt.Errorf("failed to init network session: %w", err) | |
| 56 | 75 | } | |
| 57 | 76 | ||
| 58 | - | // Get or create WebContext | |
| 59 | - | wkCtx.webContext = webkit.WebContextGetDefault() | |
| 77 | + | // Create WebContext - use custom constructor if memory pressure settings are configured | |
| 78 | + | if opts.IsWebProcessMemoryConfigured() { | |
| 79 | + | memSettings := buildMemoryPressureSettings(opts.WebProcessMemory) | |
| 80 | + | if memSettings != nil { | |
| 81 | + | wkCtx.webContext = webkit.NewWebContextWithMemoryPressureSettings(memSettings) | |
| 82 | + | if wkCtx.webContext != nil { | |
| 83 | + | log.Info(). | |
| 84 | + | Int("limit_mb", opts.WebProcessMemory.MemoryLimitMB). | |
| 85 | + | Float64("poll_sec", opts.WebProcessMemory.PollIntervalSec). | |
| 86 | + | Float64("conservative", opts.WebProcessMemory.ConservativeThreshold). | |
| 87 | + | Float64("strict", opts.WebProcessMemory.StrictThreshold). | |
| 88 | + | Float64("kill", opts.WebProcessMemory.KillThreshold). | |
| 89 | + | Msg("created WebContext with memory pressure settings") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Fall back to default WebContext if custom creation failed or wasn't needed | |
| 60 | 95 | if wkCtx.webContext == nil { | |
| 61 | - | wkCtx.webContext = webkit.NewWebContext() | |
| 96 | + | wkCtx.webContext = webkit.WebContextGetDefault() | |
| 62 | 97 | } | |
| 63 | 98 | if wkCtx.webContext == nil { | |
| 64 | - | return nil, fmt.Errorf("failed to get or create WebContext") | |
| 99 | + | return nil, fmt.Errorf("failed to create or get WebContext") | |
| 65 | 100 | } | |
| 66 | 101 | ||
| 67 | 102 | // Set cache model for browser-style caching | |
| @@ -69,8 +104,8 @@ func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitCon | |||
| 69 | 104 | ||
| 70 | 105 | wkCtx.initialized = true | |
| 71 | 106 | log.Info(). | |
| 72 | - | Str("data_dir", dataDir). | |
| 73 | - | Str("cache_dir", cacheDir). | |
| 107 | + | Str("data_dir", opts.DataDir). | |
| 108 | + | Str("cache_dir", opts.CacheDir). | |
| 74 | 109 | Msg("webkit context initialized") | |
| 75 | 110 | ||
| 76 | 111 | return wkCtx, nil | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | "github.com/bnema/puregotk-webkit/webkit" | 11 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 11 | "github.com/rs/zerolog" | 12 | "github.com/rs/zerolog" | ||
| @@ -34,34 +35,68 @@ type WebKitContext struct { | |||||
| 34 | // The dataDir and cacheDir are used for cookie storage, cache, and other persistent data. | 35 | // The dataDir and cacheDir are used for cookie storage, cache, and other persistent data. | ||
| 35 | // This MUST be called before creating any WebViews to ensure they use persistent storage. | 36 | // This MUST be called before creating any WebViews to ensure they use persistent storage. | ||
| 36 | func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) { | 37 | func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) { | ||
| 38 | + | return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{ | |||
| 39 | + | DataDir: dataDir, | |||
| 40 | + | CacheDir: cacheDir, | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // NewWebKitContextWithOptions creates and initializes a WebKitContext with the given options. | |||
| 45 | + | // This allows configuring memory pressure settings for web and network processes. | |||
| 46 | + | func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOptions) (*WebKitContext, error) { | |||
| 37 | log := logging.FromContext(ctx).With().Str("component", "webkit-context").Logger() | 47 | log := logging.FromContext(ctx).With().Str("component", "webkit-context").Logger() | ||
| 38 | 48 | ||||
| 39 | - | if dataDir == "" { | 49 | + | if opts.DataDir == "" { |
| 40 | return nil, fmt.Errorf("data directory cannot be empty") | 50 | return nil, fmt.Errorf("data directory cannot be empty") | ||
| 41 | } | 51 | } | ||
| 42 | - | if cacheDir == "" { | 52 | + | if opts.CacheDir == "" { |
| 43 | return nil, fmt.Errorf("cache directory cannot be empty") | 53 | return nil, fmt.Errorf("cache directory cannot be empty") | ||
| 44 | } | 54 | } | ||
| 45 | 55 | ||||
| 46 | wkCtx := &WebKitContext{ | 56 | wkCtx := &WebKitContext{ | ||
| 47 | - | dataDir: dataDir, | 57 | + | dataDir: opts.DataDir, |
| 48 | - | cacheDir: cacheDir, | 58 | + | cacheDir: opts.CacheDir, |
| 49 | logger: log, | 59 | logger: log, | ||
| 50 | } | 60 | } | ||
| 51 | 61 | ||||
| 62 | + | // Apply network process memory pressure settings BEFORE creating session | |||
| 63 | + | // Per WebKitGTK docs: must be called before creating any NetworkSession | |||
| 64 | + | if opts.IsNetworkProcessMemoryConfigured() { | |||
| 65 | + | applier := NewMemoryPressureApplier() | |||
| 66 | + | if err := applier.ApplyNetworkProcessSettings(ctx, opts.NetworkProcessMemory); err != nil { | |||
| 67 | + | log.Warn().Err(err).Msg("failed to apply network process memory pressure settings") | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 52 | // Create persistent network session FIRST | 71 | // Create persistent network session FIRST | ||
| 53 | // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default" | 72 | // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default" | ||
| 54 | if err := wkCtx.initNetworkSession(); err != nil { | 73 | if err := wkCtx.initNetworkSession(); err != nil { | ||
| 55 | return nil, fmt.Errorf("failed to init network session: %w", err) | 74 | return nil, fmt.Errorf("failed to init network session: %w", err) | ||
| 56 | } | 75 | } | ||
| 57 | 76 | ||||
| 58 | - | // Get or create WebContext | 77 | + | // Create WebContext - use custom constructor if memory pressure settings are configured |
| 59 | - | wkCtx.webContext = webkit.WebContextGetDefault() | 78 | + | if opts.IsWebProcessMemoryConfigured() { |
| 79 | + | memSettings := buildMemoryPressureSettings(opts.WebProcessMemory) | |||
| 80 | + | if memSettings != nil { | |||
| 81 | + | wkCtx.webContext = webkit.NewWebContextWithMemoryPressureSettings(memSettings) | |||
| 82 | + | if wkCtx.webContext != nil { | |||
| 83 | + | log.Info(). | |||
| 84 | + | Int("limit_mb", opts.WebProcessMemory.MemoryLimitMB). | |||
| 85 | + | Float64("poll_sec", opts.WebProcessMemory.PollIntervalSec). | |||
| 86 | + | Float64("conservative", opts.WebProcessMemory.ConservativeThreshold). | |||
| 87 | + | Float64("strict", opts.WebProcessMemory.StrictThreshold). | |||
| 88 | + | Float64("kill", opts.WebProcessMemory.KillThreshold). | |||
| 89 | + | Msg("created WebContext with memory pressure settings") | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // Fall back to default WebContext if custom creation failed or wasn't needed | |||
| 60 | if wkCtx.webContext == nil { | 95 | if wkCtx.webContext == nil { | ||
| 61 | - | wkCtx.webContext = webkit.NewWebContext() | 96 | + | wkCtx.webContext = webkit.WebContextGetDefault() |
| 62 | } | 97 | } | ||
| 63 | if wkCtx.webContext == nil { | 98 | if wkCtx.webContext == nil { | ||
| 64 | - | return nil, fmt.Errorf("failed to get or create WebContext") | 99 | + | return nil, fmt.Errorf("failed to create or get WebContext") |
| 65 | } | 100 | } | ||
| 66 | 101 | ||||
| 67 | // Set cache model for browser-style caching | 102 | // Set cache model for browser-style caching | ||
| @@ -69,8 +104,8 @@ func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitCon | |||||
| 69 | 104 | ||||
| 70 | wkCtx.initialized = true | 105 | wkCtx.initialized = true | ||
| 71 | log.Info(). | 106 | log.Info(). | ||
| 72 | - | Str("data_dir", dataDir). | 107 | + | Str("data_dir", opts.DataDir). |
| 73 | - | Str("cache_dir", cacheDir). | 108 | + | Str("cache_dir", opts.CacheDir). |
| 74 | Msg("webkit context initialized") | 109 | Msg("webkit context initialized") | ||
| 75 | 110 | ||||
| 76 | return wkCtx, nil | 111 | return wkCtx, nil | ||
A internal/infrastructure/webkit/memory_pressure.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | + | "github.com/bnema/puregotk-webkit/webkit" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // MemoryPressureApplier implements port.MemoryPressureApplier for WebKitGTK. | |
| 12 | + | type MemoryPressureApplier struct{} | |
| 13 | + | ||
| 14 | + | // NewMemoryPressureApplier creates a new MemoryPressureApplier. | |
| 15 | + | func NewMemoryPressureApplier() *MemoryPressureApplier { | |
| 16 | + | return &MemoryPressureApplier{} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // ApplyNetworkProcessSettings applies memory pressure settings to the network process. | |
| 20 | + | // Must be called BEFORE creating any NetworkSession. | |
| 21 | + | func (*MemoryPressureApplier) ApplyNetworkProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) error { | |
| 22 | + | log := logging.FromContext(ctx) | |
| 23 | + | ||
| 24 | + | if cfg == nil || !cfg.IsConfigured() { | |
| 25 | + | log.Debug().Msg("no network process memory pressure settings configured") | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | if cfg.ConservativeThreshold > 0 && cfg.StrictThreshold <= 0 { | |
| 30 | + | log.Warn().Msg("conservative threshold set without strict threshold; skipping conservative to avoid WebKit assertion") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | settings := buildMemoryPressureSettings(cfg) | |
| 34 | + | if settings == nil { | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | webkit.NetworkSessionSetMemoryPressureSettings(settings) | |
| 39 | + | log.Info(). | |
| 40 | + | Int("limit_mb", cfg.MemoryLimitMB). | |
| 41 | + | Float64("poll_sec", cfg.PollIntervalSec). | |
| 42 | + | Float64("conservative", cfg.ConservativeThreshold). | |
| 43 | + | Float64("strict", cfg.StrictThreshold). | |
| 44 | + | Float64("kill", cfg.KillThreshold). | |
| 45 | + | Msg("applied network process memory pressure settings") | |
| 46 | + | ||
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ApplyWebProcessSettings applies memory pressure settings to web processes. | |
| 51 | + | // Returns an opaque settings object that should be passed to WebContext creation. | |
| 52 | + | // Returns nil if no settings are configured. | |
| 53 | + | func (*MemoryPressureApplier) ApplyWebProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error) { | |
| 54 | + | log := logging.FromContext(ctx) | |
| 55 | + | ||
| 56 | + | if cfg == nil || !cfg.IsConfigured() { | |
| 57 | + | log.Debug().Msg("no web process memory pressure settings configured") | |
| 58 | + | return nil, nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | if cfg.ConservativeThreshold > 0 && cfg.StrictThreshold <= 0 { | |
| 62 | + | log.Warn().Msg("conservative threshold set without strict threshold; skipping conservative to avoid WebKit assertion") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | settings := buildMemoryPressureSettings(cfg) | |
| 66 | + | if settings == nil { | |
| 67 | + | return nil, nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | log.Info(). | |
| 71 | + | Int("limit_mb", cfg.MemoryLimitMB). | |
| 72 | + | Float64("poll_sec", cfg.PollIntervalSec). | |
| 73 | + | Float64("conservative", cfg.ConservativeThreshold). | |
| 74 | + | Float64("strict", cfg.StrictThreshold). | |
| 75 | + | Float64("kill", cfg.KillThreshold). | |
| 76 | + | Msg("prepared web process memory pressure settings") | |
| 77 | + | ||
| 78 | + | return settings, nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // buildMemoryPressureSettings creates WebKitMemoryPressureSettings from config. | |
| 82 | + | // Returns nil if no settings are configured. | |
| 83 | + | func buildMemoryPressureSettings(cfg *port.MemoryPressureConfig) *webkit.MemoryPressureSettings { | |
| 84 | + | if cfg == nil || !cfg.IsConfigured() { | |
| 85 | + | return nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | settings := webkit.NewMemoryPressureSettings() | |
| 89 | + | ||
| 90 | + | if cfg.MemoryLimitMB > 0 { | |
| 91 | + | settings.SetMemoryLimit(uint(cfg.MemoryLimitMB)) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | if cfg.PollIntervalSec > 0 { | |
| 95 | + | settings.SetPollInterval(cfg.PollIntervalSec) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Thresholds must be in (0, 1) | |
| 99 | + | // IMPORTANT: WebKit asserts that conservative < strict, so set strict first. | |
| 100 | + | strict := cfg.StrictThreshold | |
| 101 | + | conservative := cfg.ConservativeThreshold | |
| 102 | + | ||
| 103 | + | if strict > 0 && strict < 1 { | |
| 104 | + | settings.SetStrictThreshold(strict) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Only set conservative when we also have a strict threshold; otherwise | |
| 108 | + | // WebKit's internal default strict value may not be initialized yet. | |
| 109 | + | if conservative > 0 && conservative < 1 && strict > 0 { | |
| 110 | + | if conservative < strict { | |
| 111 | + | settings.SetConservativeThreshold(conservative) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Kill threshold: -1 = unset, 0 = never kill, >0 = threshold | |
| 116 | + | if cfg.KillThreshold >= 0 { | |
| 117 | + | settings.SetKillThreshold(cfg.KillThreshold) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | return settings | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Ensure MemoryPressureApplier implements the interface. | |
| 124 | + | var _ port.MemoryPressureApplier = (*MemoryPressureApplier)(nil) | |
| @@ -0,0 +1,124 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |||
| 8 | + | "github.com/bnema/puregotk-webkit/webkit" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // MemoryPressureApplier implements port.MemoryPressureApplier for WebKitGTK. | |||
| 12 | + | type MemoryPressureApplier struct{} | |||
| 13 | + | ||||
| 14 | + | // NewMemoryPressureApplier creates a new MemoryPressureApplier. | |||
| 15 | + | func NewMemoryPressureApplier() *MemoryPressureApplier { | |||
| 16 | + | return &MemoryPressureApplier{} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // ApplyNetworkProcessSettings applies memory pressure settings to the network process. | |||
| 20 | + | // Must be called BEFORE creating any NetworkSession. | |||
| 21 | + | func (*MemoryPressureApplier) ApplyNetworkProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) error { | |||
| 22 | + | log := logging.FromContext(ctx) | |||
| 23 | + | ||||
| 24 | + | if cfg == nil || !cfg.IsConfigured() { | |||
| 25 | + | log.Debug().Msg("no network process memory pressure settings configured") | |||
| 26 | + | return nil | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | if cfg.ConservativeThreshold > 0 && cfg.StrictThreshold <= 0 { | |||
| 30 | + | log.Warn().Msg("conservative threshold set without strict threshold; skipping conservative to avoid WebKit assertion") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | settings := buildMemoryPressureSettings(cfg) | |||
| 34 | + | if settings == nil { | |||
| 35 | + | return nil | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | webkit.NetworkSessionSetMemoryPressureSettings(settings) | |||
| 39 | + | log.Info(). | |||
| 40 | + | Int("limit_mb", cfg.MemoryLimitMB). | |||
| 41 | + | Float64("poll_sec", cfg.PollIntervalSec). | |||
| 42 | + | Float64("conservative", cfg.ConservativeThreshold). | |||
| 43 | + | Float64("strict", cfg.StrictThreshold). | |||
| 44 | + | Float64("kill", cfg.KillThreshold). | |||
| 45 | + | Msg("applied network process memory pressure settings") | |||
| 46 | + | ||||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // ApplyWebProcessSettings applies memory pressure settings to web processes. | |||
| 51 | + | // Returns an opaque settings object that should be passed to WebContext creation. | |||
| 52 | + | // Returns nil if no settings are configured. | |||
| 53 | + | func (*MemoryPressureApplier) ApplyWebProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error) { | |||
| 54 | + | log := logging.FromContext(ctx) | |||
| 55 | + | ||||
| 56 | + | if cfg == nil || !cfg.IsConfigured() { | |||
| 57 | + | log.Debug().Msg("no web process memory pressure settings configured") | |||
| 58 | + | return nil, nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | if cfg.ConservativeThreshold > 0 && cfg.StrictThreshold <= 0 { | |||
| 62 | + | log.Warn().Msg("conservative threshold set without strict threshold; skipping conservative to avoid WebKit assertion") | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | settings := buildMemoryPressureSettings(cfg) | |||
| 66 | + | if settings == nil { | |||
| 67 | + | return nil, nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | log.Info(). | |||
| 71 | + | Int("limit_mb", cfg.MemoryLimitMB). | |||
| 72 | + | Float64("poll_sec", cfg.PollIntervalSec). | |||
| 73 | + | Float64("conservative", cfg.ConservativeThreshold). | |||
| 74 | + | Float64("strict", cfg.StrictThreshold). | |||
| 75 | + | Float64("kill", cfg.KillThreshold). | |||
| 76 | + | Msg("prepared web process memory pressure settings") | |||
| 77 | + | ||||
| 78 | + | return settings, nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // buildMemoryPressureSettings creates WebKitMemoryPressureSettings from config. | |||
| 82 | + | // Returns nil if no settings are configured. | |||
| 83 | + | func buildMemoryPressureSettings(cfg *port.MemoryPressureConfig) *webkit.MemoryPressureSettings { | |||
| 84 | + | if cfg == nil || !cfg.IsConfigured() { | |||
| 85 | + | return nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | settings := webkit.NewMemoryPressureSettings() | |||
| 89 | + | ||||
| 90 | + | if cfg.MemoryLimitMB > 0 { | |||
| 91 | + | settings.SetMemoryLimit(uint(cfg.MemoryLimitMB)) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | if cfg.PollIntervalSec > 0 { | |||
| 95 | + | settings.SetPollInterval(cfg.PollIntervalSec) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Thresholds must be in (0, 1) | |||
| 99 | + | // IMPORTANT: WebKit asserts that conservative < strict, so set strict first. | |||
| 100 | + | strict := cfg.StrictThreshold | |||
| 101 | + | conservative := cfg.ConservativeThreshold | |||
| 102 | + | ||||
| 103 | + | if strict > 0 && strict < 1 { | |||
| 104 | + | settings.SetStrictThreshold(strict) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Only set conservative when we also have a strict threshold; otherwise | |||
| 108 | + | // WebKit's internal default strict value may not be initialized yet. | |||
| 109 | + | if conservative > 0 && conservative < 1 && strict > 0 { | |||
| 110 | + | if conservative < strict { | |||
| 111 | + | settings.SetConservativeThreshold(conservative) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Kill threshold: -1 = unset, 0 = never kill, >0 = threshold | |||
| 116 | + | if cfg.KillThreshold >= 0 { | |||
| 117 | + | settings.SetKillThreshold(cfg.KillThreshold) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | return settings | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // Ensure MemoryPressureApplier implements the interface. | |||
| 124 | + | var _ port.MemoryPressureApplier = (*MemoryPressureApplier)(nil) | |||
M internal/infrastructure/webkit/scheme_handler.go +129 -19
| @@ -13,7 +13,9 @@ import ( | |||
| 13 | 13 | "strings" | |
| 14 | 14 | "sync" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 18 | + | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 17 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 20 | "github.com/bnema/puregotk-webkit/soup" | |
| 19 | 21 | "github.com/bnema/puregotk-webkit/webkit" | |
| @@ -59,11 +61,13 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||
| 59 | 61 | ||
| 60 | 62 | // DumbSchemeHandler handles dumb:// URI scheme requests. | |
| 61 | 63 | type DumbSchemeHandler struct { | |
| 62 | - | handlers map[string]PageHandler | |
| 63 | - | assets embed.FS | |
| 64 | - | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | |
| 65 | - | logger zerolog.Logger | |
| 66 | - | mu sync.RWMutex | |
| 64 | + | handlers map[string]PageHandler | |
| 65 | + | assets embed.FS | |
| 66 | + | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | |
| 67 | + | logger zerolog.Logger | |
| 68 | + | mu sync.RWMutex | |
| 69 | + | hwSurveyor *env.HardwareSurveyor | |
| 70 | + | ctx context.Context | |
| 67 | 71 | } | |
| 68 | 72 | ||
| 69 | 73 | type configAppearancePayload struct { | |
| @@ -76,8 +80,46 @@ type configAppearancePayload struct { | |||
| 76 | 80 | DarkPalette config.ColorPalette `json:"dark_palette"` | |
| 77 | 81 | } | |
| 78 | 82 | ||
| 83 | + | type configHardwarePayload struct { | |
| 84 | + | CPUCores int `json:"cpu_cores"` | |
| 85 | + | CPUThreads int `json:"cpu_threads"` | |
| 86 | + | TotalRAMMB int `json:"total_ram_mb"` | |
| 87 | + | GPUVendor string `json:"gpu_vendor"` | |
| 88 | + | GPUName string `json:"gpu_name"` | |
| 89 | + | VRAMMB int `json:"vram_mb"` | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | type configPerformancePayload struct { | |
| 93 | + | Profile string `json:"profile"` | |
| 94 | + | Custom configCustomPerformance `json:"custom"` | |
| 95 | + | Resolved configResolvedPerformance `json:"resolved"` | |
| 96 | + | Hardware configHardwarePayload `json:"hardware"` | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // configCustomPerformance holds user-editable fields for custom profile. | |
| 100 | + | type configCustomPerformance struct { | |
| 101 | + | SkiaCPUThreads int `json:"skia_cpu_threads"` | |
| 102 | + | SkiaGPUThreads int `json:"skia_gpu_threads"` | |
| 103 | + | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |
| 104 | + | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |
| 105 | + | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // configResolvedPerformance shows the actual values that will be applied at startup. | |
| 109 | + | type configResolvedPerformance struct { | |
| 110 | + | SkiaCPUThreads int `json:"skia_cpu_threads"` | |
| 111 | + | SkiaGPUThreads int `json:"skia_gpu_threads"` | |
| 112 | + | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |
| 113 | + | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |
| 114 | + | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |
| 115 | + | ConservativeThreshold float64 `json:"conservative_threshold"` | |
| 116 | + | StrictThreshold float64 `json:"strict_threshold"` | |
| 117 | + | KillThreshold float64 `json:"kill_threshold"` | |
| 118 | + | } | |
| 119 | + | ||
| 79 | 120 | type configPayload struct { | |
| 80 | 121 | Appearance configAppearancePayload `json:"appearance"` | |
| 122 | + | Performance configPerformancePayload `json:"performance"` | |
| 81 | 123 | DefaultUIScale float64 `json:"default_ui_scale"` | |
| 82 | 124 | DefaultSearchEngine string `json:"default_search_engine"` | |
| 83 | 125 | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |
| @@ -88,9 +130,11 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||
| 88 | 130 | log := logging.FromContext(ctx) | |
| 89 | 131 | ||
| 90 | 132 | h := &DumbSchemeHandler{ | |
| 91 | - | handlers: make(map[string]PageHandler), | |
| 92 | - | assetDir: "webui", | |
| 93 | - | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 133 | + | handlers: make(map[string]PageHandler), | |
| 134 | + | assetDir: "webui", | |
| 135 | + | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 136 | + | hwSurveyor: env.NewHardwareSurveyor(), | |
| 137 | + | ctx: ctx, | |
| 94 | 138 | } | |
| 95 | 139 | ||
| 96 | 140 | // Register default pages | |
| @@ -124,7 +168,7 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 124 | 168 | return nil | |
| 125 | 169 | } | |
| 126 | 170 | ||
| 127 | - | return buildConfigResponse(config.Get()) | |
| 171 | + | return h.buildConfigResponse(config.Get()) | |
| 128 | 172 | })) | |
| 129 | 173 | ||
| 130 | 174 | // API: Get default config (used by Reset Defaults in dumb://config) | |
| @@ -133,11 +177,23 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 133 | 177 | return nil | |
| 134 | 178 | } | |
| 135 | 179 | ||
| 136 | - | return buildConfigResponse(config.DefaultConfig()) | |
| 180 | + | return h.buildConfigResponse(config.DefaultConfig()) | |
| 137 | 181 | })) | |
| 138 | 182 | } | |
| 139 | 183 | ||
| 140 | - | func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |
| 184 | + | func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse { | |
| 185 | + | // Get hardware info for display and profile resolution | |
| 186 | + | // Use background context since survey results are cached and we don't want | |
| 187 | + | // request context cancellation to affect this | |
| 188 | + | var hw *port.HardwareInfo | |
| 189 | + | if h.hwSurveyor != nil { | |
| 190 | + | hwInfo := h.hwSurveyor.Survey(context.Background()) | |
| 191 | + | hw = &hwInfo | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Resolve performance profile with hardware info | |
| 195 | + | resolved := config.ResolvePerformanceProfile(&cfg.Performance, hw) | |
| 196 | + | ||
| 141 | 197 | resp := configPayload{ | |
| 142 | 198 | DefaultUIScale: cfg.DefaultUIScale, | |
| 143 | 199 | DefaultSearchEngine: cfg.DefaultSearchEngine, | |
| @@ -151,6 +207,27 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |||
| 151 | 207 | LightPalette: cfg.Appearance.LightPalette, | |
| 152 | 208 | DarkPalette: cfg.Appearance.DarkPalette, | |
| 153 | 209 | }, | |
| 210 | + | Performance: configPerformancePayload{ | |
| 211 | + | Profile: string(cfg.Performance.Profile), | |
| 212 | + | Custom: configCustomPerformance{ | |
| 213 | + | SkiaCPUThreads: cfg.Performance.SkiaCPUPaintingThreads, | |
| 214 | + | SkiaGPUThreads: cfg.Performance.SkiaGPUPaintingThreads, | |
| 215 | + | WebProcessMemoryMB: cfg.Performance.WebProcessMemoryLimitMB, | |
| 216 | + | NetworkProcessMemoryMB: cfg.Performance.NetworkProcessMemoryLimitMB, | |
| 217 | + | WebViewPoolPrewarm: cfg.Performance.WebViewPoolPrewarmCount, | |
| 218 | + | }, | |
| 219 | + | Resolved: configResolvedPerformance{ | |
| 220 | + | SkiaCPUThreads: resolved.SkiaCPUPaintingThreads, | |
| 221 | + | SkiaGPUThreads: resolved.SkiaGPUPaintingThreads, | |
| 222 | + | WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB, | |
| 223 | + | NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB, | |
| 224 | + | WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount, | |
| 225 | + | ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold, | |
| 226 | + | StrictThreshold: resolved.WebProcessMemoryStrictThreshold, | |
| 227 | + | KillThreshold: resolved.WebProcessMemoryKillThreshold, | |
| 228 | + | }, | |
| 229 | + | Hardware: buildHardwarePayload(hw), | |
| 230 | + | }, | |
| 154 | 231 | } | |
| 155 | 232 | ||
| 156 | 233 | data, err := json.Marshal(resp) | |
| @@ -169,6 +246,21 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |||
| 169 | 246 | } | |
| 170 | 247 | } | |
| 171 | 248 | ||
| 249 | + | // buildHardwarePayload converts HardwareInfo to JSON payload. | |
| 250 | + | func buildHardwarePayload(hw *port.HardwareInfo) configHardwarePayload { | |
| 251 | + | if hw == nil { | |
| 252 | + | return configHardwarePayload{} | |
| 253 | + | } | |
| 254 | + | return configHardwarePayload{ | |
| 255 | + | CPUCores: hw.CPUCores, | |
| 256 | + | CPUThreads: hw.CPUThreads, | |
| 257 | + | TotalRAMMB: hw.TotalRAMMB(), | |
| 258 | + | GPUVendor: string(hw.GPUVendor), | |
| 259 | + | GPUName: hw.GPUName, | |
| 260 | + | VRAMMB: hw.VRAMMB(), | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 172 | 264 | // RegisterPage registers a handler for a specific path. | |
| 173 | 265 | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | |
| 174 | 266 | h.mu.Lock() | |
| @@ -419,6 +511,8 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||
| 419 | 511 | } | |
| 420 | 512 | ||
| 421 | 513 | // RegisterWithContext registers the dumb:// scheme with a WebKitContext. | |
| 514 | + | // The scheme is always registered on the default WebContext to ensure WebViews | |
| 515 | + | // (which use the default WebContext) can load dumb:// URLs. | |
| 422 | 516 | func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) { | |
| 423 | 517 | if wkCtx == nil || wkCtx.Context() == nil { | |
| 424 | 518 | h.logger.Error().Msg("cannot register scheme: context is nil") | |
| @@ -429,15 +523,31 @@ func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) { | |||
| 429 | 523 | h.HandleRequest(reqPtr) | |
| 430 | 524 | }) | |
| 431 | 525 | ||
| 432 | - | wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil) | |
| 526 | + | // Always register on the default WebContext since WebViews use it | |
| 527 | + | defaultCtx := webkit.WebContextGetDefault() | |
| 528 | + | if defaultCtx != nil { | |
| 529 | + | defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil) | |
| 433 | 530 | ||
| 434 | - | // Mark scheme as local, secure, and CORS-enabled for proper security policies | |
| 435 | - | secMgr := wkCtx.Context().GetSecurityManager() | |
| 436 | - | if secMgr != nil { | |
| 437 | - | secMgr.RegisterUriSchemeAsLocal("dumb") | |
| 438 | - | secMgr.RegisterUriSchemeAsSecure("dumb") | |
| 439 | - | secMgr.RegisterUriSchemeAsCorsEnabled("dumb") | |
| 440 | - | h.logger.Debug().Msg("dumb:// scheme registered as local, secure, and CORS-enabled") | |
| 531 | + | // Mark scheme as local, secure, and CORS-enabled for proper security policies | |
| 532 | + | if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil { | |
| 533 | + | secMgr.RegisterUriSchemeAsLocal("dumb") | |
| 534 | + | secMgr.RegisterUriSchemeAsSecure("dumb") | |
| 535 | + | secMgr.RegisterUriSchemeAsCorsEnabled("dumb") | |
| 536 | + | } | |
| 537 | + | h.logger.Debug().Msg("dumb:// scheme registered on default WebContext") | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | // Also register on custom WebContext if different from default | |
| 541 | + | customCtx := wkCtx.Context() | |
| 542 | + | if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) { | |
| 543 | + | wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil) | |
| 544 | + | ||
| 545 | + | if secMgr := customCtx.GetSecurityManager(); secMgr != nil { | |
| 546 | + | secMgr.RegisterUriSchemeAsLocal("dumb") | |
| 547 | + | secMgr.RegisterUriSchemeAsSecure("dumb") | |
| 548 | + | secMgr.RegisterUriSchemeAsCorsEnabled("dumb") | |
| 549 | + | } | |
| 550 | + | h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext") | |
| 441 | 551 | } | |
| 442 | 552 | ||
| 443 | 553 | h.logger.Info().Msg("dumb:// scheme registered") | |
| @@ -13,7 +13,9 @@ import ( | |||||
| 13 | "strings" | 13 | "strings" | ||
| 14 | "sync" | 14 | "sync" | ||
| 15 | 15 | ||||
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 16 | "github.com/bnema/dumber/internal/infrastructure/config" | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 18 | + | "github.com/bnema/dumber/internal/infrastructure/env" | |||
| 17 | "github.com/bnema/dumber/internal/logging" | 19 | "github.com/bnema/dumber/internal/logging" | ||
| 18 | "github.com/bnema/puregotk-webkit/soup" | 20 | "github.com/bnema/puregotk-webkit/soup" | ||
| 19 | "github.com/bnema/puregotk-webkit/webkit" | 21 | "github.com/bnema/puregotk-webkit/webkit" | ||
| @@ -59,11 +61,13 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||||
| 59 | 61 | ||||
| 60 | // DumbSchemeHandler handles dumb:// URI scheme requests. | 62 | // DumbSchemeHandler handles dumb:// URI scheme requests. | ||
| 61 | type DumbSchemeHandler struct { | 63 | type DumbSchemeHandler struct { | ||
| 62 | - | handlers map[string]PageHandler | 64 | + | handlers map[string]PageHandler |
| 63 | - | assets embed.FS | 65 | + | assets embed.FS |
| 64 | - | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | 66 | + | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") |
| 65 | - | logger zerolog.Logger | 67 | + | logger zerolog.Logger |
| 66 | - | mu sync.RWMutex | 68 | + | mu sync.RWMutex |
| 69 | + | hwSurveyor *env.HardwareSurveyor | |||
| 70 | + | ctx context.Context | |||
| 67 | } | 71 | } | ||
| 68 | 72 | ||||
| 69 | type configAppearancePayload struct { | 73 | type configAppearancePayload struct { | ||
| @@ -76,8 +80,46 @@ type configAppearancePayload struct { | |||||
| 76 | DarkPalette config.ColorPalette `json:"dark_palette"` | 80 | DarkPalette config.ColorPalette `json:"dark_palette"` | ||
| 77 | } | 81 | } | ||
| 78 | 82 | ||||
| 83 | + | type configHardwarePayload struct { | |||
| 84 | + | CPUCores int `json:"cpu_cores"` | |||
| 85 | + | CPUThreads int `json:"cpu_threads"` | |||
| 86 | + | TotalRAMMB int `json:"total_ram_mb"` | |||
| 87 | + | GPUVendor string `json:"gpu_vendor"` | |||
| 88 | + | GPUName string `json:"gpu_name"` | |||
| 89 | + | VRAMMB int `json:"vram_mb"` | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | type configPerformancePayload struct { | |||
| 93 | + | Profile string `json:"profile"` | |||
| 94 | + | Custom configCustomPerformance `json:"custom"` | |||
| 95 | + | Resolved configResolvedPerformance `json:"resolved"` | |||
| 96 | + | Hardware configHardwarePayload `json:"hardware"` | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // configCustomPerformance holds user-editable fields for custom profile. | |||
| 100 | + | type configCustomPerformance struct { | |||
| 101 | + | SkiaCPUThreads int `json:"skia_cpu_threads"` | |||
| 102 | + | SkiaGPUThreads int `json:"skia_gpu_threads"` | |||
| 103 | + | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |||
| 104 | + | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |||
| 105 | + | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // configResolvedPerformance shows the actual values that will be applied at startup. | |||
| 109 | + | type configResolvedPerformance struct { | |||
| 110 | + | SkiaCPUThreads int `json:"skia_cpu_threads"` | |||
| 111 | + | SkiaGPUThreads int `json:"skia_gpu_threads"` | |||
| 112 | + | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |||
| 113 | + | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |||
| 114 | + | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |||
| 115 | + | ConservativeThreshold float64 `json:"conservative_threshold"` | |||
| 116 | + | StrictThreshold float64 `json:"strict_threshold"` | |||
| 117 | + | KillThreshold float64 `json:"kill_threshold"` | |||
| 118 | + | } | |||
| 119 | + | ||||
| 79 | type configPayload struct { | 120 | type configPayload struct { | ||
| 80 | Appearance configAppearancePayload `json:"appearance"` | 121 | Appearance configAppearancePayload `json:"appearance"` | ||
| 122 | + | Performance configPerformancePayload `json:"performance"` | |||
| 81 | DefaultUIScale float64 `json:"default_ui_scale"` | 123 | DefaultUIScale float64 `json:"default_ui_scale"` | ||
| 82 | DefaultSearchEngine string `json:"default_search_engine"` | 124 | DefaultSearchEngine string `json:"default_search_engine"` | ||
| 83 | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | 125 | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | ||
| @@ -88,9 +130,11 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||||
| 88 | log := logging.FromContext(ctx) | 130 | log := logging.FromContext(ctx) | ||
| 89 | 131 | ||||
| 90 | h := &DumbSchemeHandler{ | 132 | h := &DumbSchemeHandler{ | ||
| 91 | - | handlers: make(map[string]PageHandler), | 133 | + | handlers: make(map[string]PageHandler), |
| 92 | - | assetDir: "webui", | 134 | + | assetDir: "webui", |
| 93 | - | logger: log.With().Str("component", "scheme-handler").Logger(), | 135 | + | logger: log.With().Str("component", "scheme-handler").Logger(), |
| 136 | + | hwSurveyor: env.NewHardwareSurveyor(), | |||
| 137 | + | ctx: ctx, | |||
| 94 | } | 138 | } | ||
| 95 | 139 | ||||
| 96 | // Register default pages | 140 | // Register default pages | ||
| @@ -124,7 +168,7 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 124 | return nil | 168 | return nil | ||
| 125 | } | 169 | } | ||
| 126 | 170 | ||||
| 127 | - | return buildConfigResponse(config.Get()) | 171 | + | return h.buildConfigResponse(config.Get()) |
| 128 | })) | 172 | })) | ||
| 129 | 173 | ||||
| 130 | // API: Get default config (used by Reset Defaults in dumb://config) | 174 | // API: Get default config (used by Reset Defaults in dumb://config) | ||
| @@ -133,11 +177,23 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 133 | return nil | 177 | return nil | ||
| 134 | } | 178 | } | ||
| 135 | 179 | ||||
| 136 | - | return buildConfigResponse(config.DefaultConfig()) | 180 | + | return h.buildConfigResponse(config.DefaultConfig()) |
| 137 | })) | 181 | })) | ||
| 138 | } | 182 | } | ||
| 139 | 183 | ||||
| 140 | - | func buildConfigResponse(cfg *config.Config) *SchemeResponse { | 184 | + | func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse { |
| 185 | + | // Get hardware info for display and profile resolution | |||
| 186 | + | // Use background context since survey results are cached and we don't want | |||
| 187 | + | // request context cancellation to affect this | |||
| 188 | + | var hw *port.HardwareInfo | |||
| 189 | + | if h.hwSurveyor != nil { | |||
| 190 | + | hwInfo := h.hwSurveyor.Survey(context.Background()) | |||
| 191 | + | hw = &hwInfo | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // Resolve performance profile with hardware info | |||
| 195 | + | resolved := config.ResolvePerformanceProfile(&cfg.Performance, hw) | |||
| 196 | + | ||||
| 141 | resp := configPayload{ | 197 | resp := configPayload{ | ||
| 142 | DefaultUIScale: cfg.DefaultUIScale, | 198 | DefaultUIScale: cfg.DefaultUIScale, | ||
| 143 | DefaultSearchEngine: cfg.DefaultSearchEngine, | 199 | DefaultSearchEngine: cfg.DefaultSearchEngine, | ||
| @@ -151,6 +207,27 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |||||
| 151 | LightPalette: cfg.Appearance.LightPalette, | 207 | LightPalette: cfg.Appearance.LightPalette, | ||
| 152 | DarkPalette: cfg.Appearance.DarkPalette, | 208 | DarkPalette: cfg.Appearance.DarkPalette, | ||
| 153 | }, | 209 | }, | ||
| 210 | + | Performance: configPerformancePayload{ | |||
| 211 | + | Profile: string(cfg.Performance.Profile), | |||
| 212 | + | Custom: configCustomPerformance{ | |||
| 213 | + | SkiaCPUThreads: cfg.Performance.SkiaCPUPaintingThreads, | |||
| 214 | + | SkiaGPUThreads: cfg.Performance.SkiaGPUPaintingThreads, | |||
| 215 | + | WebProcessMemoryMB: cfg.Performance.WebProcessMemoryLimitMB, | |||
| 216 | + | NetworkProcessMemoryMB: cfg.Performance.NetworkProcessMemoryLimitMB, | |||
| 217 | + | WebViewPoolPrewarm: cfg.Performance.WebViewPoolPrewarmCount, | |||
| 218 | + | }, | |||
| 219 | + | Resolved: configResolvedPerformance{ | |||
| 220 | + | SkiaCPUThreads: resolved.SkiaCPUPaintingThreads, | |||
| 221 | + | SkiaGPUThreads: resolved.SkiaGPUPaintingThreads, | |||
| 222 | + | WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB, | |||
| 223 | + | NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB, | |||
| 224 | + | WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount, | |||
| 225 | + | ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold, | |||
| 226 | + | StrictThreshold: resolved.WebProcessMemoryStrictThreshold, | |||
| 227 | + | KillThreshold: resolved.WebProcessMemoryKillThreshold, | |||
| 228 | + | }, | |||
| 229 | + | Hardware: buildHardwarePayload(hw), | |||
| 230 | + | }, | |||
| 154 | } | 231 | } | ||
| 155 | 232 | ||||
| 156 | data, err := json.Marshal(resp) | 233 | data, err := json.Marshal(resp) | ||
| @@ -169,6 +246,21 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse { | |||||
| 169 | } | 246 | } | ||
| 170 | } | 247 | } | ||
| 171 | 248 | ||||
| 249 | + | // buildHardwarePayload converts HardwareInfo to JSON payload. | |||
| 250 | + | func buildHardwarePayload(hw *port.HardwareInfo) configHardwarePayload { | |||
| 251 | + | if hw == nil { | |||
| 252 | + | return configHardwarePayload{} | |||
| 253 | + | } | |||
| 254 | + | return configHardwarePayload{ | |||
| 255 | + | CPUCores: hw.CPUCores, | |||
| 256 | + | CPUThreads: hw.CPUThreads, | |||
| 257 | + | TotalRAMMB: hw.TotalRAMMB(), | |||
| 258 | + | GPUVendor: string(hw.GPUVendor), | |||
| 259 | + | GPUName: hw.GPUName, | |||
| 260 | + | VRAMMB: hw.VRAMMB(), | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 172 | // RegisterPage registers a handler for a specific path. | 264 | // RegisterPage registers a handler for a specific path. | ||
| 173 | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | 265 | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | ||
| 174 | h.mu.Lock() | 266 | h.mu.Lock() | ||
| @@ -419,6 +511,8 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||||
| 419 | } | 511 | } | ||
| 420 | 512 | ||||
| 421 | // RegisterWithContext registers the dumb:// scheme with a WebKitContext. | 513 | // RegisterWithContext registers the dumb:// scheme with a WebKitContext. | ||
| 514 | + | // The scheme is always registered on the default WebContext to ensure WebViews | |||
| 515 | + | // (which use the default WebContext) can load dumb:// URLs. | |||
| 422 | func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) { | 516 | func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) { | ||
| 423 | if wkCtx == nil || wkCtx.Context() == nil { | 517 | if wkCtx == nil || wkCtx.Context() == nil { | ||
| 424 | h.logger.Error().Msg("cannot register scheme: context is nil") | 518 | h.logger.Error().Msg("cannot register scheme: context is nil") | ||
| @@ -429,15 +523,31 @@ func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) { | |||||
| 429 | h.HandleRequest(reqPtr) | 523 | h.HandleRequest(reqPtr) | ||
| 430 | }) | 524 | }) | ||
| 431 | 525 | ||||
| 432 | - | wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil) | 526 | + | // Always register on the default WebContext since WebViews use it |
| 527 | + | defaultCtx := webkit.WebContextGetDefault() | |||
| 528 | + | if defaultCtx != nil { | |||
| 529 | + | defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil) | |||
| 433 | 530 | ||||
| 434 | - | // Mark scheme as local, secure, and CORS-enabled for proper security policies | 531 | + | // Mark scheme as local, secure, and CORS-enabled for proper security policies |
| 435 | - | secMgr := wkCtx.Context().GetSecurityManager() | 532 | + | if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil { |
| 436 | - | if secMgr != nil { | 533 | + | secMgr.RegisterUriSchemeAsLocal("dumb") |
| 437 | - | secMgr.RegisterUriSchemeAsLocal("dumb") | 534 | + | secMgr.RegisterUriSchemeAsSecure("dumb") |
| 438 | - | secMgr.RegisterUriSchemeAsSecure("dumb") | 535 | + | secMgr.RegisterUriSchemeAsCorsEnabled("dumb") |
| 439 | - | secMgr.RegisterUriSchemeAsCorsEnabled("dumb") | 536 | + | } |
| 440 | - | h.logger.Debug().Msg("dumb:// scheme registered as local, secure, and CORS-enabled") | 537 | + | h.logger.Debug().Msg("dumb:// scheme registered on default WebContext") |
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | // Also register on custom WebContext if different from default | |||
| 541 | + | customCtx := wkCtx.Context() | |||
| 542 | + | if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) { | |||
| 543 | + | wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil) | |||
| 544 | + | ||||
| 545 | + | if secMgr := customCtx.GetSecurityManager(); secMgr != nil { | |||
| 546 | + | secMgr.RegisterUriSchemeAsLocal("dumb") | |||
| 547 | + | secMgr.RegisterUriSchemeAsSecure("dumb") | |||
| 548 | + | secMgr.RegisterUriSchemeAsCorsEnabled("dumb") | |||
| 549 | + | } | |||
| 550 | + | h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext") | |||
| 441 | } | 551 | } | ||
| 442 | 552 | ||||
| 443 | h.logger.Info().Msg("dumb:// scheme registered") | 553 | h.logger.Info().Msg("dumb:// scheme registered") | ||
M internal/infrastructure/webkit/webview.go +8 -3
| @@ -248,10 +248,15 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan | |||
| 248 | 248 | return nil, fmt.Errorf("webkit context not initialized") | |
| 249 | 249 | } | |
| 250 | 250 | ||
| 251 | - | // Use NetworkSession-aware constructor for persistent cookie storage | |
| 252 | - | inner := webkit.NewWebViewWithNetworkSession(wkCtx.NetworkSession()) | |
| 251 | + | // Use options constructor to pass both WebContext and NetworkSession. | |
| 252 | + | // This ensures WebViews use our custom WebContext (with memory pressure settings) | |
| 253 | + | // and the persistent NetworkSession for cookie/storage. | |
| 254 | + | inner := webkit.NewWebViewWithOptions(&webkit.WebViewOptions{ | |
| 255 | + | WebContext: wkCtx.Context(), | |
| 256 | + | NetworkSession: wkCtx.NetworkSession(), | |
| 257 | + | }) | |
| 253 | 258 | if inner == nil { | |
| 254 | - | return nil, fmt.Errorf("failed to create webkit webview with network session") | |
| 259 | + | return nil, fmt.Errorf("failed to create webkit webview with options") | |
| 255 | 260 | } | |
| 256 | 261 | ||
| 257 | 262 | // Set background color IMMEDIATELY after creation, before any other operations. | |
| @@ -248,10 +248,15 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan | |||||
| 248 | return nil, fmt.Errorf("webkit context not initialized") | 248 | return nil, fmt.Errorf("webkit context not initialized") | ||
| 249 | } | 249 | } | ||
| 250 | 250 | ||||
| 251 | - | // Use NetworkSession-aware constructor for persistent cookie storage | 251 | + | // Use options constructor to pass both WebContext and NetworkSession. |
| 252 | - | inner := webkit.NewWebViewWithNetworkSession(wkCtx.NetworkSession()) | 252 | + | // This ensures WebViews use our custom WebContext (with memory pressure settings) |
| 253 | + | // and the persistent NetworkSession for cookie/storage. | |||
| 254 | + | inner := webkit.NewWebViewWithOptions(&webkit.WebViewOptions{ | |||
| 255 | + | WebContext: wkCtx.Context(), | |||
| 256 | + | NetworkSession: wkCtx.NetworkSession(), | |||
| 257 | + | }) | |||
| 253 | if inner == nil { | 258 | if inner == nil { | ||
| 254 | - | return nil, fmt.Errorf("failed to create webkit webview with network session") | 259 | + | return nil, fmt.Errorf("failed to create webkit webview with options") |
| 255 | } | 260 | } | ||
| 256 | 261 | ||||
| 257 | // Set background color IMMEDIATELY after creation, before any other operations. | 262 | // Set background color IMMEDIATELY after creation, before any other operations. | ||
M webui/src/pages/ConfigPage.svelte +249 -0
| @@ -10,6 +10,7 @@ | |||
| 10 | 10 | import { Spinner } from "$lib/components/ui/spinner"; | |
| 11 | 11 | import * as Card from "$lib/components/ui/card"; | |
| 12 | 12 | import * as Tabs from "$lib/components/ui/tabs"; | |
| 13 | + | import { FlaskConical, RefreshCw } from "@lucide/svelte"; | |
| 13 | 14 | ||
| 14 | 15 | type ColorPalette = { | |
| 15 | 16 | background: string; | |
| @@ -26,6 +27,41 @@ | |||
| 26 | 27 | description: string; | |
| 27 | 28 | }; | |
| 28 | 29 | ||
| 30 | + | type ResolvedPerformance = { | |
| 31 | + | skia_cpu_threads: number; | |
| 32 | + | skia_gpu_threads: number; | |
| 33 | + | web_process_memory_mb: number; | |
| 34 | + | network_process_memory_mb: number; | |
| 35 | + | webview_pool_prewarm: number; | |
| 36 | + | conservative_threshold: number; | |
| 37 | + | strict_threshold: number; | |
| 38 | + | kill_threshold: number; | |
| 39 | + | }; | |
| 40 | + | ||
| 41 | + | type HardwareInfo = { | |
| 42 | + | cpu_cores: number; | |
| 43 | + | cpu_threads: number; | |
| 44 | + | total_ram_mb: number; | |
| 45 | + | gpu_vendor: string; | |
| 46 | + | gpu_name: string; | |
| 47 | + | vram_mb: number; | |
| 48 | + | }; | |
| 49 | + | ||
| 50 | + | type CustomPerformance = { | |
| 51 | + | skia_cpu_threads: number; | |
| 52 | + | skia_gpu_threads: number; | |
| 53 | + | web_process_memory_mb: number; | |
| 54 | + | network_process_memory_mb: number; | |
| 55 | + | webview_pool_prewarm: number; | |
| 56 | + | }; | |
| 57 | + | ||
| 58 | + | type PerformanceConfig = { | |
| 59 | + | profile: string; | |
| 60 | + | custom: CustomPerformance; | |
| 61 | + | resolved: ResolvedPerformance; | |
| 62 | + | hardware: HardwareInfo; | |
| 63 | + | }; | |
| 64 | + | ||
| 29 | 65 | type ConfigDTO = { | |
| 30 | 66 | appearance: { | |
| 31 | 67 | sans_font: string; | |
| @@ -36,11 +72,19 @@ | |||
| 36 | 72 | light_palette: ColorPalette; | |
| 37 | 73 | dark_palette: ColorPalette; | |
| 38 | 74 | }; | |
| 75 | + | performance: PerformanceConfig; | |
| 39 | 76 | default_ui_scale: number; | |
| 40 | 77 | default_search_engine: string; | |
| 41 | 78 | search_shortcuts: Record<string, SearchShortcut>; | |
| 42 | 79 | }; | |
| 43 | 80 | ||
| 81 | + | const PERFORMANCE_PROFILES = [ | |
| 82 | + | { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." }, | |
| 83 | + | { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." }, | |
| 84 | + | { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." }, | |
| 85 | + | { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." }, | |
| 86 | + | ]; | |
| 87 | + | ||
| 44 | 88 | const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [ | |
| 45 | 89 | { key: "background", label: "Background" }, | |
| 46 | 90 | { key: "surface", label: "Surface" }, | |
| @@ -62,6 +106,7 @@ | |||
| 62 | 106 | let saving = $state(false); | |
| 63 | 107 | let saveSuccess = $state(false); | |
| 64 | 108 | let saveError = $state<string | null>(null); | |
| 109 | + | let showRestartWarning = $state(false); | |
| 65 | 110 | let themeEvents = $state(0); | |
| 66 | 111 | let activeTab = $state("appearance"); | |
| 67 | 112 | ||
| @@ -158,6 +203,11 @@ | |||
| 158 | 203 | saveSuccess = true; | |
| 159 | 204 | saving = false; | |
| 160 | 205 | ||
| 206 | + | // Show restart warning if performance tab was active | |
| 207 | + | if (activeTab === "performance") { | |
| 208 | + | showRestartWarning = true; | |
| 209 | + | } | |
| 210 | + | ||
| 161 | 211 | // Refresh config from backend (in case watcher normalized values) | |
| 162 | 212 | reloadConfig(); | |
| 163 | 213 | ||
| @@ -250,6 +300,7 @@ | |||
| 250 | 300 | <Tabs.List class="bg-muted/60"> | |
| 251 | 301 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | |
| 252 | 302 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | |
| 303 | + | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | |
| 253 | 304 | </Tabs.List> | |
| 254 | 305 | </div> | |
| 255 | 306 | ||
| @@ -374,6 +425,191 @@ | |||
| 374 | 425 | </Card.Content> | |
| 375 | 426 | </Card.Root> | |
| 376 | 427 | </Tabs.Content> | |
| 428 | + | ||
| 429 | + | <Tabs.Content value="performance"> | |
| 430 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |
| 431 | + | <Card.Header> | |
| 432 | + | <Card.Title>Performance Profile</Card.Title> | |
| 433 | + | <Card.Description> | |
| 434 | + | WebKitGTK rendering and memory tuning presets. | |
| 435 | + | </Card.Description> | |
| 436 | + | </Card.Header> | |
| 437 | + | <Card.Content class="space-y-6"> | |
| 438 | + | <!-- Detected Hardware Card --> | |
| 439 | + | {#if config.performance?.hardware} | |
| 440 | + | {@const hw = config.performance.hardware} | |
| 441 | + | <div class="rounded-md border border-border bg-muted/30 px-4 py-3"> | |
| 442 | + | <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |
| 443 | + | Detected Hardware | |
| 444 | + | </div> | |
| 445 | + | <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm"> | |
| 446 | + | <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div> | |
| 447 | + | <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div> | |
| 448 | + | <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div> | |
| 449 | + | <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : (hw.gpu_vendor === 'intel' ? 'Shared' : '?')}</span></div> | |
| 450 | + | </div> | |
| 451 | + | </div> | |
| 452 | + | {/if} | |
| 453 | + | ||
| 454 | + | <!-- Experimental feature warning --> | |
| 455 | + | <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3"> | |
| 456 | + | <FlaskConical class="mt-0.5 size-4 shrink-0 text-info" /> | |
| 457 | + | <div class="space-y-1"> | |
| 458 | + | <p class="text-sm font-medium text-info">Experimental Feature</p> | |
| 459 | + | <p class="text-xs text-muted-foreground"> | |
| 460 | + | Performance profiles are experimental. Results may vary depending on your hardware and WebKitGTK version. | |
| 461 | + | </p> | |
| 462 | + | </div> | |
| 463 | + | </div> | |
| 464 | + | ||
| 465 | + | <!-- Profile selector --> | |
| 466 | + | {#if config.performance} | |
| 467 | + | <div class="space-y-4"> | |
| 468 | + | {#each PERFORMANCE_PROFILES as profile (profile.value)} | |
| 469 | + | <label | |
| 470 | + | class="flex cursor-pointer items-start gap-4 rounded-md border p-4 transition-colors hover:bg-muted/50 {config.performance.profile === profile.value ? 'border-primary bg-primary/5' : ''}" | |
| 471 | + | > | |
| 472 | + | <input | |
| 473 | + | type="radio" | |
| 474 | + | name="performance_profile" | |
| 475 | + | value={profile.value} | |
| 476 | + | checked={config.performance.profile === profile.value} | |
| 477 | + | onchange={() => config!.performance.profile = profile.value} | |
| 478 | + | class="mt-1 accent-primary" | |
| 479 | + | /> | |
| 480 | + | <div class="space-y-1"> | |
| 481 | + | <div class="font-medium">{profile.label}</div> | |
| 482 | + | <div class="text-sm text-muted-foreground">{profile.description}</div> | |
| 483 | + | </div> | |
| 484 | + | </label> | |
| 485 | + | {/each} | |
| 486 | + | </div> | |
| 487 | + | {:else} | |
| 488 | + | <div class="text-sm text-muted-foreground"> | |
| 489 | + | Performance settings not available. This may be due to a configuration error. | |
| 490 | + | </div> | |
| 491 | + | {/if} | |
| 492 | + | ||
| 493 | + | <!-- Custom profile fields --> | |
| 494 | + | {#if config.performance?.profile === "custom"} | |
| 495 | + | <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4"> | |
| 496 | + | <div class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |
| 497 | + | Custom Settings | |
| 498 | + | </div> | |
| 499 | + | <div class="grid gap-4 md:grid-cols-2"> | |
| 500 | + | <div class="space-y-2"> | |
| 501 | + | <Label for="skia_cpu_threads">Skia CPU Threads</Label> | |
| 502 | + | <Input | |
| 503 | + | id="skia_cpu_threads" | |
| 504 | + | type="number" | |
| 505 | + | min="0" | |
| 506 | + | max="8" | |
| 507 | + | bind:value={config.performance.custom.skia_cpu_threads} | |
| 508 | + | /> | |
| 509 | + | <p class="text-xs text-muted-foreground">0 = unset (WebKit default). Max 8.</p> | |
| 510 | + | </div> | |
| 511 | + | <div class="space-y-2"> | |
| 512 | + | <Label for="skia_gpu_threads">Skia GPU Threads</Label> | |
| 513 | + | <Input | |
| 514 | + | id="skia_gpu_threads" | |
| 515 | + | type="number" | |
| 516 | + | min="-1" | |
| 517 | + | bind:value={config.performance.custom.skia_gpu_threads} | |
| 518 | + | /> | |
| 519 | + | <p class="text-xs text-muted-foreground">-1 = unset, 0 = disable GPU tiles.</p> | |
| 520 | + | </div> | |
| 521 | + | <div class="space-y-2"> | |
| 522 | + | <Label for="web_process_memory">Web Process Memory (MB)</Label> | |
| 523 | + | <Input | |
| 524 | + | id="web_process_memory" | |
| 525 | + | type="number" | |
| 526 | + | min="0" | |
| 527 | + | step="256" | |
| 528 | + | bind:value={config.performance.custom.web_process_memory_mb} | |
| 529 | + | /> | |
| 530 | + | <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p> | |
| 531 | + | </div> | |
| 532 | + | <div class="space-y-2"> | |
| 533 | + | <Label for="network_process_memory">Network Process Memory (MB)</Label> | |
| 534 | + | <Input | |
| 535 | + | id="network_process_memory" | |
| 536 | + | type="number" | |
| 537 | + | min="0" | |
| 538 | + | step="128" | |
| 539 | + | bind:value={config.performance.custom.network_process_memory_mb} | |
| 540 | + | /> | |
| 541 | + | <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p> | |
| 542 | + | </div> | |
| 543 | + | <div class="space-y-2"> | |
| 544 | + | <Label for="webview_pool_prewarm">WebView Pool Prewarm</Label> | |
| 545 | + | <Input | |
| 546 | + | id="webview_pool_prewarm" | |
| 547 | + | type="number" | |
| 548 | + | min="0" | |
| 549 | + | max="20" | |
| 550 | + | bind:value={config.performance.custom.webview_pool_prewarm} | |
| 551 | + | /> | |
| 552 | + | <p class="text-xs text-muted-foreground">Pre-created WebViews at startup.</p> | |
| 553 | + | </div> | |
| 554 | + | </div> | |
| 555 | + | </div> | |
| 556 | + | {/if} | |
| 557 | + | ||
| 558 | + | <!-- Resolved values display --> | |
| 559 | + | {#if config.performance?.resolved} | |
| 560 | + | <div class="space-y-3"> | |
| 561 | + | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> | |
| 562 | + | Resolved Settings (applied on restart) | |
| 563 | + | </div> | |
| 564 | + | <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4 text-sm"> | |
| 565 | + | <div class="space-y-1"> | |
| 566 | + | <div class="flex justify-between"> | |
| 567 | + | <span class="font-medium">Skia CPU Threads</span> | |
| 568 | + | <span class="font-mono">{config.performance.resolved.skia_cpu_threads === 0 ? 'unset' : config.performance.resolved.skia_cpu_threads}</span> | |
| 569 | + | </div> | |
| 570 | + | <p class="text-xs text-muted-foreground">Parallel threads for CPU-based 2D rendering. More threads = faster paint operations.</p> | |
| 571 | + | </div> | |
| 572 | + | <div class="space-y-1"> | |
| 573 | + | <div class="flex justify-between"> | |
| 574 | + | <span class="font-medium">Skia GPU Threads</span> | |
| 575 | + | <span class="font-mono">{config.performance.resolved.skia_gpu_threads === -1 ? 'unset' : config.performance.resolved.skia_gpu_threads}</span> | |
| 576 | + | </div> | |
| 577 | + | <p class="text-xs text-muted-foreground">Threads for GPU texture uploads and shader compilation.</p> | |
| 578 | + | </div> | |
| 579 | + | <div class="space-y-1"> | |
| 580 | + | <div class="flex justify-between"> | |
| 581 | + | <span class="font-medium">Web Process Memory</span> | |
| 582 | + | <span class="font-mono">{config.performance.resolved.web_process_memory_mb === 0 ? 'unset' : config.performance.resolved.web_process_memory_mb + ' MB'}</span> | |
| 583 | + | </div> | |
| 584 | + | <p class="text-xs text-muted-foreground">Memory limit for web content processes (JavaScript, DOM, rendering).</p> | |
| 585 | + | </div> | |
| 586 | + | <div class="space-y-1"> | |
| 587 | + | <div class="flex justify-between"> | |
| 588 | + | <span class="font-medium">Network Process Memory</span> | |
| 589 | + | <span class="font-mono">{config.performance.resolved.network_process_memory_mb === 0 ? 'unset' : config.performance.resolved.network_process_memory_mb + ' MB'}</span> | |
| 590 | + | </div> | |
| 591 | + | <p class="text-xs text-muted-foreground">Memory limit for network operations (HTTP cache, connections).</p> | |
| 592 | + | </div> | |
| 593 | + | <div class="space-y-1"> | |
| 594 | + | <div class="flex justify-between"> | |
| 595 | + | <span class="font-medium">WebView Pool Prewarm</span> | |
| 596 | + | <span class="font-mono">{config.performance.resolved.webview_pool_prewarm}</span> | |
| 597 | + | </div> | |
| 598 | + | <p class="text-xs text-muted-foreground">Pre-created WebViews at startup for faster new tab/pane creation.</p> | |
| 599 | + | </div> | |
| 600 | + | <div class="space-y-1"> | |
| 601 | + | <div class="flex justify-between"> | |
| 602 | + | <span class="font-medium">Memory Kill Threshold</span> | |
| 603 | + | <span class="font-mono">{config.performance.resolved.kill_threshold === -1 ? 'never' : (config.performance.resolved.kill_threshold * 100).toFixed(0) + '%'}</span> | |
| 604 | + | </div> | |
| 605 | + | <p class="text-xs text-muted-foreground">When memory usage exceeds this threshold, WebKit may terminate the process to free memory.</p> | |
| 606 | + | </div> | |
| 607 | + | </div> | |
| 608 | + | </div> | |
| 609 | + | {/if} | |
| 610 | + | </Card.Content> | |
| 611 | + | </Card.Root> | |
| 612 | + | </Tabs.Content> | |
| 377 | 613 | </Tabs.Root> | |
| 378 | 614 | ||
| 379 | 615 | <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4"> | |
| @@ -424,6 +660,19 @@ | |||
| 424 | 660 | {saveError} | |
| 425 | 661 | </div> | |
| 426 | 662 | {/if} | |
| 663 | + | ||
| 664 | + | <!-- Restart warning after saving performance settings --> | |
| 665 | + | {#if showRestartWarning} | |
| 666 | + | <div class="flex items-start gap-3 border border-warning/30 bg-warning/10 px-4 py-3"> | |
| 667 | + | <RefreshCw class="mt-0.5 size-4 shrink-0 text-warning" /> | |
| 668 | + | <div class="space-y-1"> | |
| 669 | + | <p class="text-sm font-medium text-warning">Restart Required</p> | |
| 670 | + | <p class="text-xs text-muted-foreground"> | |
| 671 | + | Performance settings are applied at browser startup. Restart Dumber for changes to take effect. | |
| 672 | + | </p> | |
| 673 | + | </div> | |
| 674 | + | </div> | |
| 675 | + | {/if} | |
| 427 | 676 | {/if} | |
| 428 | 677 | </div> | |
| 429 | 678 | </ConfigShell> | |
| @@ -10,6 +10,7 @@ | |||||
| 10 | import { Spinner } from "$lib/components/ui/spinner"; | 10 | import { Spinner } from "$lib/components/ui/spinner"; | ||
| 11 | import * as Card from "$lib/components/ui/card"; | 11 | import * as Card from "$lib/components/ui/card"; | ||
| 12 | import * as Tabs from "$lib/components/ui/tabs"; | 12 | import * as Tabs from "$lib/components/ui/tabs"; | ||
| 13 | + | import { FlaskConical, RefreshCw } from "@lucide/svelte"; | |||
| 13 | 14 | ||||
| 14 | type ColorPalette = { | 15 | type ColorPalette = { | ||
| 15 | background: string; | 16 | background: string; | ||
| @@ -26,6 +27,41 @@ | |||||
| 26 | description: string; | 27 | description: string; | ||
| 27 | }; | 28 | }; | ||
| 28 | 29 | ||||
| 30 | + | type ResolvedPerformance = { | |||
| 31 | + | skia_cpu_threads: number; | |||
| 32 | + | skia_gpu_threads: number; | |||
| 33 | + | web_process_memory_mb: number; | |||
| 34 | + | network_process_memory_mb: number; | |||
| 35 | + | webview_pool_prewarm: number; | |||
| 36 | + | conservative_threshold: number; | |||
| 37 | + | strict_threshold: number; | |||
| 38 | + | kill_threshold: number; | |||
| 39 | + | }; | |||
| 40 | + | ||||
| 41 | + | type HardwareInfo = { | |||
| 42 | + | cpu_cores: number; | |||
| 43 | + | cpu_threads: number; | |||
| 44 | + | total_ram_mb: number; | |||
| 45 | + | gpu_vendor: string; | |||
| 46 | + | gpu_name: string; | |||
| 47 | + | vram_mb: number; | |||
| 48 | + | }; | |||
| 49 | + | ||||
| 50 | + | type CustomPerformance = { | |||
| 51 | + | skia_cpu_threads: number; | |||
| 52 | + | skia_gpu_threads: number; | |||
| 53 | + | web_process_memory_mb: number; | |||
| 54 | + | network_process_memory_mb: number; | |||
| 55 | + | webview_pool_prewarm: number; | |||
| 56 | + | }; | |||
| 57 | + | ||||
| 58 | + | type PerformanceConfig = { | |||
| 59 | + | profile: string; | |||
| 60 | + | custom: CustomPerformance; | |||
| 61 | + | resolved: ResolvedPerformance; | |||
| 62 | + | hardware: HardwareInfo; | |||
| 63 | + | }; | |||
| 64 | + | ||||
| 29 | type ConfigDTO = { | 65 | type ConfigDTO = { | ||
| 30 | appearance: { | 66 | appearance: { | ||
| 31 | sans_font: string; | 67 | sans_font: string; | ||
| @@ -36,11 +72,19 @@ | |||||
| 36 | light_palette: ColorPalette; | 72 | light_palette: ColorPalette; | ||
| 37 | dark_palette: ColorPalette; | 73 | dark_palette: ColorPalette; | ||
| 38 | }; | 74 | }; | ||
| 75 | + | performance: PerformanceConfig; | |||
| 39 | default_ui_scale: number; | 76 | default_ui_scale: number; | ||
| 40 | default_search_engine: string; | 77 | default_search_engine: string; | ||
| 41 | search_shortcuts: Record<string, SearchShortcut>; | 78 | search_shortcuts: Record<string, SearchShortcut>; | ||
| 42 | }; | 79 | }; | ||
| 43 | 80 | ||||
| 81 | + | const PERFORMANCE_PROFILES = [ | |||
| 82 | + | { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." }, | |||
| 83 | + | { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." }, | |||
| 84 | + | { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." }, | |||
| 85 | + | { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." }, | |||
| 86 | + | ]; | |||
| 87 | + | ||||
| 44 | const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [ | 88 | const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [ | ||
| 45 | { key: "background", label: "Background" }, | 89 | { key: "background", label: "Background" }, | ||
| 46 | { key: "surface", label: "Surface" }, | 90 | { key: "surface", label: "Surface" }, | ||
| @@ -62,6 +106,7 @@ | |||||
| 62 | let saving = $state(false); | 106 | let saving = $state(false); | ||
| 63 | let saveSuccess = $state(false); | 107 | let saveSuccess = $state(false); | ||
| 64 | let saveError = $state<string | null>(null); | 108 | let saveError = $state<string | null>(null); | ||
| 109 | + | let showRestartWarning = $state(false); | |||
| 65 | let themeEvents = $state(0); | 110 | let themeEvents = $state(0); | ||
| 66 | let activeTab = $state("appearance"); | 111 | let activeTab = $state("appearance"); | ||
| 67 | 112 | ||||
| @@ -158,6 +203,11 @@ | |||||
| 158 | saveSuccess = true; | 203 | saveSuccess = true; | ||
| 159 | saving = false; | 204 | saving = false; | ||
| 160 | 205 | ||||
| 206 | + | // Show restart warning if performance tab was active | |||
| 207 | + | if (activeTab === "performance") { | |||
| 208 | + | showRestartWarning = true; | |||
| 209 | + | } | |||
| 210 | + | ||||
| 161 | // Refresh config from backend (in case watcher normalized values) | 211 | // Refresh config from backend (in case watcher normalized values) | ||
| 162 | reloadConfig(); | 212 | reloadConfig(); | ||
| 163 | 213 | ||||
| @@ -250,6 +300,7 @@ | |||||
| 250 | <Tabs.List class="bg-muted/60"> | 300 | <Tabs.List class="bg-muted/60"> | ||
| 251 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | 301 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | ||
| 252 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | 302 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | ||
| 303 | + | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | |||
| 253 | </Tabs.List> | 304 | </Tabs.List> | ||
| 254 | </div> | 305 | </div> | ||
| 255 | 306 | ||||
| @@ -374,6 +425,191 @@ | |||||
| 374 | </Card.Content> | 425 | </Card.Content> | ||
| 375 | </Card.Root> | 426 | </Card.Root> | ||
| 376 | </Tabs.Content> | 427 | </Tabs.Content> | ||
| 428 | + | ||||
| 429 | + | <Tabs.Content value="performance"> | |||
| 430 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |||
| 431 | + | <Card.Header> | |||
| 432 | + | <Card.Title>Performance Profile</Card.Title> | |||
| 433 | + | <Card.Description> | |||
| 434 | + | WebKitGTK rendering and memory tuning presets. | |||
| 435 | + | </Card.Description> | |||
| 436 | + | </Card.Header> | |||
| 437 | + | <Card.Content class="space-y-6"> | |||
| 438 | + | <!-- Detected Hardware Card --> | |||
| 439 | + | {#if config.performance?.hardware} | |||
| 440 | + | {@const hw = config.performance.hardware} | |||
| 441 | + | <div class="rounded-md border border-border bg-muted/30 px-4 py-3"> | |||
| 442 | + | <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 443 | + | Detected Hardware | |||
| 444 | + | </div> | |||
| 445 | + | <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm"> | |||
| 446 | + | <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div> | |||
| 447 | + | <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div> | |||
| 448 | + | <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div> | |||
| 449 | + | <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : (hw.gpu_vendor === 'intel' ? 'Shared' : '?')}</span></div> | |||
| 450 | + | </div> | |||
| 451 | + | </div> | |||
| 452 | + | {/if} | |||
| 453 | + | ||||
| 454 | + | <!-- Experimental feature warning --> | |||
| 455 | + | <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3"> | |||
| 456 | + | <FlaskConical class="mt-0.5 size-4 shrink-0 text-info" /> | |||
| 457 | + | <div class="space-y-1"> | |||
| 458 | + | <p class="text-sm font-medium text-info">Experimental Feature</p> | |||
| 459 | + | <p class="text-xs text-muted-foreground"> | |||
| 460 | + | Performance profiles are experimental. Results may vary depending on your hardware and WebKitGTK version. | |||
| 461 | + | </p> | |||
| 462 | + | </div> | |||
| 463 | + | </div> | |||
| 464 | + | ||||
| 465 | + | <!-- Profile selector --> | |||
| 466 | + | {#if config.performance} | |||
| 467 | + | <div class="space-y-4"> | |||
| 468 | + | {#each PERFORMANCE_PROFILES as profile (profile.value)} | |||
| 469 | + | <label | |||
| 470 | + | class="flex cursor-pointer items-start gap-4 rounded-md border p-4 transition-colors hover:bg-muted/50 {config.performance.profile === profile.value ? 'border-primary bg-primary/5' : ''}" | |||
| 471 | + | > | |||
| 472 | + | <input | |||
| 473 | + | type="radio" | |||
| 474 | + | name="performance_profile" | |||
| 475 | + | value={profile.value} | |||
| 476 | + | checked={config.performance.profile === profile.value} | |||
| 477 | + | onchange={() => config!.performance.profile = profile.value} | |||
| 478 | + | class="mt-1 accent-primary" | |||
| 479 | + | /> | |||
| 480 | + | <div class="space-y-1"> | |||
| 481 | + | <div class="font-medium">{profile.label}</div> | |||
| 482 | + | <div class="text-sm text-muted-foreground">{profile.description}</div> | |||
| 483 | + | </div> | |||
| 484 | + | </label> | |||
| 485 | + | {/each} | |||
| 486 | + | </div> | |||
| 487 | + | {:else} | |||
| 488 | + | <div class="text-sm text-muted-foreground"> | |||
| 489 | + | Performance settings not available. This may be due to a configuration error. | |||
| 490 | + | </div> | |||
| 491 | + | {/if} | |||
| 492 | + | ||||
| 493 | + | <!-- Custom profile fields --> | |||
| 494 | + | {#if config.performance?.profile === "custom"} | |||
| 495 | + | <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4"> | |||
| 496 | + | <div class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 497 | + | Custom Settings | |||
| 498 | + | </div> | |||
| 499 | + | <div class="grid gap-4 md:grid-cols-2"> | |||
| 500 | + | <div class="space-y-2"> | |||
| 501 | + | <Label for="skia_cpu_threads">Skia CPU Threads</Label> | |||
| 502 | + | <Input | |||
| 503 | + | id="skia_cpu_threads" | |||
| 504 | + | type="number" | |||
| 505 | + | min="0" | |||
| 506 | + | max="8" | |||
| 507 | + | bind:value={config.performance.custom.skia_cpu_threads} | |||
| 508 | + | /> | |||
| 509 | + | <p class="text-xs text-muted-foreground">0 = unset (WebKit default). Max 8.</p> | |||
| 510 | + | </div> | |||
| 511 | + | <div class="space-y-2"> | |||
| 512 | + | <Label for="skia_gpu_threads">Skia GPU Threads</Label> | |||
| 513 | + | <Input | |||
| 514 | + | id="skia_gpu_threads" | |||
| 515 | + | type="number" | |||
| 516 | + | min="-1" | |||
| 517 | + | bind:value={config.performance.custom.skia_gpu_threads} | |||
| 518 | + | /> | |||
| 519 | + | <p class="text-xs text-muted-foreground">-1 = unset, 0 = disable GPU tiles.</p> | |||
| 520 | + | </div> | |||
| 521 | + | <div class="space-y-2"> | |||
| 522 | + | <Label for="web_process_memory">Web Process Memory (MB)</Label> | |||
| 523 | + | <Input | |||
| 524 | + | id="web_process_memory" | |||
| 525 | + | type="number" | |||
| 526 | + | min="0" | |||
| 527 | + | step="256" | |||
| 528 | + | bind:value={config.performance.custom.web_process_memory_mb} | |||
| 529 | + | /> | |||
| 530 | + | <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p> | |||
| 531 | + | </div> | |||
| 532 | + | <div class="space-y-2"> | |||
| 533 | + | <Label for="network_process_memory">Network Process Memory (MB)</Label> | |||
| 534 | + | <Input | |||
| 535 | + | id="network_process_memory" | |||
| 536 | + | type="number" | |||
| 537 | + | min="0" | |||
| 538 | + | step="128" | |||
| 539 | + | bind:value={config.performance.custom.network_process_memory_mb} | |||
| 540 | + | /> | |||
| 541 | + | <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p> | |||
| 542 | + | </div> | |||
| 543 | + | <div class="space-y-2"> | |||
| 544 | + | <Label for="webview_pool_prewarm">WebView Pool Prewarm</Label> | |||
| 545 | + | <Input | |||
| 546 | + | id="webview_pool_prewarm" | |||
| 547 | + | type="number" | |||
| 548 | + | min="0" | |||
| 549 | + | max="20" | |||
| 550 | + | bind:value={config.performance.custom.webview_pool_prewarm} | |||
| 551 | + | /> | |||
| 552 | + | <p class="text-xs text-muted-foreground">Pre-created WebViews at startup.</p> | |||
| 553 | + | </div> | |||
| 554 | + | </div> | |||
| 555 | + | </div> | |||
| 556 | + | {/if} | |||
| 557 | + | ||||
| 558 | + | <!-- Resolved values display --> | |||
| 559 | + | {#if config.performance?.resolved} | |||
| 560 | + | <div class="space-y-3"> | |||
| 561 | + | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> | |||
| 562 | + | Resolved Settings (applied on restart) | |||
| 563 | + | </div> | |||
| 564 | + | <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4 text-sm"> | |||
| 565 | + | <div class="space-y-1"> | |||
| 566 | + | <div class="flex justify-between"> | |||
| 567 | + | <span class="font-medium">Skia CPU Threads</span> | |||
| 568 | + | <span class="font-mono">{config.performance.resolved.skia_cpu_threads === 0 ? 'unset' : config.performance.resolved.skia_cpu_threads}</span> | |||
| 569 | + | </div> | |||
| 570 | + | <p class="text-xs text-muted-foreground">Parallel threads for CPU-based 2D rendering. More threads = faster paint operations.</p> | |||
| 571 | + | </div> | |||
| 572 | + | <div class="space-y-1"> | |||
| 573 | + | <div class="flex justify-between"> | |||
| 574 | + | <span class="font-medium">Skia GPU Threads</span> | |||
| 575 | + | <span class="font-mono">{config.performance.resolved.skia_gpu_threads === -1 ? 'unset' : config.performance.resolved.skia_gpu_threads}</span> | |||
| 576 | + | </div> | |||
| 577 | + | <p class="text-xs text-muted-foreground">Threads for GPU texture uploads and shader compilation.</p> | |||
| 578 | + | </div> | |||
| 579 | + | <div class="space-y-1"> | |||
| 580 | + | <div class="flex justify-between"> | |||
| 581 | + | <span class="font-medium">Web Process Memory</span> | |||
| 582 | + | <span class="font-mono">{config.performance.resolved.web_process_memory_mb === 0 ? 'unset' : config.performance.resolved.web_process_memory_mb + ' MB'}</span> | |||
| 583 | + | </div> | |||
| 584 | + | <p class="text-xs text-muted-foreground">Memory limit for web content processes (JavaScript, DOM, rendering).</p> | |||
| 585 | + | </div> | |||
| 586 | + | <div class="space-y-1"> | |||
| 587 | + | <div class="flex justify-between"> | |||
| 588 | + | <span class="font-medium">Network Process Memory</span> | |||
| 589 | + | <span class="font-mono">{config.performance.resolved.network_process_memory_mb === 0 ? 'unset' : config.performance.resolved.network_process_memory_mb + ' MB'}</span> | |||
| 590 | + | </div> | |||
| 591 | + | <p class="text-xs text-muted-foreground">Memory limit for network operations (HTTP cache, connections).</p> | |||
| 592 | + | </div> | |||
| 593 | + | <div class="space-y-1"> | |||
| 594 | + | <div class="flex justify-between"> | |||
| 595 | + | <span class="font-medium">WebView Pool Prewarm</span> | |||
| 596 | + | <span class="font-mono">{config.performance.resolved.webview_pool_prewarm}</span> | |||
| 597 | + | </div> | |||
| 598 | + | <p class="text-xs text-muted-foreground">Pre-created WebViews at startup for faster new tab/pane creation.</p> | |||
| 599 | + | </div> | |||
| 600 | + | <div class="space-y-1"> | |||
| 601 | + | <div class="flex justify-between"> | |||
| 602 | + | <span class="font-medium">Memory Kill Threshold</span> | |||
| 603 | + | <span class="font-mono">{config.performance.resolved.kill_threshold === -1 ? 'never' : (config.performance.resolved.kill_threshold * 100).toFixed(0) + '%'}</span> | |||
| 604 | + | </div> | |||
| 605 | + | <p class="text-xs text-muted-foreground">When memory usage exceeds this threshold, WebKit may terminate the process to free memory.</p> | |||
| 606 | + | </div> | |||
| 607 | + | </div> | |||
| 608 | + | </div> | |||
| 609 | + | {/if} | |||
| 610 | + | </Card.Content> | |||
| 611 | + | </Card.Root> | |||
| 612 | + | </Tabs.Content> | |||
| 377 | </Tabs.Root> | 613 | </Tabs.Root> | ||
| 378 | 614 | ||||
| 379 | <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4"> | 615 | <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4"> | ||
| @@ -424,6 +660,19 @@ | |||||
| 424 | {saveError} | 660 | {saveError} | ||
| 425 | </div> | 661 | </div> | ||
| 426 | {/if} | 662 | {/if} | ||
| 663 | + | ||||
| 664 | + | <!-- Restart warning after saving performance settings --> | |||
| 665 | + | {#if showRestartWarning} | |||
| 666 | + | <div class="flex items-start gap-3 border border-warning/30 bg-warning/10 px-4 py-3"> | |||
| 667 | + | <RefreshCw class="mt-0.5 size-4 shrink-0 text-warning" /> | |||
| 668 | + | <div class="space-y-1"> | |||
| 669 | + | <p class="text-sm font-medium text-warning">Restart Required</p> | |||
| 670 | + | <p class="text-xs text-muted-foreground"> | |||
| 671 | + | Performance settings are applied at browser startup. Restart Dumber for changes to take effect. | |||
| 672 | + | </p> | |||
| 673 | + | </div> | |||
| 674 | + | </div> | |||
| 675 | + | {/if} | |||
| 427 | {/if} | 676 | {/if} | ||
| 428 | </div> | 677 | </div> | ||
| 429 | </ConfigShell> | 678 | </ConfigShell> | ||