detached 4c18f79

Merge pull request #65 from bnema/feature/issue-62-webkitgtk-performance-tuning

feat: WebKitGTK performance profile tuning
commit
4c18f793930bf1f1f2ae05bf82185ecea487aa41
author
bnema · · 2025-12-29 17:44 UTC
committer
GitHub · · 2025-12-29 17:44 UTC
merge of
a85da87 + 6e1d371 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
26 files · +3094 -41
M .mockery.yaml +3 -0
@@ -18,6 +18,9 @@ packages:
1818 WebView: {}
1919 WebViewFactory: {}
2020 WebViewPool: {}
21+ MemoryPressureApplier: {}
22+ WebKitContextProvider: {}
23+ HardwareSurveyor: {}
2124 github.com/bnema/dumber/internal/domain/repository:
2225 config:
2326 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
491491 dumber config migrate --yes
492492 ```
493493
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+
494584 ## Environment Variables
495585
496586 All config values can be overridden via environment variables with the prefix `DUMBER_`:
@@ -491,6 +491,96 @@ dumber config migrate
491 dumber config migrate --yes491 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 Variables584 ## 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
33 go 1.25.3
44
55 require (
6- github.com/bnema/puregotk-webkit v0.0.7
6+ github.com/bnema/puregotk-webkit v0.0.9
77 github.com/charmbracelet/bubbles v0.21.0
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
@@ -19,6 +19,7 @@ require (
1919 github.com/stretchr/testify v1.11.1
2020 golang.org/x/image v0.34.0
2121 golang.org/x/sys v0.38.0
22+ gopkg.in/yaml.v3 v3.0.1
2223 )
2324
2425 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -64,5 +65,4 @@ require (
6465 go.yaml.in/yaml/v3 v3.0.4 // indirect
6566 golang.org/x/sync v0.19.0 // indirect
6667 golang.org/x/text v0.32.0 // indirect
67- gopkg.in/yaml.v3 v3.0.1 // indirect
6868 )
@@ -3,7 +3,7 @@ module github.com/bnema/dumber
3 go 1.25.33 go 1.25.3
4 4
5 require (5 require (
6- github.com/bnema/puregotk-webkit v0.0.76+ github.com/bnema/puregotk-webkit v0.0.9
7 github.com/charmbracelet/bubbles v0.21.07 github.com/charmbracelet/bubbles v0.21.0
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
@@ -19,6 +19,7 @@ require (
19 github.com/stretchr/testify v1.11.119 github.com/stretchr/testify v1.11.1
20 golang.org/x/image v0.34.020 golang.org/x/image v0.34.0
21 golang.org/x/sys v0.38.021 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-73930527faa325 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 // indirect65 go.yaml.in/yaml/v3 v3.0.4 // indirect
65 golang.org/x/sync v0.19.0 // indirect66 golang.org/x/sync v0.19.0 // indirect
66 golang.org/x/text v0.32.0 // indirect67 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)
638638 return _c
639639 }
640640
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+
641719 // 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.
642720 // The first argument is typically a *testing.T value.
643721 func NewMockSessionLogger(t interface {
@@ -940,6 +1018,414 @@ func (_c *MockSessionSpawner_SpawnWithSession_Call) RunAndReturn(run func(sessio
9401018 return _c
9411019 }
9421020
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+
9431429 // 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.
9441430 // The first argument is typically a *testing.T value.
9451431 func NewMockWebView(t interface {
@@ -638,6 +638,84 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool)
638 return _c638 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 _c1018 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 {
4646 ShowFPS bool
4747 SampleMemory bool
4848 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
4959 }
5060
5161 // RenderingEnvManager configures rendering environment variables
@@ -46,6 +46,16 @@ type RenderingEnvSettings struct {
46 ShowFPS bool46 ShowFPS bool
47 SampleMemory bool47 SampleMemory bool
48 DebugFrames bool48 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 variables61 // 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"
55 // WebUIConfig represents the subset of configuration editable in dumb://config.
66 type WebUIConfig struct {
77 Appearance WebUIAppearanceConfig `json:"appearance"`
8+ Performance WebUIPerformanceConfig `json:"performance"`
89 DefaultUIScale float64 `json:"default_ui_scale"`
910 DefaultSearchEngine string `json:"default_search_engine"`
1011 SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
1112 }
1213
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+
1329 type WebUIAppearanceConfig struct {
1430 SansFont string `json:"sans_font"`
1531 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(
3232 themeManager *theme.Manager,
3333 logger zerolog.Logger,
3434 ) 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+
3555 // CRITICAL: Configure rendering environment BEFORE WebKit/GTK initialization.
3656 // Environment variables must be set before GTK/WebKit/GStreamer initializes.
3757 renderEnv := env.NewManager()
@@ -58,6 +78,11 @@ func BuildWebKitStack(
5878 ShowFPS: cfg.Rendering.ShowFPS,
5979 SampleMemory: cfg.Rendering.SampleMemory,
6080 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,
6186 }
6287 if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
6388 logger.Warn().Err(err).Msg("failed to apply rendering environment")
@@ -67,7 +92,35 @@ func BuildWebKitStack(
6792 Interface("vars", renderEnv.GetAppliedVars()).
6893 Msg("rendering environment configured")
6994
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)
71124 if err != nil {
72125 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
73126 }
@@ -106,9 +159,9 @@ func BuildWebKitStack(
106159
107160 messageRouter := webkit.NewMessageRouter(ctx)
108161 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
112165 }
113166 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
114167 // Ensure prewarmed WebViews pick up the theme background color.
@@ -135,3 +188,21 @@ func BuildWebKitStack(
135188 FilterManager: filterManager,
136189 }
137190 }
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 set162+ // Override prewarm count from resolved profile settings
110- if cfg.Performance.WebViewPoolPrewarmCount > 0 {163+ if perfSettings.WebViewPoolPrewarmCount > 0 {
111- poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount164+ 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 (
6565 // Performance defaults
6666 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
6767 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
6875 )
6976
7077 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -282,8 +289,26 @@ func DefaultConfig() *Config {
282289 Prefix: "",
283290 },
284291 Performance: PerformanceConfig{
292+ Profile: ProfileDefault, // Use WebKit defaults, no tuning
285293 ZoomCacheSize: defaultZoomCacheSize,
286294 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,
287312 },
288313 Update: UpdateConfig{
289314 EnableOnStartup: true, // Check for updates on startup by default
@@ -65,6 +65,13 @@ const (
65 // Performance defaults65 // 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 startup67 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 error77 // 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 default314 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) {
212212 }
213213
214214 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+ }
215229 }
216230
217231 // Get returns the current configuration (thread-safe).
@@ -252,6 +266,9 @@ func (m *Manager) Save(cfg *Config) error {
252266 m.viper.Set("default_ui_scale", cfg.DefaultUIScale)
253267 m.viper.Set("default_search_engine", cfg.DefaultSearchEngine)
254268
269+ // Performance profile (requires browser restart to take effect)
270+ m.viper.Set("performance.profile", string(cfg.Performance.Profile))
271+
255272 if err := m.viper.WriteConfig(); err != nil {
256273 return fmt.Errorf("failed to write config: %w", err)
257274 }
@@ -316,6 +333,7 @@ func (m *Manager) setDefaults() {
316333 m.setRuntimeDefaults(defaults)
317334 m.setSessionDefaults(defaults)
318335 m.setUpdateDefaults(defaults)
336+ m.setPerformanceDefaults(defaults)
319337 }
320338
321339 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -461,6 +479,29 @@ func (m *Manager) setUpdateDefaults(defaults *Config) {
461479 m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
462480 }
463481
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+
464505 // New returns a new default configuration instance.
465506 // This is a convenience function for getting default config without the full manager.
466507 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 (
125125 GLRenderingModeNone GLRenderingMode = "none"
126126 )
127127
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+
128142 // ThemeDefault is the default theme setting (follows system).
129143 const ThemeDefault = "default"
130144
@@ -181,6 +195,12 @@ type HistoryConfig struct {
181195 // PerformanceConfig holds internal performance tuning options.
182196 // These are not exposed in dumb://config UI but can be set in config file.
183197 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+
184204 // ZoomCacheSize is the number of domain zoom levels to cache in memory.
185205 // Higher values reduce database queries but use more memory.
186206 // Default: 256 domains (~20KB memory)
@@ -188,8 +208,64 @@ type PerformanceConfig struct {
188208
189209 // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup.
190210 // Higher values speed up initial tab creation but use more memory.
191- // Default: 4
211+ // Default: 4 (custom mode), computed from profile otherwise
192212 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
193269 }
194270
195271 // 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: 4211+ // 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 line212 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 {
2828 validationErrors = append(validationErrors, validateRendering(config)...)
2929 validationErrors = append(validationErrors, validateColorScheme(config)...)
3030 validationErrors = append(validationErrors, validateSession(config)...)
31+ validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
3132
3233 // If there are validation errors, return them
3334 if len(validationErrors) > 0 {
@@ -347,3 +348,47 @@ func validateSession(config *Config) []string {
347348 }
348349 return validationErrors
349350 }
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 them33 // 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 validationErrors349 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 {
1515 return &WebUIConfigGateway{mgr: mgr}
1616 }
1717
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+
1836 func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {
1937 _ = ctx
2038 if g == nil || g.mgr == nil {
@@ -60,5 +78,19 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUI
6078 current.SearchShortcuts = shortcuts
6179 }
6280
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+
6395 return g.mgr.Save(current)
6496 }
@@ -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 _ = ctx37 _ = 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 = shortcuts78 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
5454 m.applyWebKitEnv(settings)
5555 m.applyGTKEnv(settings)
5656 m.applyDebugEnv(settings)
57+ m.applySkiaEnv(settings)
5758
5859 log.Debug().
5960 Interface("vars", m.appliedVars).
@@ -162,6 +163,23 @@ func (m *Manager) applyDebugEnv(settings port.RenderingEnvSettings) {
162163 }
163164 }
164165
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+
165183 func (m *Manager) setEnv(key, value string) {
166184 _ = os.Setenv(key, value)
167185 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] = value185 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 (
66 "path/filepath"
77 "sync"
88
9+ "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/logging"
1011 "github.com/bnema/puregotk-webkit/webkit"
1112 "github.com/rs/zerolog"
@@ -34,34 +35,68 @@ type WebKitContext struct {
3435 // The dataDir and cacheDir are used for cookie storage, cache, and other persistent data.
3536 // This MUST be called before creating any WebViews to ensure they use persistent storage.
3637 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) {
3747 log := logging.FromContext(ctx).With().Str("component", "webkit-context").Logger()
3848
39- if dataDir == "" {
49+ if opts.DataDir == "" {
4050 return nil, fmt.Errorf("data directory cannot be empty")
4151 }
42- if cacheDir == "" {
52+ if opts.CacheDir == "" {
4353 return nil, fmt.Errorf("cache directory cannot be empty")
4454 }
4555
4656 wkCtx := &WebKitContext{
47- dataDir: dataDir,
48- cacheDir: cacheDir,
57+ dataDir: opts.DataDir,
58+ cacheDir: opts.CacheDir,
4959 logger: log,
5060 }
5161
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+
5271 // Create persistent network session FIRST
5372 // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"
5473 if err := wkCtx.initNetworkSession(); err != nil {
5574 return nil, fmt.Errorf("failed to init network session: %w", err)
5675 }
5776
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
6095 if wkCtx.webContext == nil {
61- wkCtx.webContext = webkit.NewWebContext()
96+ wkCtx.webContext = webkit.WebContextGetDefault()
6297 }
6398 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")
65100 }
66101
67102 // Set cache model for browser-style caching
@@ -69,8 +104,8 @@ func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitCon
69104
70105 wkCtx.initialized = true
71106 log.Info().
72- Str("data_dir", dataDir).
73- Str("cache_dir", cacheDir).
107+ Str("data_dir", opts.DataDir).
108+ Str("cache_dir", opts.CacheDir).
74109 Msg("webkit context initialized")
75110
76111 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 FIRST71 // 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 WebContext77+ // 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 caching102 // 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 = true105 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, nil111 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 (
1313 "strings"
1414 "sync"
1515
16+ "github.com/bnema/dumber/internal/application/port"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
18+ "github.com/bnema/dumber/internal/infrastructure/env"
1719 "github.com/bnema/dumber/internal/logging"
1820 "github.com/bnema/puregotk-webkit/soup"
1921 "github.com/bnema/puregotk-webkit/webkit"
@@ -59,11 +61,13 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
5961
6062 // DumbSchemeHandler handles dumb:// URI scheme requests.
6163 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
6771 }
6872
6973 type configAppearancePayload struct {
@@ -76,8 +80,46 @@ type configAppearancePayload struct {
7680 DarkPalette config.ColorPalette `json:"dark_palette"`
7781 }
7882
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+
79120 type configPayload struct {
80121 Appearance configAppearancePayload `json:"appearance"`
122+ Performance configPerformancePayload `json:"performance"`
81123 DefaultUIScale float64 `json:"default_ui_scale"`
82124 DefaultSearchEngine string `json:"default_search_engine"`
83125 SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
@@ -88,9 +130,11 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
88130 log := logging.FromContext(ctx)
89131
90132 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,
94138 }
95139
96140 // Register default pages
@@ -124,7 +168,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
124168 return nil
125169 }
126170
127- return buildConfigResponse(config.Get())
171+ return h.buildConfigResponse(config.Get())
128172 }))
129173
130174 // API: Get default config (used by Reset Defaults in dumb://config)
@@ -133,11 +177,23 @@ func (h *DumbSchemeHandler) registerDefaults() {
133177 return nil
134178 }
135179
136- return buildConfigResponse(config.DefaultConfig())
180+ return h.buildConfigResponse(config.DefaultConfig())
137181 }))
138182 }
139183
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+
141197 resp := configPayload{
142198 DefaultUIScale: cfg.DefaultUIScale,
143199 DefaultSearchEngine: cfg.DefaultSearchEngine,
@@ -151,6 +207,27 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse {
151207 LightPalette: cfg.Appearance.LightPalette,
152208 DarkPalette: cfg.Appearance.DarkPalette,
153209 },
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+ },
154231 }
155232
156233 data, err := json.Marshal(resp)
@@ -169,6 +246,21 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse {
169246 }
170247 }
171248
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+
172264 // RegisterPage registers a handler for a specific path.
173265 func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
174266 h.mu.Lock()
@@ -419,6 +511,8 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
419511 }
420512
421513 // 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.
422516 func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
423517 if wkCtx == nil || wkCtx.Context() == nil {
424518 h.logger.Error().Msg("cannot register scheme: context is nil")
@@ -429,15 +523,31 @@ func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
429523 h.HandleRequest(reqPtr)
430524 })
431525
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)
433530
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")
441551 }
442552
443553 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]PageHandler64+ handlers map[string]PageHandler
63- assets embed.FS65+ 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.Logger67+ logger zerolog.Logger
66- mu sync.RWMutex68+ 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 pages140 // Register default pages
@@ -124,7 +168,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
124 return nil168 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 nil177 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 policies531+ // 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
248248 return nil, fmt.Errorf("webkit context not initialized")
249249 }
250250
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+ })
253258 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")
255260 }
256261
257262 // 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 storage251+ // 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 @@
1010 import { Spinner } from "$lib/components/ui/spinner";
1111 import * as Card from "$lib/components/ui/card";
1212 import * as Tabs from "$lib/components/ui/tabs";
13+ import { FlaskConical, RefreshCw } from "@lucide/svelte";
1314
1415 type ColorPalette = {
1516 background: string;
@@ -26,6 +27,41 @@
2627 description: string;
2728 };
2829
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+
2965 type ConfigDTO = {
3066 appearance: {
3167 sans_font: string;
@@ -36,11 +72,19 @@
3672 light_palette: ColorPalette;
3773 dark_palette: ColorPalette;
3874 };
75+ performance: PerformanceConfig;
3976 default_ui_scale: number;
4077 default_search_engine: string;
4178 search_shortcuts: Record<string, SearchShortcut>;
4279 };
4380
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+
4488 const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
4589 { key: "background", label: "Background" },
4690 { key: "surface", label: "Surface" },
@@ -62,6 +106,7 @@
62106 let saving = $state(false);
63107 let saveSuccess = $state(false);
64108 let saveError = $state<string | null>(null);
109+ let showRestartWarning = $state(false);
65110 let themeEvents = $state(0);
66111 let activeTab = $state("appearance");
67112
@@ -158,6 +203,11 @@
158203 saveSuccess = true;
159204 saving = false;
160205
206+ // Show restart warning if performance tab was active
207+ if (activeTab === "performance") {
208+ showRestartWarning = true;
209+ }
210+
161211 // Refresh config from backend (in case watcher normalized values)
162212 reloadConfig();
163213
@@ -250,6 +300,7 @@
250300 <Tabs.List class="bg-muted/60">
251301 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
252302 <Tabs.Trigger value="search">Search</Tabs.Trigger>
303+ <Tabs.Trigger value="performance">Performance</Tabs.Trigger>
253304 </Tabs.List>
254305 </div>
255306
@@ -374,6 +425,191 @@
374425 </Card.Content>
375426 </Card.Root>
376427 </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>
377613 </Tabs.Root>
378614
379615 <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
@@ -424,6 +660,19 @@
424660 {saveError}
425661 </div>
426662 {/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}
427676 {/if}
428677 </div>
429678 </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>

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close